diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..83ce06b --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +node_modules +**/node_modules +**/.next +**/dist +mod +server-snapshot +.superpowers +.git +**/test-results +**/playwright-report diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..8378333 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,5 @@ +* text=auto eol=lf +*.jar binary +*.png binary +*.bat text eol=crlf +backend/test/fixtures/** -text diff --git a/.gitignore b/.gitignore index 8b197ac..e460053 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ mod/run/ .env .env.* !.env.example +.superpowers/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..c3923cb --- /dev/null +++ b/README.md @@ -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 --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. diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..537901f --- /dev/null +++ b/backend/Dockerfile @@ -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"] diff --git a/backend/config/mod-categories.json b/backend/config/mod-categories.json new file mode 100644 index 0000000..f3db7ae --- /dev/null +++ b/backend/config/mod-categories.json @@ -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" + ] +} diff --git a/backend/migrations/001_init.sql b/backend/migrations/001_init.sql new file mode 100644 index 0000000..243c8aa --- /dev/null +++ b/backend/migrations/001_init.sql @@ -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 +); diff --git a/backend/migrations/002_files.sql b/backend/migrations/002_files.sql new file mode 100644 index 0000000..eaf5121 --- /dev/null +++ b/backend/migrations/002_files.sql @@ -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); diff --git a/backend/migrations/003_health_hourly.sql b/backend/migrations/003_health_hourly.sql new file mode 100644 index 0000000..e99037d --- /dev/null +++ b/backend/migrations/003_health_hourly.sql @@ -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); diff --git a/backend/migrations/004_admin.sql b/backend/migrations/004_admin.sql new file mode 100644 index 0000000..42b7830 --- /dev/null +++ b/backend/migrations/004_admin.sql @@ -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 +); diff --git a/backend/migrations/005_review_fixes.sql b/backend/migrations/005_review_fixes.sql new file mode 100644 index 0000000..0c7fe26 --- /dev/null +++ b/backend/migrations/005_review_fixes.sql @@ -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); diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..3c97c1d --- /dev/null +++ b/backend/package.json @@ -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" + } +} diff --git a/backend/src/app.ts b/backend/src/app.ts new file mode 100644 index 0000000..ee06508 --- /dev/null +++ b/backend/src/app.ts @@ -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 { + 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; +} diff --git a/backend/src/auth/admin.ts b/backend/src/auth/admin.ts new file mode 100644 index 0000000..c6ee041 --- /dev/null +++ b/backend/src/auth/admin.ts @@ -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 { + 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 | 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 { + await pool.query('DELETE FROM admin_sessions WHERE id = $1', [sessionId]); +} diff --git a/backend/src/cli.ts b/backend/src/cli.ts new file mode 100644 index 0000000..c4b153b --- /dev/null +++ b/backend/src/cli.ts @@ -0,0 +1,59 @@ +// Verwaltungs-CLI: +// cli admin:create 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 { + 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 '); + 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 | token:create'); + process.exitCode = 1; + } + } finally { + await pool.end(); + } +} + +main().catch((err) => { + console.error((err as Error).message); + process.exit(1); +}); diff --git a/backend/src/config.ts b/backend/src/config.ts new file mode 100644 index 0000000..c6a12ee --- /dev/null +++ b/backend/src/config.ts @@ -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 & { + sftp: { host: string; port: number; username: string; password: string; root: string } | null; +}; + +export function loadConfig(env: Record = 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 }; +} diff --git a/backend/src/db/migrate.ts b/backend/src/db/migrate.ts new file mode 100644 index 0000000..7a08e9b --- /dev/null +++ b/backend/src/db/migrate.ts @@ -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 { + 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; +} diff --git a/backend/src/db/pool.ts b/backend/src/db/pool.ts new file mode 100644 index 0000000..9398fcd --- /dev/null +++ b/backend/src/db/pool.ts @@ -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(pool: pg.Pool, fn: (c: pg.PoolClient) => Promise): Promise { + 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(); + } +} diff --git a/backend/src/ingest/auth.ts b/backend/src/ingest/auth.ts new file mode 100644 index 0000000..394c6cb --- /dev/null +++ b/backend/src/ingest/auth.ts @@ -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; +} + +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; rawBody: Buffer }, + opts: VerifyOpts, +): Promise { + 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 }; +} diff --git a/backend/src/ingest/route.ts b/backend/src/ingest/route.ts new file mode 100644 index 0000000..164cf0c --- /dev/null +++ b/backend/src/ingest/route.ts @@ -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, 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 }; + }, + ); + }; +} diff --git a/backend/src/ingest/tokens.ts b/backend/src/ingest/tokens.ts new file mode 100644 index 0000000..c46801a --- /dev/null +++ b/backend/src/ingest/tokens.ts @@ -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 { + 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 { + 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 { + await pool.query('UPDATE ingest_tokens SET revoked_at = now() WHERE revoked_at IS NULL'); +} diff --git a/backend/src/live/hub.ts b/backend/src/live/hub.ts new file mode 100644 index 0000000..96073ca --- /dev/null +++ b/backend/src/live/hub.ts @@ -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; + 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 { + return this.readyPromise; + } + + async start(): Promise { + 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((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 { + 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); + } +} diff --git a/backend/src/live/notifier.ts b/backend/src/live/notifier.ts new file mode 100644 index 0000000..58fa19e --- /dev/null +++ b/backend/src/live/notifier.ts @@ -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; +} + +/** 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 })]); + } + }); +} diff --git a/backend/src/parsers/ftbquests.ts b/backend/src/parsers/ftbquests.ts new file mode 100644 index 0000000..e2398d8 --- /dev/null +++ b/backend/src/parsers/ftbquests.ts @@ -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/.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..title`, `quest..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(); + 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}/.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)), + }; +} diff --git a/backend/src/parsers/log.ts b/backend/src/parsers/log.ts new file mode 100644 index 0000000..2f45405 --- /dev/null +++ b/backend/src/parsers/log.ts @@ -0,0 +1,93 @@ +import { createHash } from 'node:crypto'; +import { deterministicId, type AtmEvent } from '@atm/shared'; + +const MONTHS: Record = { + 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, + 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 }; +} diff --git a/backend/src/parsers/snbt.ts b/backend/src/parsers/snbt.ts new file mode 100644 index 0000000..083d27e --- /dev/null +++ b/backend/src/parsers/snbt.ts @@ -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); diff --git a/backend/src/parsers/vanilla.ts b/backend/src/parsers/vanilla.ts new file mode 100644 index 0000000..b40a0bf --- /dev/null +++ b/backend/src/parsers/vanilla.ts @@ -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/.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> }; + const stats: Record> = {}; + for (const [cat, entries] of Object.entries(root.stats ?? {})) { + const clean: Record = {}; + 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/.json` → one advancement event per completed, non-recipe advancement. */ +export function advancementsFileToEvents(uuid: string, json: string): AtmEvent[] { + const root = JSON.parse(json) as Record; + 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 }; + 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; +} diff --git a/backend/src/pipeline/process.ts b/backend/src/pipeline/process.ts new file mode 100644 index 0000000..95a95b7 --- /dev/null +++ b/backend/src/pipeline/process.ts @@ -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 = (c: PoolClient, e: EventOf, source: Source) => Promise; + +const projections = new Map(); + +export function registerProjection(type: T, fn: Projection): void { + const list = projections.get(type) ?? []; + list.push(fn as unknown as Projection); + projections.set(type, list); +} + +const batchEnd = new WeakMap Promise>>(); + +/** + * 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 { + 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; +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 { + 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: [], + }; +} diff --git a/backend/src/pipeline/projections/advancements.ts b/backend/src/pipeline/projections/advancements.ts new file mode 100644 index 0000000..8f0f62f --- /dev/null +++ b/backend/src/pipeline/projections/advancements.ts @@ -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], + ); + } +}); diff --git a/backend/src/pipeline/projections/combat.ts b/backend/src/pipeline/projections/combat.ts new file mode 100644 index 0000000..f165ce1 --- /dev/null +++ b/backend/src/pipeline/projections/combat.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], + ); +}); diff --git a/backend/src/pipeline/projections/files.ts b/backend/src/pipeline/projections/files.ts new file mode 100644 index 0000000..30a5393 --- /dev/null +++ b/backend/src/pipeline/projections/files.ts @@ -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 { + 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 { + 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}`); + } +}); diff --git a/backend/src/pipeline/projections/index.ts b/backend/src/pipeline/projections/index.ts new file mode 100644 index 0000000..1912dda --- /dev/null +++ b/backend/src/pipeline/projections/index.ts @@ -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'; diff --git a/backend/src/pipeline/projections/players.ts b/backend/src/pipeline/projections/players.ts new file mode 100644 index 0000000..7fbc517 --- /dev/null +++ b/backend/src/pipeline/projections/players.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 { + 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], + ); + } +} diff --git a/backend/src/pipeline/projections/server.ts b/backend/src/pipeline/projections/server.ts new file mode 100644 index 0000000..dcae73d --- /dev/null +++ b/backend/src/pipeline/projections/server.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], + ); +}); diff --git a/backend/src/pipeline/projections/sessions.ts b/backend/src/pipeline/projections/sessions.ts new file mode 100644 index 0000000..9ed72fa --- /dev/null +++ b/backend/src/pipeline/projections/sessions.ts @@ -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)], + ); +}); diff --git a/backend/src/pipeline/projections/stats.ts b/backend/src/pipeline/projections/stats.ts new file mode 100644 index 0000000..cbacc6f --- /dev/null +++ b/backend/src/pipeline/projections/stats.ts @@ -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], + ); +}); diff --git a/backend/src/routes/admin.ts b/backend/src/routes/admin.ts new file mode 100644 index 0000000..fc205d1 --- /dev/null +++ b/backend/src/routes/admin.ts @@ -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 }; + }); + }); +} diff --git a/backend/src/routes/live.ts b/backend/src/routes/live.ts new file mode 100644 index 0000000..2eb7bdf --- /dev/null +++ b/backend/src/routes/live.ts @@ -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); + }); +} diff --git a/backend/src/routes/public/avatar.ts b/backend/src/routes/public/avatar.ts new file mode 100644 index 0000000..9d40459 --- /dev/null +++ b/backend/src/routes/public/avatar.ts @@ -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(); + + 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); + } + }); +} diff --git a/backend/src/routes/public/index.ts b/backend/src/routes/public/index.ts new file mode 100644 index 0000000..9530d4a --- /dev/null +++ b/backend/src/routes/public/index.ts @@ -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 }); +} diff --git a/backend/src/routes/public/leaderboards.ts b/backend/src/routes/public/leaderboards.ts new file mode 100644 index 0000000..57453b0 --- /dev/null +++ b/backend/src/routes/public/leaderboards.ts @@ -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 => ({ + 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; + }); +} diff --git a/backend/src/routes/public/overview.ts b/backend/src/routes/public/overview.ts new file mode 100644 index 0000000..52d201d --- /dev/null +++ b/backend/src/routes/public/overview.ts @@ -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 => { + 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 => { + 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 })) }; + }); +} diff --git a/backend/src/routes/public/players.ts b/backend/src/routes/public/players.ts new file mode 100644 index 0000000..c0d446c --- /dev/null +++ b/backend/src/routes/public/players.ts @@ -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 => { + 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 { + const q = async >(sql: string, params: unknown[] = [uuid]) => (await pool.query(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 = {}; + const movement: Record = {}; + 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, + }; +} diff --git a/backend/src/routes/public/server.ts b/backend/src/routes/public/server.ts new file mode 100644 index 0000000..53f8f9f --- /dev/null +++ b/backend/src/routes/public/server.ts @@ -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 => { + 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(); + 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 => { + 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 => { + 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 => { + 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, + }; + }); +} diff --git a/backend/src/routes/public/shared.ts b/backend/src/routes/public/shared.ts new file mode 100644 index 0000000..628485d --- /dev/null +++ b/backend/src/routes/public/shared.ts @@ -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 { + 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.', +]; diff --git a/backend/src/server.ts b/backend/src/server.ts new file mode 100644 index 0000000..a859fb3 --- /dev/null +++ b/backend/src/server.ts @@ -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 }); diff --git a/backend/src/stats/leaderboards.ts b/backend/src/stats/leaderboards.ts new file mode 100644 index 0000000..2566d66 --- /dev/null +++ b/backend/src/stats/leaderboards.ts @@ -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, 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 { + 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> { + const out: Record = {}; + 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; +} diff --git a/backend/src/stats/metrics.ts b/backend/src/stats/metrics.ts new file mode 100644 index 0000000..f8fbb47 --- /dev/null +++ b/backend/src/stats/metrics.ts @@ -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; + } +} diff --git a/backend/src/stats/snapshots.ts b/backend/src/stats/snapshots.ts new file mode 100644 index 0000000..d25caab --- /dev/null +++ b/backend/src/stats/snapshots.ts @@ -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 { + 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 { + 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(); + } +} diff --git a/backend/src/worker.ts b/backend/src/worker.ts new file mode 100644 index 0000000..bdfa971 --- /dev/null +++ b/backend/src/worker.ts @@ -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(); diff --git a/backend/src/worker/fileSource.ts b/backend/src/worker/fileSource.ts new file mode 100644 index 0000000..8cf7c77 --- /dev/null +++ b/backend/src/worker/fileSource.ts @@ -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; + /** Reads `length` bytes from `start` (default: to end of file). */ + read(path: string, start?: number, length?: number): Promise; + close(): Promise; +} + +async function collect(stream: NodeJS.ReadableStream): Promise { + 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 { + 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 { + const end = length !== undefined ? start + length - 1 : undefined; + return collect(createReadStream(join(this.root, path), { start, end })); + } + + async close(): Promise {} +} + +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 | 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 { + 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 { + 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 { + if (this.connected) await this.client.end().catch(() => {}); + this.connected = null; + } +} diff --git a/backend/src/worker/scheduler.ts b/backend/src/worker/scheduler.ts new file mode 100644 index 0000000..3c222ad --- /dev/null +++ b/backend/src/worker/scheduler.ts @@ -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): Promise { + 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; diff --git a/backend/src/worker/sync.ts b/backend/src/worker/sync.ts new file mode 100644 index 0000000..d6e2fe6 --- /dev/null +++ b/backend/src/worker/sync.ts @@ -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('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(); + + // 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): Promise { + 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 { + 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; +} diff --git a/backend/test/admin.test.ts b/backend/test/admin.test.ts new file mode 100644 index 0000000..0eedb05 --- /dev/null +++ b/backend/test/admin.test.ts @@ -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); + }); +}); diff --git a/backend/test/db.test.ts b/backend/test/db.test.ts new file mode 100644 index 0000000..1e3e040 --- /dev/null +++ b/backend/test/db.test.ts @@ -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(); + }); +}); diff --git a/backend/test/fixtures/advancements/308a693c-2bd8-4d6b-8790-670aef5be601.json b/backend/test/fixtures/advancements/308a693c-2bd8-4d6b-8790-670aef5be601.json new file mode 100644 index 0000000..8af8ca5 --- /dev/null +++ b/backend/test/fixtures/advancements/308a693c-2bd8-4d6b-8790-670aef5be601.json @@ -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 +} diff --git a/backend/test/fixtures/ftbquests/308a693c-2bd8-4d6b-8790-670aef5be601.snbt b/backend/test/fixtures/ftbquests/308a693c-2bd8-4d6b-8790-670aef5be601.snbt new file mode 100644 index 0000000..82b8e21 --- /dev/null +++ b/backend/test/fixtures/ftbquests/308a693c-2bd8-4d6b-8790-670aef5be601.snbt @@ -0,0 +1,2417 @@ +{ + version: 1 + uuid: "308a693c2bd84d6b8790670aef5be601" + name: "CDOGamer#308a693c" + lock: false + rewards_blocked: false + task_progress: { + 27BABC4910038807: 1L + 431D7329CFCAF880: 1L + 6609E5E7EDE6F92B: 1L + 3C95552D03C16F3D: 1L + 3F04107B4D2907E0: 1L + 48A30B08A090BD75: 1L + 6BD970777103D55D: 1L + 047D297988E59B6B: 1L + 624ADD79136376C0: 1L + 2E04D0F6EEABE677: 1L + 5E51F5B17F77573E: 1L + 4E7FDA9DE2071EE5: 1L + 2A16BDCF5D671F6C: 1L + 195338A3977C682E: 1L + 76A7B9E9F755A223: 1L + 5FA5A4469CF8A9EF: 1L + 4178C3EBAD9F0670: 1L + 43FDC45CF464047E: 1L + 474E1537C5CA7FB7: 1L + 7DEB271808127E13: 1L + 0008773503CCA262: 1L + 2F56ECC8DBC90C01: 1L + 13F86953093670F6: 1L + 27BDA44CC3FFCCF1: 1L + 47A184CF57387DC7: 1L + 4EEF358CAC170CC4: 1L + 78D7794707E4BD83: 1L + 2612CACBDDF9AD1A: 1L + 50795B2B2A320D16: 1L + 1AA73DEFAA50FDF6: 1L + 4E4CF3303EF32A51: 1L + 1F44E7BEA2D191E5: 1L + 7CB9C080F0597A4D: 1L + 102E085F5F52E04E: 1L + 5E8A262AF1E9DB0B: 1L + 33B4B2E11DEF74E2: 1L + 0197CC61699DBE7D: 1L + 52B4CBE9BE14AD70: 1L + 1C1B919636DDC925: 1L + 7E2317305BD5F842: 1L + 3209E46C9B05C163: 1L + 2068F53F091BFD5A: 1L + 4671FA2855861C13: 1L + 012550B8C5B1BBCC: 1L + 46B42667B6BC1A3C: 1L + 1A6781CA48458EE5: 1L + 6B6B34B77A5563FC: 1L + 02C93CCE15B79995: 1L + 304A15731EA889F5: 1L + 1934789006427AE9: 1L + 6204AF3CD7D2CC37: 1L + 576428EF1CDDEF96: 1L + 294D97F3514A382B: 1L + 27DF6EE4B85F1792: 1L + 408C5263C4664195: 1L + 23B6BEDDD3B41602: 1L + 7D3BB67D10EA5245: 1L + 4E0A3C347EDD235A: 1L + 3298C53B3E5FAF14: 1L + 695C19A116BBBDBB: 1L + 3B9E51CF30228269: 1L + 4B60ACBCC3B46D1D: 1L + 290255C654A57980: 1L + 0C282319C6EAC76E: 1L + 6502D0124314BF25: 1L + 77FDF195DA1CE7F4: 1L + 6E3ACC74E0987981: 1L + 78910F1DA5FE3640: 1L + 746C44D800F4E4FB: 1L + 0AB9BED5E5144BAC: 1L + 76F3B5C3CB49DDBD: 1L + 38BA6B3FF1EC9185: 1L + 6EB023B3AEF61518: 1L + 17B9A643AFD7EB39: 1L + 704D0B6CBDB29691: 1L + 4346DAC18183627E: 1L + 211A2C594472A9C4: 1L + 2FD63B9DA3F02205: 1L + 09788D3638E59F3B: 1L + 2725700B5EA1CD75: 1L + 6DACC77807D1F342: 1L + 117AD4ACD2CB198A: 1L + 3B51C96356777FE6: 1L + 1693B07767081FB3: 1L + 6AA00AA3ADBA5F34: 1L + 7F8F61CB2A5279F1: 1L + 08BA6518F2BBDD85: 1L + 4D5FA238E199BECE: 1L + 64FADC78A8AD3FE4: 1L + 084D67D36A0859B7: 1L + 591B93F4B6397821: 1L + 62EB681EEB76FCDB: 1L + 6D88F09899BB4808: 1L + 7FCF6E8726BCC769: 1L + 6627658A032CDC95: 1L + 16670D70BD63FA37: 1L + 42D3D4E96812274C: 1L + 1960B985B54B9FBD: 1L + 126B3D11A754B899: 1L + 0045D90350F06441: 1L + 2BCD016F70368254: 1L + 2F6E6A8E20D0884E: 1L + 55A840C3871027EA: 1L + 35A07C18FFEEB872: 1L + 6A352964478AE499: 1L + 3B1F1A7CAC8B7BC7: 1L + 49F46466DB8EDD61: 1L + 3C45AF8C3DC5A45E: 1L + 29CAA814200ABBA8: 1L + 20BC17BCC5E270A6: 1L + 533879E84DC7CAEB: 1L + 03005DEF00AB48F3: 1L + 25F49B7B8EC6F48B: 1L + 4448DE84ABA737BE: 1L + 537CABE8F876E65A: 1L + 55628B1EECBB5E94: 1L + 67D884FA6DD22065: 1L + 52967F83338A3AC3: 1L + 14E43D7B404B8AD4: 1L + 6A0FAC4CA0768455: 1L + 03C590DB786810FB: 1L + 4044AC9B58C2DC35: 1L + 0F998B5ABA5A3DE8: 1L + 128DC243A78982C3: 1L + 365C31F15DC05036: 1L + 10B368D0D52F377B: 1L + 7570E5AAAC5CCA39: 1L + 7EA092725A784CA7: 1L + 3563498293CD3BF7: 1L + 6B84DAA99028ACEA: 1L + 4A10EADC3914EA38: 1L + 68DC39876E14FC1B: 1L + 2F7ABC9758AAC23E: 1L + 4632192573FD8501: 1L + 16BC496604F56E3B: 1L + 1869893A4F8E9E9C: 1L + 514207C426B8725F: 1L + 1AE233B4CCFE99F7: 1L + 17E6AEEE9C081DB1: 1L + 72CE838EFDDE6479: 1L + 51A617F8FBBFA712: 1L + 44529CBD7ED7502A: 1L + 4FBF146C9301DEC7: 1L + 6BD95DE2DB508F9A: 1L + 540DD4E3E568DBF2: 1L + 41FF171C7D97B574: 1L + 279DA565E9ADF7B0: 1L + 703D7D142BD7CEC9: 1L + 5CEB2D56A9EA74D0: 1L + 03F5ECD810F037D8: 1L + 7C48234066589145: 1L + 1A66B7FFB4E2163E: 1L + 73989F729ED52289: 1L + 073D1C45B3018BBA: 1L + 78DA1FE8F54FDAE8: 1L + 3768935221C60A6D: 1L + 4C0098C62CDF1FF9: 1L + 539C6F64C251543B: 1L + 2ACCBA73F15785AE: 1L + 41910A3BC6C0E838: 1L + 70B760551AA03D3A: 1L + 09C547CC92623525: 1L + 4DB11C6F180743E9: 1L + 103C42C743E2A2DB: 1L + 12083A6D1B9E9DD2: 1L + 17C665008B4C0CD7: 1L + 202931B62E242E7F: 1L + 426BED88B406A44B: 1L + 0138936FB4DD8A88: 1L + 6F496FD2BCFE21BA: 1L + 667004CD0469493D: 1L + 71A3B742D8757C89: 1L + 6467C991A6D45E36: 1L + 6CB34AE622DF9CC2: 1L + 5936B8B8EDE0AE55: 1L + 39CEB20C5018F460: 1L + 499AF83C86984D26: 1L + 4B6C5B2099B18AB7: 1L + 1BAC364D2AF426D3: 1L + 759571AAA3323C3C: 1L + 5824C93C84312142: 1L + 3027F7F9B0D0EB88: 1L + 2228CC59A99F49E9: 1L + 4D849193A07D4F83: 1L + 5999B2D204EC280E: 1L + 02C822663913DFCF: 1L + 53A6720343E57302: 1L + 20D72B07EE11DDCB: 1L + 7DABA35BECFBE1E2: 1L + 6DCF32EB40B8E9C6: 1L + 2027616929A4AA39: 1L + 064B1841F9288CC3: 1L + 5C547747E49BA6BB: 1L + 324B835F4DCD19E1: 1L + 318E7C9C3687A8A4: 1L + 13D240DA839AE0F9: 1L + 3916C13A250BB5E9: 1L + 1EA4F0AB480D485C: 1L + 206791A6A8DA0F16: 1L + 7A7F8E95E037CD5E: 1L + 07F6D01E644305E7: 1L + 3DF1F367C3EA9631: 1L + 0BAEC53DD32C9870: 1L + 042B93D43308564F: 1L + 6FF63A559722919C: 1L + 1E87CE2F633083A4: 1L + 16E5E87F1CAC042A: 1L + 5EE73B206A8A3021: 1L + 34424E87D679D3AA: 1L + 28FC7C19E2A3E1B9: 1L + 1ACDF9B8AED8F168: 1L + 4B2621F8D8FA7CA7: 1L + 606B73596F3EAFE5: 1L + 50624A39AD15A8A6: 1L + 67DD297C9649EFAB: 1L + 324DACE30B49D25B: 1L + 1C677CD515E8862B: 1L + 38FFA24FDD924194: 1L + 4555EA054687BD5F: 1L + 52BB142F044075B4: 1L + 2A07AF6A43DCEA8C: 1L + 238F4D40E6D9238E: 1L + 792B4AC07BA2D148: 1L + 10B65DF825ACC0E7: 1L + 1ADA8EDA9196471C: 1L + 43BC5EBC129DCE92: 1L + 5A081977DB9407EA: 1L + 1DCF1EB2F1D2F966: 1L + 2065DC263D762764: 1L + 77211E454F98A72B: 1L + 55DD09212E3DB168: 1L + 62E0C347DC32EA77: 1L + 3E3EBDB1D26C3117: 1L + 586916ED82D2DD2C: 1L + 41A47F38D96A9747: 1L + 2BECEC3EFA8F6DA3: 1L + 58D16A3D3A480A37: 1L + 0D3DC52244DC7ADD: 1L + 67D42E0DB8AA7578: 1L + 276930FE3A5CD916: 1L + 2E885362FFDF770D: 1L + 70367F545A9CB4D0: 1L + 4BEC4730588463FE: 1L + 11EDB05BC54E502C: 1L + 4CDD283558108D8B: 1L + 1C4E971513EF2B15: 1L + 4C72CA4823807604: 1L + 049799C40545AB1C: 1L + 02ED3169ADC0E7F9: 1L + 091B97D20403D516: 1L + 185D97FCA1C83569: 1L + 564D0E533237E951: 1L + 6877D91D6603E4FD: 1L + 6E9280AEB3B1D973: 1L + 2501510A452D3752: 1L + 59E3B2A8BA8579C0: 1L + 2BA7FC10243AA011: 1L + 58541E64C0528FCB: 1L + 103895C9792183DB: 1L + 76378687551A79C3: 1L + 28A1D25CDDBF5163: 1L + 620BF9687558DE44: 1L + 43DC656348DC56EB: 1L + 25AE4E531CE97B98: 1L + 70A3CED85106362F: 1L + 794CD7B21426F36D: 1L + 39E3DA5BA041E2A4: 1L + 7EDA9BD14A177D8D: 1L + 1BB0FB49B96E22E2: 1L + 0B653D464BDF40AD: 1L + 4B274207A9646FE9: 1L + 28277BA9F319240D: 1L + 71321938044ADBE4: 1L + 4D389CB7B26F5EB5: 1L + 0EEB2867C1D2D227: 1L + 3ABECD22D09DEEFB: 1L + 0EBE231AB868C868: 1L + 400E42F53FDE783C: 1L + 17F07D6404668DE6: 1L + 742BD37F93033B43: 1L + 4BF8E17EC585CA54: 1L + 3FD63A725A23D407: 1L + 4795ADB0596D26DA: 1L + 52A3EC00B0885573: 1L + 40B5F7ECA67EBF03: 1L + 66013401F4FA44FC: 1L + 6990F3804C13A8E5: 1L + 3D88946A32BFA35F: 1L + 0B67D1F6E147566B: 1L + 576227D79B7D383D: 1L + 587C27CE66ABA2F0: 1L + 2A6F0D976724CBF3: 1L + 664ADBD5CACCEC65: 1L + 3839A67493B2E5C3: 1L + 61C6CFA5DB18E2DC: 1L + 23553673B9AC2E61: 1L + 4EA95AE9AF641FB4: 1L + 5CA7453233441165: 1L + 078D36F0A6A3F09C: 1L + 6C4B8A2662030CB0: 1L + 4F13A02FB0055A62: 1L + 2CE553B282962E01: 1L + 3E038C9465AFD2DF: 1L + 5E7A46B9CB8A2523: 1L + 19C973A3F42D631C: 1L + 292C044797A58CC1: 1L + 41BCFBEC6E169507: 1L + 1F2490B2B9A2B5F9: 1L + 39AD121BCB73453D: 1L + 1758F9A1E399448B: 1L + 2811A593A759354C: 1L + 55D98C63A1A01713: 1L + 297D80507C3A56E1: 1L + 06CFF66846E1CE0F: 1L + 285A53DB9D54595D: 1L + 7033C2D58700433D: 1L + 4D6630377238B530: 1L + 729C1974AE346ECA: 1L + 7C3D8A97769BBBE3: 1L + 7D14A6A6ABBA9960: 1L + 1F7C3D5DE3CE359D: 1L + 337725ECA2C04090: 1L + 49DC1FFCD031DB6E: 1L + 7D9A58EBADE91F54: 1L + 477AAD8E6A0BB944: 1L + 7CC983792E1265FD: 1L + 577EC349CB79E93E: 1L + 3CAD6AD06BE4C3D3: 1L + 14C0CA9C756B3ABD: 1L + 64D374ADCCF2E14C: 1L + 6EBC44E6FDA8A350: 1L + 713EFE882A8DF739: 1L + 6B24D8667BC856A3: 1L + 418E333710652BFA: 1L + 676E0375B6D394DE: 1L + 59DE118DD6597A4E: 1L + 05FD7D9C29619813: 1L + 6205B6B93B306A12: 1L + 328177CC5FD61DA4: 1L + 4E28970476CABACD: 1L + 08082713F1556E3A: 1L + 4559773DB06CEDD6: 1L + 62C6F14FE6A82305: 1L + 323A434E1AF54408: 1L + 1311C09169CA7055: 1L + 33218EFDD498E9DB: 1L + 00DDA48F9632A5C1: 1L + 4F664D5E6C5FDE3B: 1L + 21FBC4E0F668347C: 1L + 616C07AFBAFA7AE4: 1L + 250A0320FF2BC775: 1L + 743C664C4E8EE776: 1L + 4B0A4027FACE9108: 1L + 14ACE78261F4EE35: 1L + 720CE629EA4E37D0: 1L + 39FE4ED3B2A83A42: 1L + 0A2949304DBB3D75: 1L + 13AFEBED5C116FC8: 1L + 26FAC6C93C935044: 1L + 0A3853BD4F62B78E: 1L + 4C45690D5EBCAB90: 1L + 46803BBAC2069773: 1L + 1E7A57902EA62332: 1L + 3CAFF729F25A9850: 1L + 3893B57532491926: 1L + 5B7926B511000F1F: 1L + 10A16F89D4AD238D: 1L + 1F65F84DA1EAFB3E: 1L + 200CE35BF3C04F6F: 1L + 2E74430108DE6170: 1L + 2E116AC5F25650BE: 1L + 4F6FDB5872FD9E61: 1L + 34909E6C5CB47A43: 1L + 43A5CD740F24A3ED: 1L + 41C5BF6EDEF88B0B: 1L + 51CC9A8E36385400: 1L + 44BDEA5268F6ABE6: 1L + 5B32C56CD3D3FAEA: 1L + 0C99990D814C2823: 1L + 28EABAF0B91E94AE: 1L + 43063168AB07D6FC: 1L + 781B2DF9116224AE: 1L + 557074D4BFBAB82E: 1L + 53B3983C04A5CB1C: 1L + 2A1D9DE362310D7E: 1L + 68C11540A9010775: 1L + 79F67E07C28F8E97: 1L + 2E92D23F149C142D: 1L + 347729D52A71A889: 1L + 268319B4D86AF1FA: 1L + 4558919345C3BE5D: 1L + 31D0730D68948E3B: 1L + 47DA42491982BD41: 1L + 41A5F4BEC3477FEF: 1L + 6B772FA03562537D: 1L + 79CC596EB3BF6A64: 1L + 035EB822D59843CF: 1L + 33E5A303B907306B: 1L + 4AA065E761BB6A42: 1L + 1476F390A6789572: 1L + 11655B2807FF11CA: 1L + 49459B4370DD8D1A: 1L + 76C92D826994D92B: 1L + 32E6C837728B2EF1: 1L + 7213278CEE1B25A3: 1L + 3CC50EAEEE1E8A56: 1L + 40B39896B6CD41C6: 1L + 77329B944064E893: 1L + 1CC0E97BB8DC4F35: 2L + 58BF5D3267CF02F8: 2L + 0EA4B08DAAFA4287: 2L + 18980EBAE4ED777A: 2L + 4B83A0D1C2C3C226: 2L + 6BA6847A172C8DD7: 2L + 423AF6C6647B1626: 2L + 381CBCFB71018C25: 2L + 24BA89BD9898759B: 2L + 1AB92CF7F0246D21: 2L + 7E538C87D777B809: 2L + 53C23DB8B4D1CEE2: 2L + 3621D48E2CFAF2C2: 2L + 045D1087A2692360: 2L + 1AFB7D87A71F862A: 3L + 36AD2A449FE452E8: 3L + 01BBDC6956BD2DD3: 3L + 0EE813C344409F59: 3L + 78AC8EC04CDAFFDE: 3L + 7F822A21A9F71A4F: 3L + 5E2F12A4A10B340F: 3L + 7F08D4E77359BF4A: 3L + 6890EB2819D88073: 3L + 31103B425588A4DF: 3L + 03C7F9CB0DDB2D9B: 3L + 7330FAC338D718B8: 3L + 3824247686C3D875: 4L + 1DF996B4E387DB41: 4L + 73F9C130378195B9: 4L + 3FBFC3BAACA69389: 4L + 5B35720DF93CE2DB: 5L + 5457CD8C1ABA0B9E: 5L + 18644A08B73A4B12: 5L + 611CC8005150DC9B: 6L + 76BCC3C288514737: 6L + 73B6ED422F9292D1: 8L + 04DE1F990F95E617: 8L + 78082DDA22FC7B9B: 8L + 52874F5B79ECAE65: 9L + 5801AF8000768808: 10L + 015CB0D172D0712A: 10L + 3AD10C31CB6BA4C2: 10L + 6206DC761D4CDDFE: 10L + 00655AC734444E54: 12L + 2E17443A1BF2A0DA: 12L + 173044E1C1134A80: 16L + 460FA61A72720319: 16L + 21FC6D010E5D5360: 16L + 0F94F7427F99DAFE: 16L + 0F8ACB07CFA8CB33: 16L + 2E97226C40818239: 17L + 0CA0B32D52395A39: 20L + 5056DC37AEF523E5: 32L + 51471254BFEBDAA7: 32L + } + started: { + 3BC0A50886A3222B: 1791559740993L + 5B00676D79306EA2: 1791559740993L + 0000000000000001: 1791559740993L + 33218EFDD498E9DB: 1791559740993L + 2C4299D33D419AA0: 1791559741026L + 4293754F9B2D05F0: 1791559741026L + 00DDA48F9632A5C1: 1791559741026L + 730130CCF1CF5119: 1791559741088L + 78910F1DA5FE3640: 1791559741088L + 11919EBB416B2BD0: 1791559741088L + 3DF1F367C3EA9631: 1791559741105L + 52A3EC00B0885573: 1791559741105L + 18AA64DF63F68762: 1791559741105L + 1F748ACA3976A92F: 1791559741105L + 12AD9789D962B179: 1791559741105L + 08BA6518F2BBDD85: 1791559741106L + 14E43D7B404B8AD4: 1791559741106L + 3009018DEC1EC952: 1791559741106L + 4EBB2171D5A3AAAC: 1791559741106L + 692183ACBB2AABF7: 1791559741106L + 76DF5FAA7961E5E7: 1791559741106L + 24151251C8730F2E: 1791559741122L + 40AD1851C6851925: 1791559741122L + 0B653D464BDF40AD: 1791559741122L + 0F998B5ABA5A3DE8: 1791559741123L + 0496E8E786262464: 1791559741123L + 59395B6806F2A98A: 1791559741123L + 0731FA8830F28280: 1791559742633L + 0A3853BD4F62B78E: 1791559742633L + 196312DF0D2E2CB6: 1791559742633L + 44F441765D7CB182: 1791559747853L + 13F86953093670F6: 1791559747853L + 637EF6D9DFC59EE8: 1791559747853L + 61BE7882EE0E6FAF: 1791567234397L + 064B1841F9288CC3: 1791567234397L + 6F65216B13E9FFA9: 1791567234397L + 7A5C327771634A7D: 1791567234397L + 7213278CEE1B25A3: 1791567234397L + 1A66B7FFB4E2163E: 1791567234398L + 35C46E6E27C9CB1A: 1791567234398L + 720CE629EA4E37D0: 1791567234398L + 06FFBB12CFA87815: 1791567234398L + 0052E5592A6BBEC1: 1791567234399L + 0EBE231AB868C868: 1791567234399L + 732824E03978A934: 1791567234400L + 5CF320E9C4C1B1E1: 1791567234400L + 46803BBAC2069773: 1791567234400L + 44688FC2E34FF09F: 1791567234400L + 285A53DB9D54595D: 1791567234400L + 5577F49A93ADB19C: 1791567275048L + 6609E5E7EDE6F92B: 1791567275048L + 1FAAEF0B9FD05CB9: 1791567275048L + 3F185554793CCE54: 1791567306408L + 4AA065E761BB6A42: 1791567306408L + 292C044797A58CC1: 1791567306410L + 5151CDD8FCDE7A07: 1791567306410L + 4B9D26E0087CD163: 1791567306410L + 43BB00A7A7E1042C: 1791567306412L + 21D323D95F5A7DB3: 1791567306412L + 049799C40545AB1C: 1791567306412L + 6F5F1C525CF08F96: 1791567306416L + 279DA565E9ADF7B0: 1791567306416L + 7A72E12C3839D165: 1791567306424L + 0CA0B32D52395A39: 1791567306424L + 0460DDEEFDEB0AAF: 1791567306425L + 3ABECD22D09DEEFB: 1791567306425L + 007B547630FF0478: 1791567306425L + 3166CA00032C18F7: 1791567306435L + 5D8E83698AEF1092: 1791567306435L + 3CC50EAEEE1E8A56: 1791567306435L + 4E9B35DCA018FD42: 1791567314150L + 3CAD6AD06BE4C3D3: 1791567314150L + 4EAAE0CEB26378A6: 1791567314150L + 7975C7145572C438: 1791567320669L + 5CA7453233441165: 1791567320669L + 2EE4E88FC814AFA4: 1791567320670L + 2E885362FFDF770D: 1791567320670L + 09955CF85F579E0F: 1791567342952L + 7D14A6A6ABBA9960: 1791567342952L + 2BECEC3EFA8F6DA3: 1791567342953L + 1972B36F72808D55: 1791567342953L + 173044E1C1134A80: 1791567342954L + 57F47BA495B27F83: 1791567342955L + 02C822663913DFCF: 1791567342955L + 4CDD283558108D8B: 1791567461795L + 2E6D11A5F5626A6C: 1791567461795L + 27BABC4910038807: 1791567470844L + 0EB6ED88895366D0: 1791567470844L + 539C6F64C251543B: 1791567485048L + 24C9BA6DC32CFAD9: 1791567485048L + 1D42B373285DEF81: 1791567485050L + 704D0B6CBDB29691: 1791567485050L + 6D88F09899BB4808: 1791567485054L + 42D173B9FF8D16E4: 1791567485054L + 5D045EF1AB73DF70: 1791567485054L + 106884349C9FF3BA: 1791567537001L + 1E7A57902EA62332: 1791567537001L + 2094D1CE4BA66091: 1791567537001L + 4E28970476CABACD: 1791567537001L + 0481E975F317F64D: 1791567733983L + 7CC983792E1265FD: 1791567733983L + 092BB595F75D5536: 1791567928645L + 6B772FA03562537D: 1791567928645L + 276930FE3A5CD916: 1791568002592L + 689F32883C4E9502: 1791568002592L + 5103E2D95685ADFF: 1791568002599L + 43BC5EBC129DCE92: 1791568002599L + 7D359283625C1869: 1791568002599L + 4B552E9D05FF5415: 1791568123194L + 624ADD79136376C0: 1791568123194L + 4EEF358CAC170CC4: 1791568123198L + 219B7A003658D24F: 1791568123198L + 15540271BF17C2E4: 1791568123198L + 3F68BA498E877C8C: 1791568123202L + 59E3B2A8BA8579C0: 1791568123202L + 1ADA8EDA9196471C: 1791568135548L + 4B7D387CEFF9667E: 1791568135548L + 100C477F4E63F20A: 1791568135552L + 57A7A5C79389A96A: 1791568135552L + 2811A593A759354C: 1791568135552L + 69F11A94D44AB5CF: 1791568139344L + 08082713F1556E3A: 1791568139344L + 3917DECAEF56A06A: 1791568528495L + 25F49B7B8EC6F48B: 1791568528495L + 53A6720343E57302: 1791568673745L + 0C82CE3AFBD48C1E: 1791568673745L + 05E614FDA677D85E: 1791568673749L + 1827DEEA2DF1B144: 1791568673749L + 78D7794707E4BD83: 1791568673749L + 64FADC78A8AD3FE4: 1791568673753L + 54735C5FCD2077B3: 1791568673753L + 0F96DC3563DA78EF: 1791568673753L + 38BA6B3FF1EC9185: 1791568705096L + 4791C8461B1B4D5F: 1791568705097L + 4DF683461225690F: 1791568705097L + 7C3D8A97769BBBE3: 1791568705097L + 711826BBCA832EE2: 1791568705100L + 2A07AF6A43DCEA8C: 1791568705100L + 77329B944064E893: 1791568705102L + 58B125BD4876054C: 1791568705102L + 23983F4DC524B14B: 1791568705102L + 175D943160C10646: 1791568705108L + 297D80507C3A56E1: 1791568705108L + 2FB24A5A597459FC: 1791568705108L + 4F89C04CEBF812F9: 1791568705108L + 400E42F53FDE783C: 1791568705108L + 4EEDECACDE8F5A67: 1791568705108L + 76EA017B12E8F01B: 1791568705115L + 128DC243A78982C3: 1791568705115L + 1F81EA5E45424308: 1791568705115L + 4D6630377238B530: 1791568705115L + 02C93CCE15B79995: 1791568705116L + 4C507C004144BFEE: 1791568705116L + 698A959C9E449592: 1791568709795L + 03005DEF00AB48F3: 1791568709795L + 70ED849A2EB44DEE: 1791568709796L + 41A47F38D96A9747: 1791568709796L + 1C1B919636DDC925: 1791568725291L + 2B9AF0D6ED8BA628: 1791568725291L + 6B2027DA7AA6FF34: 1791568760952L + 3C45AF8C3DC5A45E: 1791568760952L + 2A6EBEEBAB882679: 1791568760952L + 51CC9A8E36385400: 1791568809447L + 5A94A2664BFDD7B9: 1791568809447L + 1DB294A8F8686321: 1791568809447L + 0682DC1F2417DAEB: 1791568809451L + 7EDA9BD14A177D8D: 1791568809451L + 134608BB2B04D96E: 1791568809454L + 073D1C45B3018BBA: 1791568809454L + 2893F483C10293E6: 1791568809457L + 07210DDF872160BA: 1791568809457L + 408C5263C4664195: 1791568809457L + 3A9AC6AB4407DC32: 1791568809464L + 5E31DF282998B992: 1791568809464L + 28A1D25CDDBF5163: 1791568809464L + 7658C1C663394E85: 1791568851461L + 10A16F89D4AD238D: 1791568851461L + 5AC1BE754210429E: 1791568854103L + 4F13A02FB0055A62: 1791568854103L + 3708A4780ACEB34E: 1791568856007L + 52BB142F044075B4: 1791568856007L + 0C93D7A607AB8B83: 1791568858252L + 103C42C743E2A2DB: 1791568858252L + 28FDB2D13D0DDE8E: 1791568879444L + 0138936FB4DD8A88: 1791568879444L + 1659A59303C15CC9: 1791568879448L + 5E8A262AF1E9DB0B: 1791568879448L + 72717D1135486D7F: 1791568879452L + 73B6ED422F9292D1: 1791568879452L + 6F62B5510FA881CD: 1791568879454L + 52967F83338A3AC3: 1791568879454L + 55628B1EECBB5E94: 1791568879464L + 497AE687B7E95DE8: 1791568879464L + 746C44D800F4E4FB: 1791568892142L + 282CE3A088AE5CFB: 1791568892142L + 1FE052F643401232: 1791568892145L + 7C48234066589145: 1791568892145L + 372B5BF416CA83A4: 1791568903152L + 1DCF1EB2F1D2F966: 1791568903152L + 4B35C01F5D0AAC58: 1791568903158L + 22C20C1827FE6805: 1791568903158L + 6E9280AEB3B1D973: 1791568903158L + 23B6BEDDD3B41602: 1791568903158L + 2262AB9F934165D0: 1791568903158L + 2027616929A4AA39: 1791568903160L + 25EE7E0119010291: 1791568903160L + 0D990A1D1E088701: 1791568903160L + 4DE1158931F84F22: 1791568908340L + 73F9C130378195B9: 1791568908340L + 426BED88B406A44B: 1791568908341L + 1FC7ABEAB5018E79: 1791568908341L + 0A52D0932DA3F809: 1791568908345L + 26FAC6C93C935044: 1791568908345L + 37EBB8E0D6E5F821: 1791568908345L + 185D97FCA1C83569: 1791568908351L + 0A093D8C4429B627: 1791568908352L + 77211E454F98A72B: 1791568908352L + 5AEA705D6A64A982: 1791568908352L + 6DCF32EB40B8E9C6: 1791568908352L + 557074D4BFBAB82E: 1791568908352L + 47F38E606AD3FF53: 1791568908352L + 4AB0DD227471FDBF: 1791568908353L + 1F2490B2B9A2B5F9: 1791568908353L + 6AEC23C6D526F362: 1791568908377L + 4D4DE266A2A325B0: 1791568908377L + 47DA42491982BD41: 1791568908377L + 7D38BC3DB3406F51: 1791569086641L + 39AD121BCB73453D: 1791569086641L + 6627658A032CDC95: 1791569086643L + 752DD0BF36B70D88: 1791569086643L + 76C63670624C3C56: 1791569086645L + 0D3DC52244DC7ADD: 1791569086645L + 703D7D142BD7CEC9: 1791569086662L + 47750F3CB020104C: 1791569086662L + 73180B829B960E63: 1791569086662L + 051E0C85E7B71CE0: 1791569247356L + 2E116AC5F25650BE: 1791569247356L + 14ACE78261F4EE35: 1791569247359L + 211FD55DEB635A98: 1791569247359L + 4555EA054687BD5F: 1791569247363L + 7242F591DA474B37: 1791569247363L + 5CCF92238B23EDC9: 1791569247369L + 1F44E7BEA2D191E5: 1791569247369L + 66013401F4FA44FC: 1791569247376L + 7CB9C080F0597A4D: 1791569247376L + 0542FE108089A0A2: 1791569247376L + 26E6ED94168A05C4: 1791569250565L + 131EC039435B8878: 1791569250565L + 1C677CD515E8862B: 1791569250565L + 72E1C19156633391: 1791569262706L + 2CE553B282962E01: 1791569262706L + 4CCEC13CC5A5772D: 1791569262716L + 38FFA24FDD924194: 1791569262716L + 4D389CB7B26F5EB5: 1791569438546L + 2B5E14A47062F83C: 1791569438546L + 5056DC37AEF523E5: 1791569455511L + 18A429E7F56AF5A9: 1791569455511L + 7D5B36BF3EC0C93F: 1791569455511L + 2B05A29C62676EB2: 1791569455512L + 5457CD8C1ABA0B9E: 1791569455512L + 3AD10C31CB6BA4C2: 1791569455512L + 444ACE285311ECB4: 1791569455512L + 146232B8504789C1: 1791569455512L + 51471254BFEBDAA7: 1791569455512L + 6E3ACC74E0987981: 1791569455588L + 41C0948CD9D50322: 1791569455588L + 7276892E129A739B: 1791569464998L + 0EEB2867C1D2D227: 1791569464998L + 7FCF6E8726BCC769: 1791569464999L + 6A352964478AE499: 1791569464999L + 45996B848792379E: 1791569508412L + 1476F390A6789572: 1791569508412L + 283CA6DB77D5D2EB: 1791569568198L + 1F18B64C84C8809D: 1791569568198L + 18644A08B73A4B12: 1791569568198L + 5801AF8000768808: 1791569568198L + 63A1F25DF658928A: 1791569568198L + 0F94F7427F99DAFE: 1791569568198L + 2D7E335B9D780E70: 1791569568198L + 0F8ACB07CFA8CB33: 1791569568198L + 00655AC734444E54: 1791569586047L + 555957ED58ABF8F8: 1791569586047L + 015CB0D172D0712A: 1791569586048L + 42822B1E8A53D051: 1791569586048L + 410E0DD607CB3469: 1791569586048L + 4748831E75A840BE: 1791569586048L + 5B35720DF93CE2DB: 1791569586048L + 2E17443A1BF2A0DA: 1791569586048L + 753B3EF7CC94A6DD: 1791569685406L + 3456E0C530C0038E: 1791569685406L + 19C973A3F42D631C: 1791569685406L + 76F3B5C3CB49DDBD: 1791569687090L + 377A41499D8218C9: 1791569687090L + 7D3BB67D10EA5245: 1791569687094L + 7AF827D2D101D343: 1791569687094L + 010607466D565B53: 1791569687094L + 13D240DA839AE0F9: 1791569687097L + 67B87570C340C243: 1791569687103L + 40B39896B6CD41C6: 1791569687103L + 68DC39876E14FC1B: 1791569714596L + 5CE44C58A470E48E: 1791569714596L + 7C3F65B5DAC7E57B: 1791569716367L + 2A6F0D976724CBF3: 1791569716367L + 1934789006427AE9: 1791569790745L + 022C38096F79EF07: 1791569790745L + 7F8F61CB2A5279F1: 1791569790748L + 792AD692F1468B1F: 1791569790748L + 1FFF00B5D90C3905: 1791569790748L + 62C60FF24B2F7290: 1791569790759L + 6204AF3CD7D2CC37: 1791569790759L + 5936B8B8EDE0AE55: 1791569958847L + 0596F6C70758EEC0: 1791569958847L + 67D42E0DB8AA7578: 1791569958849L + 00F00B0B66059996: 1791569958849L + 360B150190A5C894: 1791569958849L + 16C74B5F99AD6724: 1791569958849L + 6EBC44E6FDA8A350: 1791569958849L + 713EFE882A8DF739: 1791569958849L + 41C5BF6EDEF88B0B: 1791569958849L + 70CCE558E03227AB: 1791569958849L + 48A30B08A090BD75: 1791569958851L + 3DEB33F78398EAD6: 1791569958851L + 6CD7D99AA102A5C3: 1791569958851L + 798B592E956AD28C: 1791569958857L + 2F6E6A8E20D0884E: 1791569958857L + 7671AFAA1EFD287F: 1791569958857L + 4559773DB06CEDD6: 1791569958857L + 23631A4A743D4F0C: 1791569966296L + 2E92D23F149C142D: 1791569966296L + 4C72CA4823807604: 1791570082640L + 042B93D43308564F: 1791570086894L + 55D98C63A1A01713: 1791570094707L + 3685B52446DEC809: 1791570117491L + 16BC496604F56E3B: 1791570117491L + 695C19A116BBBDBB: 1791570117497L + 7377C657CED885AC: 1791570278098L + 6AA00AA3ADBA5F34: 1791570278098L + 667004CD0469493D: 1791570278102L + 1CC4F8570A7A99EB: 1791570278102L + 3FBFC3BAACA69389: 1791570458694L + 2DDA04088C8220D2: 1791570458694L + 56B11606F5F4DAB2: 1791570461856L + 3298C53B3E5FAF14: 1791570461856L + 011B8BD02D80DDD4: 1791570461856L + 5D5563142917495A: 1791570491201L + 4B274207A9646FE9: 1791570491201L + 7D279FC39DA5C630: 1791570700150L + 41A5F4BEC3477FEF: 1791570700150L + 6ECB27052FCB874E: 1791570757348L + 759571AAA3323C3C: 1791570757348L + 195338A3977C682E: 1791570856294L + 392F34FD4FCC794A: 1791570856294L + 224CE21E56703F6E: 1791570856297L + 347729D52A71A889: 1791570856297L + 78082DDA22FC7B9B: 1791570987424L + 40F634AC38F42B88: 1791570987424L + 62EB681EEB76FCDB: 1791571081315L + 70C7D92844089DEB: 1791571081315L + 340CDF4357E22A43: 1791571282198L + 14C0CA9C756B3ABD: 1791571282198L + 4E7FDA9DE2071EE5: 1791571282206L + 300F2E45D185A9A1: 1791571282206L + 7E2317305BD5F842: 1791571303297L + 29448D08C8777425: 1791571303297L + 67D884FA6DD22065: 1791571306360L + 3BF5B8DE7A1CC4D7: 1791571306360L + 0893EFCAC7031FEA: 1791571322351L + 1AA73DEFAA50FDF6: 1791571322351L + 32C45C6C811FB5F9: 1791571322358L + 2BCD016F70368254: 1791571322358L + 474E1537C5CA7FB7: 1791571325447L + 6890EB2819D88073: 1791571535201L + 0A1C2A7A8617D3E4: 1791571535201L + 3F5B5D6B788E0A73: 1791571624294L + 1ACDF9B8AED8F168: 1791571624294L + 6467C991A6D45E36: 1791571624301L + 31FD9EA513E0D010: 1791571624301L + 217DB12EA32E1724: 1791571699349L + 0C282319C6EAC76E: 1791571699349L + 73350AD668200E99: 1791571727149L + 4B2621F8D8FA7CA7: 1791571727149L + 1311C09169CA7055: 1791571985701L + 78D1C97A542133D8: 1791571985701L + 6502D0124314BF25: 1791571985702L + 615C3E012B2DECD3: 1791571985702L + 43FDC45CF464047E: 1791572009611L + 0F14270F0A718C01: 1791572009611L + 7DEB271808127E13: 1791572029446L + 409A92D40F539485: 1791572029446L + 576227D79B7D383D: 1791572198451L + 3916C13A250BB5E9: 1791572198459L + 71A3B742D8757C89: 1791572198461L + 365C31F15DC05036: 1791572198461L + 2BD077EFE77B8EBB: 1791572228661L + 44529CBD7ED7502A: 1791572228661L + 4A6B585C2394A89A: 1791572300944L + 012550B8C5B1BBCC: 1791572300944L + 58248AF507380501: 1791572300951L + 76A0FBADACAD322B: 1791572300951L + 16670D70BD63FA37: 1791572300951L + 44BD0B40EB5451AF: 1791572368048L + 44BDEA5268F6ABE6: 1791572368048L + 7941938014E97A30: 1791572368052L + 31D0730D68948E3B: 1791572368052L + 4BE209D5DAF1F321: 1791572368057L + 5E7A46B9CB8A2523: 1791572368057L + 7A7F8E95E037CD5E: 1791572368064L + 53DEA3DFEDC4809E: 1791572368064L + 2ACCBA73F15785AE: 1791572445358L + 6EB023B3AEF61518: 1791572445361L + 03C7F9CB0DDB2D9B: 1791572445365L + 659A903F97F93BE2: 1791572445365L + 17B9A643AFD7EB39: 1791572452711L + 76406EFFF8CBA6B4: 1791572452711L + 17E6AEEE9C081DB1: 1791572452714L + 1682CED78D9D4BA7: 1791572452714L + 290255C654A57980: 1791572452717L + 6B671CB07D046BEC: 1791572452717L + 6601450C63FEEE61: 1791572452719L + 206791A6A8DA0F16: 1791572452719L + 5E41363F9AE243F3: 1791572452727L + 09C547CC92623525: 1791572452727L + 5EE73B206A8A3021: 1791572741316L + 50B127137AA9E660: 1791572741316L + 1D91E37BFD8CC911: 1791572741318L + 4795ADB0596D26DA: 1791572741318L + 54D3D6806C1F33F3: 1791572751322L + 13AFEBED5C116FC8: 1791572751322L + 1E87CE2F633083A4: 1791572977255L + 002B8F2DE2CB4D2C: 1791572977255L + 429785E8F64929CE: 1791573030307L + 3893B57532491926: 1791573030307L + 01119871B1D5C576: 1791573277427L + 1758F9A1E399448B: 1791573277427L + 5CEB2D56A9EA74D0: 1791573301658L + 481735FE3164C8D5: 1791573301658L + 76F675A898EAB4E8: 1791573301662L + 0A2949304DBB3D75: 1791573301662L + 27F62C4B42B16015: 1791573301673L + 7033C2D58700433D: 1791573301673L + 71321938044ADBE4: 1791573301683L + 2B2BB53FA86A07B7: 1791573301683L + 2065DC263D762764: 1791573439344L + 792990DC06DB98F9: 1791573439344L + 2A1BFB2E0F1F3312: 1791573439347L + 79F67E07C28F8E97: 1791573439347L + 7570E5AAAC5CCA39: 1791573439350L + 0BBDB63F944F61A7: 1791573439350L + 70A3CED85106362F: 1791573439351L + 604F2B68B27885BF: 1791573439351L + 7732CF7AAA63DB3A: 1791573439351L + 24042326B4632DD4: 1791573605986L + 743C664C4E8EE776: 1791573605986L + 1693B07767081FB3: 1791573620166L + 7741905EA8380B25: 1791573620166L + 28FC7C19E2A3E1B9: 1791573620167L + 221C9EDA651144BA: 1791573620167L + 33B4B2E11DEF74E2: 1791573620170L + 3AE0198177A8E91B: 1791573620170L + 4D3379D38237F830: 1791573780667L + 61C6CFA5DB18E2DC: 1791573780667L + 318E7C9C3687A8A4: 1791573780669L + 2AEC294578952BFD: 1791573787073L + 23553673B9AC2E61: 1791573787073L + 2E97226C40818239: 1791573787079L + 5C990F7E5D345A6F: 1791573787079L + 20BC17BCC5E270A6: 1791573942010L + 611CC8005150DC9B: 1791573942013L + 1697CC05D08B388D: 1791573942013L + 4FBF146C9301DEC7: 1791574367480L + 3AAB71E9BDFD4C1E: 1791574367480L + 0197CC61699DBE7D: 1791574367483L + 3AA836E8EDB52097: 1791574367483L + 3839A67493B2E5C3: 1791574367487L + 0E81CBCD6B1D1895: 1791574367487L + 1102B27CBF564127: 1791574367487L + 577EC349CB79E93E: 1791574367490L + 371A34B297C8A8EF: 1791574367490L + 1DF996B4E387DB41: 1791574367497L + 2AED15E3AC0346FE: 1791574367497L + 31E7F7464D79F881: 1791574367506L + 7330FAC338D718B8: 1791574367506L + 2A1D9DE362310D7E: 1791574616015L + 4A7AEBEB87CEC274: 1791575133565L + 7DABA35BECFBE1E2: 1791575133565L + 102E085F5F52E04E: 1791575133567L + 122E6E7CABCB17E7: 1791575139880L + 294D97F3514A382B: 1791575139880L + 078D36F0A6A3F09C: 1791575139884L + 3B3BD39BA02DCA13: 1791575139884L + 6BD95DE2DB508F9A: 1791575414269L + 22151D56616559CB: 1791575414269L + 4671FA2855861C13: 1791575417473L + 43DC656348DC56EB: 1791575417480L + 7D81F2381516204D: 1791575417487L + 7E538C87D777B809: 1791575417487L + 2228CC59A99F49E9: 1791575417489L + 066438B01655D866: 1791575417489L + 2B864D741F7C7549: 1791575547495L + 0BAEC53DD32C9870: 1791575547495L + 6EE68EEFE388CF8A: 1791575645918L + 477AAD8E6A0BB944: 1791575645918L + 32E6C837728B2EF1: 1791575657166L + 4E895C54651A3CB3: 1791575657166L + 6AEDA2F9BEB57759: 1791575657167L + 6E0E13806F388D7E: 1791575657167L + 51A617F8FBBFA712: 1791575657167L + 3EA883C0BB7BD38F: 1791575719618L + 7F08D4E77359BF4A: 1791575719618L + 3621D48E2CFAF2C2: 1791575719625L + 56EC79AF11B0869E: 1791575719625L + 4178C3EBAD9F0670: 1791575833267L + 21BEE9867796933A: 1791575833267L + 0045D90350F06441: 1791575866021L + 12449D23295CF9ED: 1791575866021L + 73989F729ED52289: 1791576021068L + 11A6D020D6F411A8: 1791576021068L + 034A9FBCA6D79BB6: 1791576178898L + 5712300EF4E5E846: 1791576178898L + 616C07AFBAFA7AE4: 1791576178898L + 43021923E220CF68: 1791576178900L + 323A434E1AF54408: 1791576178900L + 20F9EF925DB1CE05: 1791576450250L + 52874F5B79ECAE65: 1791576450250L + 4E4CF3303EF32A51: 1791576818398L + 6DBF9CAB37B9BBF3: 1791576818398L + 0D18BBB04693885A: 1791576879050L + 39FE4ED3B2A83A42: 1791576879050L + 7E4367252A39BE6C: 1791577013543L + 76A7B9E9F755A223: 1791577013543L + 21FC6D010E5D5360: 1791577013565L + 41FF171C7D97B574: 1791577049740L + 3497C2E05DF5A87C: 1791577049740L + 3B4CEE8A8CE0D6CB: 1791577049745L + 0B826D13BAD43EEB: 1791577049745L + 5E2F12A4A10B340F: 1791577049745L + 31103B425588A4DF: 1791577049745L + 2DB2A4A1182FE0BB: 1791577049745L + 1AFB7D87A71F862A: 1791577049745L + 36AD2A449FE452E8: 1791577049745L + 1F7D147C9AF6A4FC: 1791577049745L + 131AA933D59D3017: 1791577049745L + 66ECC26BC81D0093: 1791577065202L + 1BAC364D2AF426D3: 1791577065202L + 3027F7F9B0D0EB88: 1791577065202L + 6D88C19F47D0D469: 1791577065202L + 499AF83C86984D26: 1791577166745L + 7E92ED270C67FDE5: 1791577166745L + 4DB11C6F180743E9: 1791577170538L + 346860B8EBC4C28C: 1791577170538L + 58D16A3D3A480A37: 1791577170560L + 0FD62827710F0AC6: 1791577170560L + 6206DC761D4CDDFE: 1791577185693L + 3F3F3174BD9D03BD: 1791577185697L + 6B84DAA99028ACEA: 1791577185697L + 3DDF87A1E5F5D009: 1791577212001L + 17F07D6404668DE6: 1791577212001L + 1C273D9E046FD18A: 1791577212010L + 2E74430108DE6170: 1791577212010L + 3209E46C9B05C163: 1791577439647L + 2EC9668ED4EA47CB: 1791577439647L + 3CAFF729F25A9850: 1791577439650L + 29A05589E96C0569: 1791577439650L + 162CE44400A63575: 1791577828847L + 28277BA9F319240D: 1791577828847L + 27DF6EE4B85F1792: 1791577919941L + 034DB3C64F24C7B7: 1791577919941L + 4F56BFE6E4716F6D: 1791577919945L + 4BA2F4AC2A08C294: 1791577919945L + 01BBDC6956BD2DD3: 1791577919945L + 78AC8EC04CDAFFDE: 1791577919945L + 7EF57BBEAA4B6B08: 1791577931151L + 21FBC4E0F668347C: 1791577931151L + 6205B6B93B306A12: 1791578102047L + 5E4BC0F59C90433A: 1791578102047L + 200CE35BF3C04F6F: 1791578163443L + 0CD04A00529FE1C9: 1791578163443L + 49F46466DB8EDD61: 1791578188998L + 58514FDE153FD971: 1791578188998L + 72CE838EFDDE6479: 1791578490451L + 166971866A9234C7: 1791578490451L + 3B51C96356777FE6: 1791578490455L + 5E51F5B17F77573E: 1791578516298L + 0498A578D0EC3254: 1791578516298L + 19341B01E048ACAB: 1791578516302L + 676E0375B6D394DE: 1791578516302L + 3FD63A725A23D407: 1791578530402L + 4935209D870E25F2: 1791578530402L + 211A2C594472A9C4: 1791578538696L + 2CCAA7C0FEDEB573: 1791578538696L + 50624A39AD15A8A6: 1791578538700L + 7778937DF377C1B4: 1791578538700L + 59DE118DD6597A4E: 1791578538703L + 48DC9E8E9D21A2FA: 1791578538703L + 1960B985B54B9FBD: 1791578601624L + 4869C413646CC4CC: 1791578601624L + 493D04D954E4FBA0: 1791578601627L + 4448DE84ABA737BE: 1791578601627L + 6877D91D6603E4FD: 1791578601629L + 62827AA1AD29A222: 1791578601629L + 6C4B8A2662030CB0: 1791578601633L + 5C47935A3B2877FF: 1791578601633L + 5FA5A4469CF8A9EF: 1791578989497L + 0FE30548F25FEDB1: 1791578989497L + 76C92D826994D92B: 1791578989499L + 7CF13A4941F608E5: 1791578989499L + 460FA61A72720319: 1791578989507L + 2278A98DCDF713D7: 1791578989507L + 1AE233B4CCFE99F7: 1791578997845L + 08DDE018A804BFE7: 1791578997845L + 2068F53F091BFD5A: 1791578997846L + 4C0098C62CDF1FF9: 1791578997846L + 302E9BC711779A4A: 1791578997846L + 091B97D20403D516: 1791578997846L + 103895C9792183DB: 1791578997846L + 4558919345C3BE5D: 1791578997846L + 4F664D5E6C5FDE3B: 1791579084094L + 21D8B88155648EA6: 1791579084094L + 488DBE69595F38F8: 1791579122098L + 5A081977DB9407EA: 1791579122098L + 119BED334E331C25: 1791579122105L + 4B6C5B2099B18AB7: 1791579122105L + 7EA092725A784CA7: 1791579122123L + 17C665008B4C0CD7: 1791579122123L + 6CB34AE622DF9CC2: 1791579122123L + 792B4AC07BA2D148: 1791579295599L + 64D374ADCCF2E14C: 1791579295599L + 3824247686C3D875: 1791579314547L + 0C9F9196CA49FF8D: 1791579314547L + 4B83A0D1C2C3C226: 1791620071651L + 3C78926E5D301BA0: 1791620071651L + 4FA6BEA4E646B742: 1791620071651L + 08E484DD3E751697: 1791620299803L + 53B3983C04A5CB1C: 1791620299803L + 202931B62E242E7F: 1791620678611L + 4F35D04721DFC9FF: 1791620678611L + 3B1F1A7CAC8B7BC7: 1791621038392L + 5EE945A1185E8336: 1791621038392L + 1BE666F01EFFC00D: 1791621038392L + 2E04D0F6EEABE677: 1791621038395L + 2746575C929B6C50: 1791621038395L + 4E5238F00CEED8B2: 1791621104494L + 1869893A4F8E9E9C: 1791621104494L + 1036837C9AD3F301: 1791621181695L + 7F822A21A9F71A4F: 1791621181695L + 1C4E971513EF2B15: 1791621181696L + 5FE507DAEE770507: 1791621181696L + 62C6F14FE6A82305: 1791621224299L + 072FBEB0F6F1BC48: 1791621224299L + 17DC77F7F8C68AE6: 1791621238492L + 4A10EADC3914EA38: 1791621238492L + 31AD9826FB036C98: 1791621982584L + 117AD4ACD2CB198A: 1791621982584L + 5999B2D204EC280E: 1791623054751L + 216DA9782EBDFF34: 1791623054751L + 5316DF321B45D2CA: 1791623054759L + 4B0A4027FACE9108: 1791623054759L + 537CABE8F876E65A: 1791623074195L + 28408BFAA54E4FC6: 1791623074195L + 1CC0E97BB8DC4F35: 1791623520103L + 204B969DA081056A: 1791623520103L + 452DC5EB02D592B8: 1791623547745L + 4C45690D5EBCAB90: 1791623547745L + 74B352457BF1541B: 1791623554495L + 268319B4D86AF1FA: 1791623554495L + 004D61425172324F: 1791623557544L + 70B760551AA03D3A: 1791623557544L + 230EEE4671B46D0C: 1791623611807L + 43A5CD740F24A3ED: 1791623611807L + 34909E6C5CB47A43: 1791623857095L + 7F990FBC1A691020: 1791623857095L + 4D5FA238E199BECE: 1791625056802L + 41B99DBD03EB74FF: 1791625056802L + 094D28B7A0170039: 1791625056802L + 620BF9687558DE44: 1791625056802L + 34424E87D679D3AA: 1791625056803L + 32384DE59539557D: 1791625056803L + 0008773503CCA262: 1791625056806L + 47358ADC1470C82A: 1791625056806L + 26DF1427AC3966DE: 1791718906798L + 76BCC3C288514737: 1791718906798L + 24BA89BD9898759B: 1791719085709L + 1AB92CF7F0246D21: 1791719085709L + 76A29816F19E33A2: 1791719085709L + 65BBA5C0DAEEC31F: 1791719085709L + 5FCA4FF8C135435E: 1791719085710L + 423AF6C6647B1626: 1791719085710L + 77FC692AC94D2EEF: 1791719085710L + 045D1087A2692360: 1791719085710L + 49459B4370DD8D1A: 1791720390047L + 3F04107B4D2907E0: 1791720390071L + 4F6FDB5872FD9E61: 1791720394852L + 79CC596EB3BF6A64: 1791720398040L + 5DDC15AAFD2CAF47: 1791720398040L + 3B9E51CF30228269: 1791720401648L + 55A9EB63F4964FCF: 1791720401648L + 586916ED82D2DD2C: 1791720401650L + 565703CBB06CEABF: 1791720401650L + 53829B3E51936ACD: 1791720401652L + 2FD63B9DA3F02205: 1791720401652L + 2F56ECC8DBC90C01: 1791720406995L + 497D222F74A52DF5: 1791720406995L + 18980EBAE4ED777A: 1791720486844L + 7992C63F063BE908: 1791720486844L + 5EF54E0EB14024D1: 1791720486844L + 381CBCFB71018C25: 1791720486844L + 3FF97A4B5029C0E7: 1791720486844L + 664ADBD5CACCEC65: 1791720486844L + 742BD37F93033B43: 1791724204351L + 7C687DCF71D77761: 1791724204351L + 12083A6D1B9E9DD2: 1791724204359L + 6263A27182B2CE3D: 1791724204359L + 33415CB421F7620A: 1791724211014L + 1EA4F0AB480D485C: 1791724211014L + 564D0E533237E951: 1791724211020L + 4E0A3C347EDD235A: 1791724211025L + 729C1974AE346ECA: 1791724211025L + 7C9154EF6068BE27: 1791724398293L + 25AE4E531CE97B98: 1791724398293L + 431D7329CFCAF880: 1791724398299L + 7AE502EDB73BD57A: 1791724398299L + 591B93F4B6397821: 1791724398305L + 0095422BC87AA135: 1791724398305L + 62E0C347DC32EA77: 1791724398305L + 35A07C18FFEEB872: 1791724398305L + 67E7A9A65B14C933: 1791724398305L + 109AD73F016D576B: 1791795944851L + 33E5A303B907306B: 1791795944851L + 48410B67C276ED1F: 1791795944858L + 42D3D4E96812274C: 1791795944858L + 22BB55249EF2E819: 1791795944858L + 1922623A26E08078: 1791795953601L + 16E5E87F1CAC042A: 1791795953601L + 3563498293CD3BF7: 1791795953602L + 7B385FD1CBE02C85: 1791795953602L + 4B60ACBCC3B46D1D: 1791796134198L + 0F326EEEC2EBE4E5: 1791796134198L + 1AEFF93A398B8BBF: 1791796199516L + 781B2DF9116224AE: 1791796199516L + 4D849193A07D4F83: 1791796208711L + 035EB822D59843CF: 1791796208716L + 324B835F4DCD19E1: 1791796208721L + 2501510A452D3752: 1791796208721L + 533879E84DC7CAEB: 1791796208725L + 5C547747E49BA6BB: 1791796208725L + 587C27CE66ABA2F0: 1791796208725L + 1834B5C1F8435D7A: 1791796251459L + 5B7926B511000F1F: 1791796251459L + 55DD09212E3DB168: 1791796251462L + 030AC1AF8F735550: 1791796251462L + 4632192573FD8501: 1791796251462L + 33C69B5934BE4D64: 1791796251462L + 418E333710652BFA: 1791796271743L + 34CFE1439262AA72: 1791796271743L + 2612CACBDDF9AD1A: 1791796271749L + 5E116409DC7D30BB: 1791796271749L + 3E038C9465AFD2DF: 1791796437753L + 18783C62009934DB: 1791796437753L + 1F65F84DA1EAFB3E: 1791796437759L + 445CC9AAA6F8AAB6: 1791796437759L + 7485BCBE366501DF: 1791796437764L + 77FDF195DA1CE7F4: 1791796437764L + 2A16BDCF5D671F6C: 1791796437769L + 0E175356D43E6A10: 1791796437769L + 6DACC77807D1F342: 1791796437770L + 75E4751F7A802A44: 1791796437770L + 078B69E9362A5496: 1791796437776L + 7934873E784C4B3C: 1791796437776L + 27BDA44CC3FFCCF1: 1791796437776L + 55A840C3871027EA: 1791796437776L + 1F7C3D5DE3CE359D: 1791796437776L + 6DC1E08D019FD543: 1791796437776L + 126B3D11A754B899: 1791796437782L + 17E2C05C48B03043: 1791796437782L + 43063168AB07D6FC: 1791796437783L + 40E8940C92668D4F: 1791796437783L + 2BA7FC10243AA011: 1791796561200L + 4BC2D7FCDD792DBA: 1791796561200L + 50795B2B2A320D16: 1791796561206L + 3AF1DC02E9A0010C: 1791796561206L + 30DE4F5BC3EDB555: 1791797085898L + 337725ECA2C04090: 1791797085898L + 6B24D8667BC856A3: 1791797085903L + 60ECCCAB66AAC05D: 1791797085903L + 7952DA35B4F5C598: 1791797353149L + 68C11540A9010775: 1791797353149L + 3C95552D03C16F3D: 1791798072194L + 672134A1A620EEAB: 1791798072194L + 0AA9EF3981E9BA37: 1791798072199L + 03C590DB786810FB: 1791798072199L + 477B411F84342EEA: 1791798072204L + 76378687551A79C3: 1791798072204L + 09788D3638E59F3B: 1791798155898L + 090B564935D7A8F5: 1791798155898L + 3331EBE1BB4BF64D: 1791798359844L + 1BB0FB49B96E22E2: 1791798359844L + 39CEB20C5018F460: 1791798359851L + 6D6E07564D8FDD8D: 1791798359851L + 540DD4E3E568DBF2: 1791798359859L + 7C4E4793DA887DE4: 1791798359859L + 3D88946A32BFA35F: 1791798825443L + 1E0F92F07FDD162E: 1791798825443L + 238F4D40E6D9238E: 1791798936144L + 0A21773B773F34F8: 1791798936144L + 2725700B5EA1CD75: 1791798975596L + 18EADBAFC932F864: 1791798975596L + 6ED2CDD01659FD10: 1791799063793L + 10B65DF825ACC0E7: 1791799063793L + 7F2D696AC9F4C4E4: 1791799063796L + 11655B2807FF11CA: 1791799063796L + 304A15731EA889F5: 1791799063800L + 5D64250B940BC8FD: 1791799063800L + 6BD970777103D55D: 1791799161743L + 7C2ED69E07988DF4: 1791799161743L + 41BCFBEC6E169507: 1791799319398L + 0B37355262121DD5: 1791799718294L + 04DE1F990F95E617: 1791799718294L + 03F5ECD810F037D8: 1791799892101L + 11EDB05BC54E502C: 1791800009247L + 7DFF18CFEB0B8DBE: 1791800009247L + 06CFF66846E1CE0F: 1791800385797L + 27A0BCE75F198A82: 1791800385797L + 250A0320FF2BC775: 1791800490855L + 1636F3F12E92CAEA: 1791800490855L + 2C9C9CB71941DC01: 1791801826911L + 49DC1FFCD031DB6E: 1791801826911L + 66C52B137A4FF869: 1791802209202L + 4BEC4730588463FE: 1791802209202L + 635620A03E3505BF: 1791802231200L + 5824C93C84312142: 1791802231200L + 6501A410F1543C70: 1791804410703L + 0C99990D814C2823: 1791804410703L + 4EF5DE3FBA2A7AE3: 1791804556250L + 7D9A58EBADE91F54: 1791804556250L + 58541E64C0528FCB: 1791804727753L + 081DC030A0546549: 1791804727753L + 047D297988E59B6B: 1791805091197L + 4821419D44F8083F: 1791805091197L + 78DA1FE8F54FDAE8: 1791805215952L + 0D01524F249E25D7: 1791805215952L + 6F496FD2BCFE21BA: 1791805403445L + 67DBE6C59C0D9D1B: 1791805403445L + 4A4C14AF947D93EF: 1791805411150L + 5B32C56CD3D3FAEA: 1791805411150L + 4EA95AE9AF641FB4: 1791807462997L + 6A2AD67569F91F1F: 1791807462997L + 3339445194568D77: 1791808193703L + 41910A3BC6C0E838: 1791808193703L + 58BF5D3267CF02F8: 1791808302105L + 2AAF57D1D8AB6BF8: 1791808302105L + 0EA4B08DAAFA4287: 1791808302105L + 6BA6847A172C8DD7: 1791808302105L + 17F3AC15ADCFB175: 1791808302105L + 2916F873A4DA84E8: 1791808302105L + 479A4E778D8D7317: 1791808302105L + 53C23DB8B4D1CEE2: 1791808302105L + 52B4CBE9BE14AD70: 1791808925751L + 29CAA814200ABBA8: 1791808925753L + 4C23D7E3DC8327C2: 1791808925753L + 72AB70FD8D8FABBF: 1791808943379L + 10B368D0D52F377B: 1791808943379L + 0CC0632E8276C787: 1791808943391L + 46B42667B6BC1A3C: 1791808943391L + 565376084F05ED6E: 1791808943391L + 0B67D1F6E147566B: 1791808943391L + 6990F3804C13A8E5: 1791808943401L + 2B08B19B35EEBCBA: 1791808943401L + 0F3D51FFD2FE8DCB: 1791808946443L + 6FF63A559722919C: 1791808946443L + 3FCEF8FF45E51608: 1791809012696L + 05FD7D9C29619813: 1791809012696L + 20D72B07EE11DDCB: 1791809012701L + 167E1474644C9908: 1791809012701L + 606B73596F3EAFE5: 1791809012708L + 429B5A81DF6C43FE: 1791809012708L + 38A1295878B68F83: 1791809073765L + 07F6D01E644305E7: 1791809073765L + 3E3EBDB1D26C3117: 1791809073766L + 77793642678CB886: 1791809073766L + 30B0240543253EFB: 1791809497144L + 576428EF1CDDEF96: 1791809497144L + 12ED1EE85E146A4B: 1791809512498L + 2F7ABC9758AAC23E: 1791809512498L + 17A68C4E9E9EA2B7: 1791810351591L + 28EABAF0B91E94AE: 1791810351591L + 2171DC0B13C4BD43: 1791811774894L + 3768935221C60A6D: 1791811774894L + 328177CC5FD61DA4: 1791812187501L + 227E9783382527CA: 1791812187501L + 70367F545A9CB4D0: 1791812829944L + 3ED8365BAD406CC5: 1791812829944L + 6581D4AF1A6DE230: 1791812833050L + 0EE813C344409F59: 1791812833050L + 7A89560F303A8BE6: 1791814115803L + 1A6781CA48458EE5: 1791814115803L + 794CD7B21426F36D: 1791822530244L + 17FE0C7748DD65CA: 1791822530244L + 5E799B92358A8732: 1791823549693L + 4346DAC18183627E: 1791823549693L + 4B2146C9527C54E7: 1791823549695L + 0AB9BED5E5144BAC: 1791823549695L + 39E3DA5BA041E2A4: 1791823549698L + 1EABF2E7AE7B8F22: 1791823549698L + 40B5F7ECA67EBF03: 1791823549706L + 3BDB94F17765EE77: 1791823549706L + 324DACE30B49D25B: 1791823908894L + 06A7A3FC8634D2DA: 1791823908894L + 7574A782DCFB87CE: 1791824550597L + 4BF8E17EC585CA54: 1791824550597L + 6E3AD764A67829CB: 1791825298494L + 4044AC9B58C2DC35: 1791825298494L + 47A184CF57387DC7: 1791825298498L + 219A5BDA5A01A420: 1791825298498L + 02ED3169ADC0E7F9: 1791835045495L + 48BF71269DEA1AB1: 1791835045495L + 6B6B34B77A5563FC: 1791835058196L + 212EF8601746C500: 1791835058196L + 1C4ABF4518638A82: 1791835058200L + 6A0FAC4CA0768455: 1791835058200L + 15AAF17B6665223D: 1791835969043L + 44881E39AB1D62AA: 1791835969043L + 67DD297C9649EFAB: 1791835969043L + 514207C426B8725F: 1791836013153L + 7B0764DDE94E73D0: 1791836013153L + 084D67D36A0859B7: 1791836771597L + 5F6ACDE014A61F46: 1791836771597L + } + completed: { + 3BC0A50886A3222B: 1791559740996L + 33218EFDD498E9DB: 1791559740996L + 3009018DEC1EC952: 1791559741106L + 08BA6518F2BBDD85: 1791559741106L + 196312DF0D2E2CB6: 1791559742633L + 0A3853BD4F62B78E: 1791559742633L + 13F86953093670F6: 1791559747853L + 637EF6D9DFC59EE8: 1791559747853L + 06FFBB12CFA87815: 1791567234399L + 1A66B7FFB4E2163E: 1791567234399L + 064B1841F9288CC3: 1791567234399L + 0052E5592A6BBEC1: 1791567234399L + 6F65216B13E9FFA9: 1791567234399L + 0EBE231AB868C868: 1791567234399L + 61BE7882EE0E6FAF: 1791567234399L + 7213278CEE1B25A3: 1791567234399L + 285A53DB9D54595D: 1791567234400L + 5CF320E9C4C1B1E1: 1791567234400L + 35C46E6E27C9CB1A: 1791567234422L + 720CE629EA4E37D0: 1791567234422L + 6609E5E7EDE6F92B: 1791567275049L + 5577F49A93ADB19C: 1791567275049L + 3F185554793CCE54: 1791567306408L + 4AA065E761BB6A42: 1791567306408L + 44688FC2E34FF09F: 1791567306409L + 46803BBAC2069773: 1791567306409L + 292C044797A58CC1: 1791567306410L + 5151CDD8FCDE7A07: 1791567306410L + 6F5F1C525CF08F96: 1791567306416L + 279DA565E9ADF7B0: 1791567306416L + 0460DDEEFDEB0AAF: 1791567306425L + 3ABECD22D09DEEFB: 1791567306425L + 3CC50EAEEE1E8A56: 1791567306436L + 3166CA00032C18F7: 1791567306436L + 2E885362FFDF770D: 1791567320670L + 2EE4E88FC814AFA4: 1791567320670L + 09955CF85F579E0F: 1791567342952L + 7D14A6A6ABBA9960: 1791567342952L + 1972B36F72808D55: 1791567342953L + 2BECEC3EFA8F6DA3: 1791567342953L + 57F47BA495B27F83: 1791567342955L + 02C822663913DFCF: 1791567342955L + 2E6D11A5F5626A6C: 1791567461795L + 4CDD283558108D8B: 1791567461795L + 27BABC4910038807: 1791567470845L + 0EB6ED88895366D0: 1791567470845L + 14E43D7B404B8AD4: 1791567928645L + 092BB595F75D5536: 1791567928645L + 6B772FA03562537D: 1791567928645L + 692183ACBB2AABF7: 1791567928645L + 689F32883C4E9502: 1791568002592L + 0496E8E786262464: 1791568002592L + 0F998B5ABA5A3DE8: 1791568002592L + 276930FE3A5CD916: 1791568002592L + 43BC5EBC129DCE92: 1791568002599L + 7D359283625C1869: 1791568002599L + 4EEF358CAC170CC4: 1791568123198L + 15540271BF17C2E4: 1791568123198L + 3F68BA498E877C8C: 1791568123202L + 59E3B2A8BA8579C0: 1791568123202L + 1ADA8EDA9196471C: 1791568135548L + 4B7D387CEFF9667E: 1791568135548L + 57A7A5C79389A96A: 1791568135552L + 2811A593A759354C: 1791568135552L + 69F11A94D44AB5CF: 1791568139345L + 08082713F1556E3A: 1791568139345L + 25F49B7B8EC6F48B: 1791568528496L + 3917DECAEF56A06A: 1791568528496L + 78D7794707E4BD83: 1791568673749L + 1827DEEA2DF1B144: 1791568673749L + 64FADC78A8AD3FE4: 1791568673753L + 54735C5FCD2077B3: 1791568673753L + 4DF683461225690F: 1791568705097L + 7C3D8A97769BBBE3: 1791568705097L + 58B125BD4876054C: 1791568705102L + 77329B944064E893: 1791568705102L + 175D943160C10646: 1791568705108L + 297D80507C3A56E1: 1791568705108L + 41A47F38D96A9747: 1791568709796L + 70ED849A2EB44DEE: 1791568709796L + 1C1B919636DDC925: 1791568725291L + 2B9AF0D6ED8BA628: 1791568725291L + 6B2027DA7AA6FF34: 1791568760952L + 3C45AF8C3DC5A45E: 1791568760952L + 5A94A2664BFDD7B9: 1791568809447L + 51CC9A8E36385400: 1791568809447L + 7EDA9BD14A177D8D: 1791568809451L + 0682DC1F2417DAEB: 1791568809451L + 073D1C45B3018BBA: 1791568809454L + 134608BB2B04D96E: 1791568809454L + 408C5263C4664195: 1791568809457L + 2893F483C10293E6: 1791568809457L + 10A16F89D4AD238D: 1791568851462L + 7658C1C663394E85: 1791568851462L + 5AC1BE754210429E: 1791568854104L + 4F13A02FB0055A62: 1791568854104L + 3708A4780ACEB34E: 1791568856007L + 52BB142F044075B4: 1791568856007L + 5B00676D79306EA2: 1791568858252L + 0C93D7A607AB8B83: 1791568858252L + 103C42C743E2A2DB: 1791568858252L + 28FDB2D13D0DDE8E: 1791568879444L + 0138936FB4DD8A88: 1791568879444L + 5E8A262AF1E9DB0B: 1791568879448L + 1659A59303C15CC9: 1791568879448L + 6F62B5510FA881CD: 1791568879454L + 52967F83338A3AC3: 1791568879454L + 7975C7145572C438: 1791568892142L + 173044E1C1134A80: 1791568892142L + 746C44D800F4E4FB: 1791568892142L + 5CA7453233441165: 1791568892142L + 282CE3A088AE5CFB: 1791568892142L + 372B5BF416CA83A4: 1791568903152L + 1DCF1EB2F1D2F966: 1791568903152L + 426BED88B406A44B: 1791568908341L + 1FC7ABEAB5018E79: 1791568908341L + 26FAC6C93C935044: 1791568908345L + 37EBB8E0D6E5F821: 1791568908345L + 6D88F09899BB4808: 1791568908353L + 42D173B9FF8D16E4: 1791568908353L + 4AB0DD227471FDBF: 1791568908353L + 1F2490B2B9A2B5F9: 1791568908353L + 76EA017B12E8F01B: 1791568908377L + 4D6630377238B530: 1791568908377L + 1F81EA5E45424308: 1791568908387L + 128DC243A78982C3: 1791568908387L + 7D38BC3DB3406F51: 1791569086641L + 39AD121BCB73453D: 1791569086641L + 539C6F64C251543B: 1791569086642L + 24C9BA6DC32CFAD9: 1791569086642L + 6627658A032CDC95: 1791569086643L + 752DD0BF36B70D88: 1791569086643L + 76C63670624C3C56: 1791569086645L + 0D3DC52244DC7ADD: 1791569086645L + 703D7D142BD7CEC9: 1791569086663L + 47750F3CB020104C: 1791569086663L + 38BA6B3FF1EC9185: 1791569086685L + 698A959C9E449592: 1791569247357L + 03005DEF00AB48F3: 1791569247357L + 73F9C130378195B9: 1791569247357L + 4DE1158931F84F22: 1791569247357L + 2E116AC5F25650BE: 1791569247357L + 051E0C85E7B71CE0: 1791569247357L + 211FD55DEB635A98: 1791569247359L + 14ACE78261F4EE35: 1791569247359L + 4555EA054687BD5F: 1791569247363L + 7242F591DA474B37: 1791569247363L + 1F44E7BEA2D191E5: 1791569247369L + 5CCF92238B23EDC9: 1791569247369L + 7CB9C080F0597A4D: 1791569247376L + 0542FE108089A0A2: 1791569247376L + 72E1C19156633391: 1791569262707L + 2CE553B282962E01: 1791569262707L + 4D389CB7B26F5EB5: 1791569438547L + 2B5E14A47062F83C: 1791569438547L + 6E3ACC74E0987981: 1791569455588L + 41C0948CD9D50322: 1791569455588L + 6A352964478AE499: 1791569464999L + 45996B848792379E: 1791569508413L + 1476F390A6789572: 1791569508413L + 76F3B5C3CB49DDBD: 1791569687091L + 377A41499D8218C9: 1791569687091L + 7D3BB67D10EA5245: 1791569687094L + 010607466D565B53: 1791569687094L + 13D240DA839AE0F9: 1791569687097L + 68DC39876E14FC1B: 1791569714596L + 5CE44C58A470E48E: 1791569714596L + 1934789006427AE9: 1791569790745L + 022C38096F79EF07: 1791569790745L + 1FFF00B5D90C3905: 1791569790749L + 7F8F61CB2A5279F1: 1791569790749L + 6204AF3CD7D2CC37: 1791569790759L + 62C60FF24B2F7290: 1791569790759L + 5936B8B8EDE0AE55: 1791569958848L + 0596F6C70758EEC0: 1791569958848L + 48A30B08A090BD75: 1791569958851L + 6CD7D99AA102A5C3: 1791569958851L + 23631A4A743D4F0C: 1791569966296L + 2E92D23F149C142D: 1791569966296L + 4C72CA4823807604: 1791570082640L + 042B93D43308564F: 1791570086895L + 55D98C63A1A01713: 1791570094708L + 16BC496604F56E3B: 1791570117492L + 3685B52446DEC809: 1791570117492L + 6AA00AA3ADBA5F34: 1791570278098L + 7377C657CED885AC: 1791570278098L + 667004CD0469493D: 1791570278102L + 1CC4F8570A7A99EB: 1791570278102L + 3FBFC3BAACA69389: 1791570461848L + 2DDA04088C8220D2: 1791570461848L + 5457CD8C1ABA0B9E: 1791570704258L + 2B05A29C62676EB2: 1791570704258L + 6ECB27052FCB874E: 1791570757348L + 759571AAA3323C3C: 1791570757348L + 195338A3977C682E: 1791570856294L + 392F34FD4FCC794A: 1791570856294L + 224CE21E56703F6E: 1791570856297L + 347729D52A71A889: 1791570856297L + 62EB681EEB76FCDB: 1791571081315L + 70C7D92844089DEB: 1791571081315L + 340CDF4357E22A43: 1791571282198L + 14C0CA9C756B3ABD: 1791571282198L + 4E7FDA9DE2071EE5: 1791571282206L + 300F2E45D185A9A1: 1791571282206L + 7E2317305BD5F842: 1791571303297L + 29448D08C8777425: 1791571303297L + 67D884FA6DD22065: 1791571306360L + 3BF5B8DE7A1CC4D7: 1791571306360L + 474E1537C5CA7FB7: 1791571325447L + 1ACDF9B8AED8F168: 1791571624294L + 3F5B5D6B788E0A73: 1791571624294L + 6467C991A6D45E36: 1791571624301L + 31FD9EA513E0D010: 1791571624301L + 0C282319C6EAC76E: 1791571699349L + 217DB12EA32E1724: 1791571699349L + 73350AD668200E99: 1791571727150L + 4B2621F8D8FA7CA7: 1791571727150L + 6502D0124314BF25: 1791571985702L + 615C3E012B2DECD3: 1791571985702L + 576227D79B7D383D: 1791572198451L + 44529CBD7ED7502A: 1791572228662L + 4BE209D5DAF1F321: 1791572368057L + 5E7A46B9CB8A2523: 1791572368057L + 2ACCBA73F15785AE: 1791572445359L + 6EB023B3AEF61518: 1791572445361L + 17B9A643AFD7EB39: 1791572452711L + 76406EFFF8CBA6B4: 1791572452711L + 17E6AEEE9C081DB1: 1791572452714L + 1682CED78D9D4BA7: 1791572452714L + 290255C654A57980: 1791572452717L + 6B671CB07D046BEC: 1791572452717L + 6601450C63FEEE61: 1791572452719L + 206791A6A8DA0F16: 1791572452719L + 5E41363F9AE243F3: 1791572452727L + 09C547CC92623525: 1791572452727L + 5EE73B206A8A3021: 1791572741316L + 4795ADB0596D26DA: 1791572741318L + 1D91E37BFD8CC911: 1791572741318L + 54D3D6806C1F33F3: 1791572751323L + 13AFEBED5C116FC8: 1791572751323L + 3893B57532491926: 1791573030307L + 429785E8F64929CE: 1791573030307L + 01119871B1D5C576: 1791573277428L + 1758F9A1E399448B: 1791573277428L + 76F675A898EAB4E8: 1791573301662L + 0A2949304DBB3D75: 1791573301662L + 7033C2D58700433D: 1791573301673L + 2065DC263D762764: 1791573439344L + 792990DC06DB98F9: 1791573439344L + 28FC7C19E2A3E1B9: 1791573620167L + 221C9EDA651144BA: 1791573620167L + 33B4B2E11DEF74E2: 1791573620170L + 3AE0198177A8E91B: 1791573620170L + 4D3379D38237F830: 1791573780667L + 61C6CFA5DB18E2DC: 1791573780667L + 318E7C9C3687A8A4: 1791573780669L + 20BC17BCC5E270A6: 1791573942010L + 4FBF146C9301DEC7: 1791574367480L + 3AAB71E9BDFD4C1E: 1791574367480L + 0197CC61699DBE7D: 1791574367483L + 3AA836E8EDB52097: 1791574367483L + 577EC349CB79E93E: 1791574367490L + 371A34B297C8A8EF: 1791574367490L + 3916C13A250BB5E9: 1791574367491L + 1DF996B4E387DB41: 1791574367497L + 2AED15E3AC0346FE: 1791574367497L + 2A1D9DE362310D7E: 1791574616015L + 4A7AEBEB87CEC274: 1791575133565L + 7DABA35BECFBE1E2: 1791575133565L + 102E085F5F52E04E: 1791575133567L + 122E6E7CABCB17E7: 1791575139881L + 294D97F3514A382B: 1791575139881L + 3B3BD39BA02DCA13: 1791575139884L + 078D36F0A6A3F09C: 1791575139884L + 6BD95DE2DB508F9A: 1791575414269L + 22151D56616559CB: 1791575414269L + 4671FA2855861C13: 1791575417473L + 43DC656348DC56EB: 1791575417480L + 2B864D741F7C7549: 1791575547496L + 0BAEC53DD32C9870: 1791575547496L + 6EE68EEFE388CF8A: 1791575645918L + 477AAD8E6A0BB944: 1791575645918L + 51A617F8FBBFA712: 1791575657167L + 6E0E13806F388D7E: 1791575657167L + 034A9FBCA6D79BB6: 1791576178898L + 616C07AFBAFA7AE4: 1791576178898L + 72717D1135486D7F: 1791576178900L + 73B6ED422F9292D1: 1791576178900L + 43021923E220CF68: 1791576178900L + 659A903F97F93BE2: 1791576178900L + 6890EB2819D88073: 1791576178900L + 03C7F9CB0DDB2D9B: 1791576178900L + 0A1C2A7A8617D3E4: 1791576178900L + 323A434E1AF54408: 1791576178900L + 76A7B9E9F755A223: 1791577013543L + 7E4367252A39BE6C: 1791577013543L + 21FC6D010E5D5360: 1791577013565L + 41FF171C7D97B574: 1791577049740L + 3497C2E05DF5A87C: 1791577049740L + 66ECC26BC81D0093: 1791577166745L + 499AF83C86984D26: 1791577166745L + 3027F7F9B0D0EB88: 1791577166745L + 7E92ED270C67FDE5: 1791577166745L + 6D88C19F47D0D469: 1791577166754L + 1BAC364D2AF426D3: 1791577166754L + 346860B8EBC4C28C: 1791577170538L + 4DB11C6F180743E9: 1791577170538L + 0FD62827710F0AC6: 1791577170560L + 58D16A3D3A480A37: 1791577170560L + 17F07D6404668DE6: 1791577212001L + 1C273D9E046FD18A: 1791577212010L + 2E74430108DE6170: 1791577212010L + 3209E46C9B05C163: 1791577439648L + 2EC9668ED4EA47CB: 1791577439648L + 29A05589E96C0569: 1791577439650L + 3CAFF729F25A9850: 1791577439650L + 162CE44400A63575: 1791577828848L + 4B35C01F5D0AAC58: 1791577828848L + 6E9280AEB3B1D973: 1791577828848L + 28277BA9F319240D: 1791577828848L + 034DB3C64F24C7B7: 1791577919941L + 27DF6EE4B85F1792: 1791577919941L + 7EF57BBEAA4B6B08: 1791577931151L + 21FBC4E0F668347C: 1791577931151L + 5E4BC0F59C90433A: 1791578102047L + 6205B6B93B306A12: 1791578102047L + 0CD04A00529FE1C9: 1791578163443L + 200CE35BF3C04F6F: 1791578163443L + 49F46466DB8EDD61: 1791578188999L + 58514FDE153FD971: 1791578188999L + 72CE838EFDDE6479: 1791578490451L + 166971866A9234C7: 1791578490451L + 5E51F5B17F77573E: 1791578516299L + 185D97FCA1C83569: 1791578516299L + 0498A578D0EC3254: 1791578516299L + 3B51C96356777FE6: 1791578516302L + 19341B01E048ACAB: 1791578516302L + 676E0375B6D394DE: 1791578516311L + 3FD63A725A23D407: 1791578530402L + 4935209D870E25F2: 1791578530402L + 211A2C594472A9C4: 1791578538697L + 2CCAA7C0FEDEB573: 1791578538697L + 50624A39AD15A8A6: 1791578538700L + 7778937DF377C1B4: 1791578538700L + 59DE118DD6597A4E: 1791578538703L + 48DC9E8E9D21A2FA: 1791578538703L + 1960B985B54B9FBD: 1791578601624L + 4869C413646CC4CC: 1791578601624L + 6877D91D6603E4FD: 1791578601629L + 62827AA1AD29A222: 1791578601629L + 6C4B8A2662030CB0: 1791578601634L + 5C47935A3B2877FF: 1791578601634L + 5FA5A4469CF8A9EF: 1791578989498L + 0FE30548F25FEDB1: 1791578989498L + 7CF13A4941F608E5: 1791578989499L + 76C92D826994D92B: 1791578989499L + 1AE233B4CCFE99F7: 1791579084095L + 08DDE018A804BFE7: 1791579084095L + 4F664D5E6C5FDE3B: 1791579084095L + 21D8B88155648EA6: 1791579084095L + 47F38E606AD3FF53: 1791579084095L + 4558919345C3BE5D: 1791579084115L + 7FCF6E8726BCC769: 1791579084123L + 488DBE69595F38F8: 1791579122098L + 5A081977DB9407EA: 1791579122098L + 119BED334E331C25: 1791579122105L + 4B6C5B2099B18AB7: 1791579122105L + 3824247686C3D875: 1791579314547L + 0C9F9196CA49FF8D: 1791579314547L + 08E484DD3E751697: 1791620299804L + 53B3983C04A5CB1C: 1791620299804L + 3B1F1A7CAC8B7BC7: 1791621038393L + 5EE945A1185E8336: 1791621038393L + 2E04D0F6EEABE677: 1791621038395L + 2746575C929B6C50: 1791621038395L + 4E5238F00CEED8B2: 1791621104494L + 1869893A4F8E9E9C: 1791621104494L + 072FBEB0F6F1BC48: 1791621224299L + 62C6F14FE6A82305: 1791621224299L + 4A10EADC3914EA38: 1791621238492L + 117AD4ACD2CB198A: 1791621982584L + 31AD9826FB036C98: 1791621982584L + 3AD10C31CB6BA4C2: 1791622692151L + 444ACE285311ECB4: 1791622692151L + 1F18B64C84C8809D: 1791622723198L + 18644A08B73A4B12: 1791622723198L + 5999B2D204EC280E: 1791623054752L + 216DA9782EBDFF34: 1791623054752L + 5316DF321B45D2CA: 1791623054759L + 4B0A4027FACE9108: 1791623054759L + 537CABE8F876E65A: 1791623074196L + 28408BFAA54E4FC6: 1791623074196L + 452DC5EB02D592B8: 1791623547745L + 4C45690D5EBCAB90: 1791623547745L + 74B352457BF1541B: 1791623554495L + 268319B4D86AF1FA: 1791623554495L + 70B760551AA03D3A: 1791623557545L + 230EEE4671B46D0C: 1791623611808L + 43A5CD740F24A3ED: 1791623611808L + 5B35720DF93CE2DB: 1791623731402L + 42822B1E8A53D051: 1791623731402L + 7F990FBC1A691020: 1791623857095L + 34909E6C5CB47A43: 1791623857095L + 0008773503CCA262: 1791625056806L + 47358ADC1470C82A: 1791625056806L + 26DF1427AC3966DE: 1791718906801L + 76BCC3C288514737: 1791718906801L + 49459B4370DD8D1A: 1791720390048L + 3F04107B4D2907E0: 1791720390071L + 4F6FDB5872FD9E61: 1791720394853L + 79CC596EB3BF6A64: 1791720398040L + 5DDC15AAFD2CAF47: 1791720398040L + 3B9E51CF30228269: 1791720401648L + 55A9EB63F4964FCF: 1791720401648L + 565703CBB06CEABF: 1791720401650L + 586916ED82D2DD2C: 1791720401650L + 2F56ECC8DBC90C01: 1791720406996L + 497D222F74A52DF5: 1791720406996L + 5801AF8000768808: 1791722724746L + 283CA6DB77D5D2EB: 1791722724746L + 015CB0D172D0712A: 1791722826995L + 4748831E75A840BE: 1791722826995L + 7C687DCF71D77761: 1791724204351L + 742BD37F93033B43: 1791724204351L + 12083A6D1B9E9DD2: 1791724204359L + 6263A27182B2CE3D: 1791724204359L + 33415CB421F7620A: 1791724211014L + 1EA4F0AB480D485C: 1791724211014L + 564D0E533237E951: 1791724211020L + 7C9154EF6068BE27: 1791724398294L + 25AE4E531CE97B98: 1791724398294L + 431D7329CFCAF880: 1791724398299L + 7AE502EDB73BD57A: 1791724398299L + 6DCF32EB40B8E9C6: 1791724398300L + 42D3D4E96812274C: 1791795944858L + 22BB55249EF2E819: 1791795944858L + 4B60ACBCC3B46D1D: 1791796134198L + 0F326EEEC2EBE4E5: 1791796134198L + 781B2DF9116224AE: 1791796199517L + 4D849193A07D4F83: 1791796208712L + 035EB822D59843CF: 1791796208717L + 324B835F4DCD19E1: 1791796208721L + 533879E84DC7CAEB: 1791796208725L + 493D04D954E4FBA0: 1791796271744L + 4448DE84ABA737BE: 1791796271744L + 34CFE1439262AA72: 1791796271744L + 418E333710652BFA: 1791796271744L + 2612CACBDDF9AD1A: 1791796271749L + 5E116409DC7D30BB: 1791796271749L + 4632192573FD8501: 1791796271750L + 33C69B5934BE4D64: 1791796271750L + 5B7926B511000F1F: 1791796271782L + 1834B5C1F8435D7A: 1791796271782L + 3E038C9465AFD2DF: 1791796437754L + 18783C62009934DB: 1791796437754L + 445CC9AAA6F8AAB6: 1791796437759L + 35A07C18FFEEB872: 1791796437759L + 1F65F84DA1EAFB3E: 1791796437759L + 77FDF195DA1CE7F4: 1791796437764L + 6DACC77807D1F342: 1791796437770L + 1F7C3D5DE3CE359D: 1791796437776L + 43063168AB07D6FC: 1791796437783L + 091B97D20403D516: 1791796437805L + 6CB34AE622DF9CC2: 1791796437815L + 67E7A9A65B14C933: 1791796437815L + 2BA7FC10243AA011: 1791796561200L + 4BC2D7FCDD792DBA: 1791796561200L + 1922623A26E08078: 1791796561206L + 50795B2B2A320D16: 1791796561206L + 16E5E87F1CAC042A: 1791796561206L + 3AF1DC02E9A0010C: 1791796561206L + 30DE4F5BC3EDB555: 1791797085898L + 337725ECA2C04090: 1791797085898L + 60ECCCAB66AAC05D: 1791797085903L + 6B24D8667BC856A3: 1791797085903L + 7952DA35B4F5C598: 1791797353150L + 68C11540A9010775: 1791797353150L + 3C95552D03C16F3D: 1791798072194L + 672134A1A620EEAB: 1791798072194L + 0AA9EF3981E9BA37: 1791798072199L + 03C590DB786810FB: 1791798072199L + 477B411F84342EEA: 1791798072205L + 76378687551A79C3: 1791798072205L + 09788D3638E59F3B: 1791798155899L + 090B564935D7A8F5: 1791798155899L + 3331EBE1BB4BF64D: 1791798359844L + 1BB0FB49B96E22E2: 1791798359844L + 39CEB20C5018F460: 1791798359851L + 6D6E07564D8FDD8D: 1791798359851L + 540DD4E3E568DBF2: 1791798359859L + 7C4E4793DA887DE4: 1791798359859L + 3D88946A32BFA35F: 1791798825443L + 1E0F92F07FDD162E: 1791798825443L + 238F4D40E6D9238E: 1791798936144L + 0A21773B773F34F8: 1791798936144L + 624ADD79136376C0: 1791798936145L + 4B552E9D05FF5415: 1791798936145L + 10B65DF825ACC0E7: 1791799063793L + 304A15731EA889F5: 1791799063800L + 6BD970777103D55D: 1791799161744L + 2094D1CE4BA66091: 1791799161744L + 106884349C9FF3BA: 1791799161744L + 7C2ED69E07988DF4: 1791799161744L + 4E28970476CABACD: 1791799161744L + 1E7A57902EA62332: 1791799161744L + 41BCFBEC6E169507: 1791799319398L + 0B37355262121DD5: 1791799718294L + 04DE1F990F95E617: 1791799718294L + 03F5ECD810F037D8: 1791799892101L + 7DFF18CFEB0B8DBE: 1791800009247L + 11EDB05BC54E502C: 1791800009247L + 7DEB271808127E13: 1791800009248L + 409A92D40F539485: 1791800009248L + 06CFF66846E1CE0F: 1791800385797L + 27A0BCE75F198A82: 1791800385797L + 250A0320FF2BC775: 1791800490855L + 1636F3F12E92CAEA: 1791800490855L + 2C9C9CB71941DC01: 1791801826911L + 49DC1FFCD031DB6E: 1791801826911L + 4BEC4730588463FE: 1791802209202L + 66C52B137A4FF869: 1791802209202L + 6501A410F1543C70: 1791804410704L + 0C99990D814C2823: 1791804410704L + 4EF5DE3FBA2A7AE3: 1791804556250L + 7D9A58EBADE91F54: 1791804556250L + 081DC030A0546549: 1791804727754L + 58541E64C0528FCB: 1791804727754L + 047D297988E59B6B: 1791805091197L + 4821419D44F8083F: 1791805091197L + 78DA1FE8F54FDAE8: 1791805215953L + 0D01524F249E25D7: 1791805215953L + 6F496FD2BCFE21BA: 1791805403445L + 67DBE6C59C0D9D1B: 1791805403445L + 3339445194568D77: 1791808193703L + 41910A3BC6C0E838: 1791808193703L + 6206DC761D4CDDFE: 1791808803695L + 10B368D0D52F377B: 1791808943379L + 72AB70FD8D8FABBF: 1791808943379L + 565376084F05ED6E: 1791808943391L + 0B67D1F6E147566B: 1791808943391L + 0F3D51FFD2FE8DCB: 1791808946443L + 6FF63A559722919C: 1791808946443L + 3FCEF8FF45E51608: 1791809012697L + 05FD7D9C29619813: 1791809012697L + 576428EF1CDDEF96: 1791809497144L + 30B0240543253EFB: 1791809497144L + 12ED1EE85E146A4B: 1791809512498L + 2F7ABC9758AAC23E: 1791809512498L + 28EABAF0B91E94AE: 1791810351592L + 2171DC0B13C4BD43: 1791811774894L + 3768935221C60A6D: 1791811774894L + 53A6720343E57302: 1791812829945L + 0C82CE3AFBD48C1E: 1791812829945L + 70367F545A9CB4D0: 1791812829945L + 3ED8365BAD406CC5: 1791812829945L + 5D5563142917495A: 1791812829956L + 4B274207A9646FE9: 1791812829956L + 794CD7B21426F36D: 1791822530245L + 17FE0C7748DD65CA: 1791822530245L + 5E799B92358A8732: 1791823549694L + 4346DAC18183627E: 1791823549694L + 4B2146C9527C54E7: 1791823549696L + 0AB9BED5E5144BAC: 1791823549696L + 39E3DA5BA041E2A4: 1791823549698L + 1EABF2E7AE7B8F22: 1791823549698L + 3BDB94F17765EE77: 1791823549707L + 40B5F7ECA67EBF03: 1791823549707L + 7574A782DCFB87CE: 1791824550597L + 4BF8E17EC585CA54: 1791824550597L + 6E3AD764A67829CB: 1791825298494L + 4044AC9B58C2DC35: 1791825298494L + 47A184CF57387DC7: 1791825298498L + 219A5BDA5A01A420: 1791825298498L + 48BF71269DEA1AB1: 1791835045495L + 06A7A3FC8634D2DA: 1791835045495L + 324DACE30B49D25B: 1791835045495L + 02ED3169ADC0E7F9: 1791835045495L + 4EA95AE9AF641FB4: 1791835045506L + 6A2AD67569F91F1F: 1791835045506L + 6B6B34B77A5563FC: 1791835058197L + 212EF8601746C500: 1791835058197L + 1C4ABF4518638A82: 1791835058200L + 6A0FAC4CA0768455: 1791835058200L + 084D67D36A0859B7: 1791836771597L + 5F6ACDE014A61F46: 1791836771597L + } + repeatable: { } + completion_count: { } + claimed_rewards: { + "308a693c2bd84d6b8790670aef5be601:4956CB79E44FB523": 1791559850607L + "308a693c2bd84d6b8790670aef5be601:5007C73E170C4FF6": 1791568852243L + "308a693c2bd84d6b8790670aef5be601:101D2FD24AB845AC": 1791568854567L + "308a693c2bd84d6b8790670aef5be601:4A56DF6E0204666C": 1791568856598L + "308a693c2bd84d6b8790670aef5be601:1B044C35466AFAEF": 1791568858800L + "308a693c2bd84d6b8790670aef5be601:0BC721E7CF41B60A": 1791568875909L + "308a693c2bd84d6b8790670aef5be601:371FBF809ACA40B9": 1791568875909L + "308a693c2bd84d6b8790670aef5be601:6EF5E55A7F55CA38": 1791568875910L + "308a693c2bd84d6b8790670aef5be601:14E486767CD3D466": 1791568875910L + "308a693c2bd84d6b8790670aef5be601:0920B905A30B389B": 1791568875910L + "308a693c2bd84d6b8790670aef5be601:570FFAC4E65BBF46": 1791568875910L + "308a693c2bd84d6b8790670aef5be601:289C97B3D45F7CEA": 1791568875910L + "308a693c2bd84d6b8790670aef5be601:04731B877AA067A3": 1791568875910L + "308a693c2bd84d6b8790670aef5be601:769A50AFA27134F2": 1791568875910L + "308a693c2bd84d6b8790670aef5be601:03845A435E464696": 1791568875910L + "308a693c2bd84d6b8790670aef5be601:123E45DFFDB5E287": 1791568875910L + "308a693c2bd84d6b8790670aef5be601:49795AB869D53FBA": 1791568875910L + "308a693c2bd84d6b8790670aef5be601:7E8BD57F76515A08": 1791568875910L + "308a693c2bd84d6b8790670aef5be601:3E0C9AD83C190CC8": 1791568875911L + "308a693c2bd84d6b8790670aef5be601:5B2B212F50BC74F6": 1791568875911L + "308a693c2bd84d6b8790670aef5be601:213FD686870FAC06": 1791568875911L + "308a693c2bd84d6b8790670aef5be601:30318BC56B093D00": 1791568875911L + "308a693c2bd84d6b8790670aef5be601:0CDD4192853BADC6": 1791568875911L + "308a693c2bd84d6b8790670aef5be601:2C2CD4E098E4EA6D": 1791568875911L + "308a693c2bd84d6b8790670aef5be601:15A6447533AF449F": 1791568876397L + "308a693c2bd84d6b8790670aef5be601:67EAB551221214EB": 1791568876400L + "308a693c2bd84d6b8790670aef5be601:3E3209519A6FBB68": 1791568876400L + "308a693c2bd84d6b8790670aef5be601:291CEDE02889DB80": 1791568876401L + "308a693c2bd84d6b8790670aef5be601:55E472D4A2508908": 1791568876402L + "308a693c2bd84d6b8790670aef5be601:08DE7FBC4C5D9B73": 1791568876403L + "308a693c2bd84d6b8790670aef5be601:358C852C894DD059": 1791568876403L + "308a693c2bd84d6b8790670aef5be601:66F8E6A333BC9BF6": 1791568876404L + "308a693c2bd84d6b8790670aef5be601:5C8109AE8C1BAD29": 1791568876405L + "308a693c2bd84d6b8790670aef5be601:64451D3624C2085E": 1791568876405L + "308a693c2bd84d6b8790670aef5be601:4311F03D24305238": 1791568876406L + "308a693c2bd84d6b8790670aef5be601:16E6A3E5A0BF91BA": 1791568876407L + "308a693c2bd84d6b8790670aef5be601:2BA08F25D16B9421": 1791568876408L + "308a693c2bd84d6b8790670aef5be601:49B86F222F7D3761": 1791568876409L + "308a693c2bd84d6b8790670aef5be601:2EFEA345691F570D": 1791568876411L + "308a693c2bd84d6b8790670aef5be601:2B066A28F827460C": 1791568876412L + "308a693c2bd84d6b8790670aef5be601:3A060AC1249E4D3A": 1791568876412L + "308a693c2bd84d6b8790670aef5be601:3CC6453EEA185696": 1791568876415L + "308a693c2bd84d6b8790670aef5be601:7D046DD10C7BF023": 1791568876416L + "308a693c2bd84d6b8790670aef5be601:187352DA383B7DF6": 1791568876417L + "308a693c2bd84d6b8790670aef5be601:457FAB2DDE9FBC18": 1791568876417L + "308a693c2bd84d6b8790670aef5be601:15F2DA96D08F45EE": 1791568876418L + "308a693c2bd84d6b8790670aef5be601:0A20CAF72F0E3E11": 1791568876419L + "308a693c2bd84d6b8790670aef5be601:344F5D87627A95C7": 1791568876419L + "308a693c2bd84d6b8790670aef5be601:3E9F25BFE28D3AE6": 1791568876419L + "308a693c2bd84d6b8790670aef5be601:64CD59A0372C3B5C": 1791568876420L + "308a693c2bd84d6b8790670aef5be601:5E49AB96CBF9431C": 1791568876420L + "308a693c2bd84d6b8790670aef5be601:2D116FDCEA070CBA": 1791568876420L + "308a693c2bd84d6b8790670aef5be601:3F9A3E2F866320C0": 1791568876420L + "308a693c2bd84d6b8790670aef5be601:041339FBDC3E9181": 1791568876421L + "308a693c2bd84d6b8790670aef5be601:6A4886FA200C2410": 1791568889105L + "308a693c2bd84d6b8790670aef5be601:04358096D006C946": 1791568889107L + "308a693c2bd84d6b8790670aef5be601:15B09C5CB616774A": 1791568889108L + "308a693c2bd84d6b8790670aef5be601:05E6E064A356BD50": 1791568889109L + "308a693c2bd84d6b8790670aef5be601:62734FEFB35CAA89": 1791568900145L + "308a693c2bd84d6b8790670aef5be601:09CAD4B958A24BEB": 1791568905306L + "308a693c2bd84d6b8790670aef5be601:4CF2F5980ADD8DBC": 1791568906259L + "308a693c2bd84d6b8790670aef5be601:01B466208AD081D8": 1791568906870L + "308a693c2bd84d6b8790670aef5be601:7A8E604C248E9E0B": 1791568907399L + "308a693c2bd84d6b8790670aef5be601:03B9F9A7171A4881": 1791568908499L + "308a693c2bd84d6b8790670aef5be601:5FECBB62586961F5": 1791568909308L + "308a693c2bd84d6b8790670aef5be601:7FC4EB84CCBE32D9": 1791568909661L + "308a693c2bd84d6b8790670aef5be601:410DC661CC5D20F4": 1791568910341L + "308a693c2bd84d6b8790670aef5be601:2A0643B0A0581849": 1791568910595L + "308a693c2bd84d6b8790670aef5be601:221B83F8C189F1A1": 1791568910942L + "308a693c2bd84d6b8790670aef5be601:5834AF3E0BD94AD7": 1791568911250L + "308a693c2bd84d6b8790670aef5be601:667534F77B0D08A0": 1791568913215L + "308a693c2bd84d6b8790670aef5be601:0BE74809343EA491": 1791568913217L + "308a693c2bd84d6b8790670aef5be601:7924BB8EEB78DE0D": 1791568913217L + "308a693c2bd84d6b8790670aef5be601:1FAEEED2AA6A76A1": 1791568913217L + "308a693c2bd84d6b8790670aef5be601:5C863FCC138617D2": 1791568913218L + "308a693c2bd84d6b8790670aef5be601:477F18566EC6FB2A": 1791568913218L + "308a693c2bd84d6b8790670aef5be601:3C8F065AEE4CDB80": 1791568913218L + "308a693c2bd84d6b8790670aef5be601:0CE525ECCA99C748": 1791568913218L + "308a693c2bd84d6b8790670aef5be601:36A9FD7873103E63": 1791569128844L + "308a693c2bd84d6b8790670aef5be601:2919BD4FA223CAD8": 1791569130894L + "308a693c2bd84d6b8790670aef5be601:4EB9D9828FE1B815": 1791569247534L + "308a693c2bd84d6b8790670aef5be601:47B8B05732B2F09F": 1791569250873L + "308a693c2bd84d6b8790670aef5be601:1D78E2179270F54A": 1791569264003L + "308a693c2bd84d6b8790670aef5be601:55CF29758364D4B2": 1791569955407L + "308a693c2bd84d6b8790670aef5be601:56D78BABD9B9E1ED": 1791569955408L + "308a693c2bd84d6b8790670aef5be601:1B59BB41155FFE55": 1791569955408L + "308a693c2bd84d6b8790670aef5be601:2DA383B7A79FDE9A": 1791569955408L + "308a693c2bd84d6b8790670aef5be601:54C82B7BE56A13B4": 1791569955408L + "308a693c2bd84d6b8790670aef5be601:2C46B4017AC5F3AE": 1791569955408L + "308a693c2bd84d6b8790670aef5be601:2AE7BDD533E3F839": 1791569955408L + "308a693c2bd84d6b8790670aef5be601:2C2A7012D1F87F52": 1791569955408L + "308a693c2bd84d6b8790670aef5be601:77D1205EEAD47367": 1791569955408L + "308a693c2bd84d6b8790670aef5be601:67AFD1C6A9878176": 1791569955408L + "308a693c2bd84d6b8790670aef5be601:4124A7CA30FAB48B": 1791569955408L + "308a693c2bd84d6b8790670aef5be601:08E663CBC638D862": 1791569955409L + "308a693c2bd84d6b8790670aef5be601:7E3B99D53B518048": 1791569955409L + "308a693c2bd84d6b8790670aef5be601:6A795EF7CBA25DF7": 1791569955838L + "308a693c2bd84d6b8790670aef5be601:5920FF9F85E86EB6": 1791569955840L + "308a693c2bd84d6b8790670aef5be601:1409306D81BE3A18": 1791569955840L + "308a693c2bd84d6b8790670aef5be601:370989F0ADFEE0EC": 1791569955841L + "308a693c2bd84d6b8790670aef5be601:2162B90127FA6656": 1791569955841L + "308a693c2bd84d6b8790670aef5be601:07B7AFADD4C305C1": 1791569955842L + "308a693c2bd84d6b8790670aef5be601:37C90FAC7748AF50": 1791569955843L + "308a693c2bd84d6b8790670aef5be601:54AEEA17E0FA8135": 1791569955843L + "308a693c2bd84d6b8790670aef5be601:37E4611817E3B7D1": 1791569955844L + "308a693c2bd84d6b8790670aef5be601:31CA66909C70BBD2": 1791569955844L + "308a693c2bd84d6b8790670aef5be601:5BE2B64C89F21435": 1791569955845L + "308a693c2bd84d6b8790670aef5be601:60C5AF9A89C97164": 1791569955845L + "308a693c2bd84d6b8790670aef5be601:48352E1D8C5885B8": 1791569963294L + "308a693c2bd84d6b8790670aef5be601:5BC324F4449F0732": 1791569963295L + "308a693c2bd84d6b8790670aef5be601:6CFA6ECF64B0F11F": 1791570114444L + "308a693c2bd84d6b8790670aef5be601:1C7F617A451B33EB": 1791570114992L + "308a693c2bd84d6b8790670aef5be601:6019B82199039FAD": 1791570852749L + "308a693c2bd84d6b8790670aef5be601:716EC603C1FC926F": 1791570852749L + "308a693c2bd84d6b8790670aef5be601:7C8B19F2B62BAF13": 1791570852750L + "308a693c2bd84d6b8790670aef5be601:2B7302E781A6AC1B": 1791570852750L + "308a693c2bd84d6b8790670aef5be601:05E42C3F0F26061A": 1791570852750L + "308a693c2bd84d6b8790670aef5be601:4247F14C46042AFC": 1791570853258L + "308a693c2bd84d6b8790670aef5be601:2AD8F6EAE8EF2A73": 1791570853260L + "308a693c2bd84d6b8790670aef5be601:75C1B7A9C8487826": 1791570853261L + "308a693c2bd84d6b8790670aef5be601:41E3089656803BBC": 1791570853261L + "308a693c2bd84d6b8790670aef5be601:0D935234F9A13C00": 1791570853262L + "308a693c2bd84d6b8790670aef5be601:5EAECB0D188553DE": 1791570853263L + "308a693c2bd84d6b8790670aef5be601:539532A204C98799": 1791571697861L + "308a693c2bd84d6b8790670aef5be601:4063209399EDD574": 1791571697862L + "308a693c2bd84d6b8790670aef5be601:738FEBB9C29A0B70": 1791571697862L + "308a693c2bd84d6b8790670aef5be601:65D25191099B36E8": 1791571697862L + "308a693c2bd84d6b8790670aef5be601:4F7446244FFB8094": 1791571697862L + "308a693c2bd84d6b8790670aef5be601:6492368F4FF254A7": 1791571697862L + "308a693c2bd84d6b8790670aef5be601:624BB858D3CB0912": 1791571697862L + "308a693c2bd84d6b8790670aef5be601:204938E4E04A0F20": 1791571698442L + "308a693c2bd84d6b8790670aef5be601:17AE3958527A6B92": 1791571698444L + "308a693c2bd84d6b8790670aef5be601:4D8A5DE5D26D5838": 1791571698444L + "308a693c2bd84d6b8790670aef5be601:5D265B41D964221E": 1791571698445L + "308a693c2bd84d6b8790670aef5be601:28177195AE647E97": 1791571698446L + "308a693c2bd84d6b8790670aef5be601:5BEC54791A7352FB": 1791571698446L + "308a693c2bd84d6b8790670aef5be601:46BD9205AE5E6871": 1791571723765L + "308a693c2bd84d6b8790670aef5be601:350E7B5C5FA843A3": 1791571724097L + "308a693c2bd84d6b8790670aef5be601:4D2D3786CD1BEA64": 1791572026394L + "308a693c2bd84d6b8790670aef5be601:14FB326FBF335179": 1791572026713L + "308a693c2bd84d6b8790670aef5be601:231B5B9731B07E53": 1791572027669L + "308a693c2bd84d6b8790670aef5be601:22EE7E7246E8E87E": 1791573435848L + "308a693c2bd84d6b8790670aef5be601:2399FCE4B2CC24CB": 1791573435849L + "308a693c2bd84d6b8790670aef5be601:5E13A5A23EC1E79F": 1791573435849L + "308a693c2bd84d6b8790670aef5be601:3413D696818CAB11": 1791573435849L + "308a693c2bd84d6b8790670aef5be601:60D997B5DC289133": 1791573435849L + "308a693c2bd84d6b8790670aef5be601:521AFCFE3F8C0B95": 1791573435850L + "308a693c2bd84d6b8790670aef5be601:5E00FAF6499D09FC": 1791573435850L + "308a693c2bd84d6b8790670aef5be601:4EF4E3620A7DE746": 1791573436299L + "308a693c2bd84d6b8790670aef5be601:4C83D836AF808F98": 1791573436301L + "308a693c2bd84d6b8790670aef5be601:016CEF585A7D1B3D": 1791573436302L + "308a693c2bd84d6b8790670aef5be601:3020B3DB94642847": 1791573436303L + "308a693c2bd84d6b8790670aef5be601:21F4BD3CC3708E34": 1791573436303L + "308a693c2bd84d6b8790670aef5be601:197F4FAA40D360D9": 1791573438244L + "308a693c2bd84d6b8790670aef5be601:48BF5C36004F02D4": 1791575544044L + "308a693c2bd84d6b8790670aef5be601:27A249F2662A57B2": 1791575544045L + "308a693c2bd84d6b8790670aef5be601:2613BC08CDB9516F": 1791575544045L + "308a693c2bd84d6b8790670aef5be601:79ED6E827CE8B1B8": 1791575544045L + "308a693c2bd84d6b8790670aef5be601:067713AF0F04CB00": 1791575544045L + "308a693c2bd84d6b8790670aef5be601:61AF877D7328B938": 1791575544045L + "308a693c2bd84d6b8790670aef5be601:4BB64F9FF51DBA77": 1791575544045L + "308a693c2bd84d6b8790670aef5be601:358E378F267AFB26": 1791575544045L + "308a693c2bd84d6b8790670aef5be601:72C0A302BACE0C74": 1791575544045L + "308a693c2bd84d6b8790670aef5be601:00DFACF2EA53C761": 1791575544045L + "308a693c2bd84d6b8790670aef5be601:3D51FB3501E2BBD3": 1791575544492L + "308a693c2bd84d6b8790670aef5be601:554AC9B8BD772DFF": 1791575544493L + "308a693c2bd84d6b8790670aef5be601:21F2E6AD4371C6B7": 1791575544493L + "308a693c2bd84d6b8790670aef5be601:07F174CC0D3B88BE": 1791575544494L + "308a693c2bd84d6b8790670aef5be601:116FA96039FC2359": 1791576814956L + "308a693c2bd84d6b8790670aef5be601:3EF6CC70CA664541": 1791576814956L + "308a693c2bd84d6b8790670aef5be601:65922BB4E641D525": 1791576814956L + "308a693c2bd84d6b8790670aef5be601:6F04437283A384E5": 1791576814956L + "308a693c2bd84d6b8790670aef5be601:382B2B8DF207F44F": 1791576814957L + "308a693c2bd84d6b8790670aef5be601:0CBEB0DD40A9CB80": 1791576814957L + "308a693c2bd84d6b8790670aef5be601:6DB38B87B2BB0B70": 1791576814957L + "308a693c2bd84d6b8790670aef5be601:09688F9C10379A20": 1791576815345L + "308a693c2bd84d6b8790670aef5be601:42FFCBCEDF641E38": 1791576815347L + "308a693c2bd84d6b8790670aef5be601:775CCA46E1D90BFE": 1791576815347L + "308a693c2bd84d6b8790670aef5be601:37CC4C9D02D00F80": 1791576815348L + "308a693c2bd84d6b8790670aef5be601:70556C33AE952F49": 1791576815348L + "308a693c2bd84d6b8790670aef5be601:6EE0FD0DCE6CEEDE": 1791576815349L + "308a693c2bd84d6b8790670aef5be601:24AA489F2E015748": 1791576815350L + "308a693c2bd84d6b8790670aef5be601:7611CDA3EC2EC2F0": 1791577182659L + "308a693c2bd84d6b8790670aef5be601:13AE19C718166401": 1791577182919L + "308a693c2bd84d6b8790670aef5be601:12183564E777E5E0": 1791577183240L + "308a693c2bd84d6b8790670aef5be601:67650422D108EDAC": 1791577183563L + "308a693c2bd84d6b8790670aef5be601:7386B11172F598B4": 1791577194348L + "308a693c2bd84d6b8790670aef5be601:405A74769A418204": 1791577194349L + "308a693c2bd84d6b8790670aef5be601:4384750DCCD8BCAC": 1791577194349L + "308a693c2bd84d6b8790670aef5be601:6500F8A1FD7CDE98": 1791577194349L + "308a693c2bd84d6b8790670aef5be601:27DAD35DC7E2C8C4": 1791577194349L + "308a693c2bd84d6b8790670aef5be601:06CEE001120093E8": 1791577194743L + "308a693c2bd84d6b8790670aef5be601:0F263F73458E8C1A": 1791577194745L + "308a693c2bd84d6b8790670aef5be601:28111616B1136DA6": 1791577194746L + "308a693c2bd84d6b8790670aef5be601:289392DA9F79BE65": 1791577194747L + "308a693c2bd84d6b8790670aef5be601:4796F69D08F840AF": 1791577194747L + "308a693c2bd84d6b8790670aef5be601:5B2BBBBAE5A3FD3B": 1791577194748L + "308a693c2bd84d6b8790670aef5be601:18B81A476AAD7918": 1791577194749L + "308a693c2bd84d6b8790670aef5be601:06074E4D85E9625E": 1791577194749L + "308a693c2bd84d6b8790670aef5be601:72AD97ECD624C9F1": 1791577194749L + "308a693c2bd84d6b8790670aef5be601:601B548099153FD8": 1791577222663L + "308a693c2bd84d6b8790670aef5be601:2DEB4C0CFE7C4D29": 1791577222993L + "308a693c2bd84d6b8790670aef5be601:317DD048EF0D4A8E": 1791577916573L + "308a693c2bd84d6b8790670aef5be601:1BB11BBB969C7B7B": 1791577916573L + "308a693c2bd84d6b8790670aef5be601:663292AD95F1E2D1": 1791577916895L + "308a693c2bd84d6b8790670aef5be601:29AFF06DC385D68F": 1791577916897L + "308a693c2bd84d6b8790670aef5be601:51A5CFAC8F959189": 1791577916897L + "308a693c2bd84d6b8790670aef5be601:4B45935DA547D7B4": 1791577916898L + "308a693c2bd84d6b8790670aef5be601:0289C12C1A4022C4": 1791577916898L + "308a693c2bd84d6b8790670aef5be601:748014A68CC69AA4": 1791577916899L + "308a693c2bd84d6b8790670aef5be601:1A2C25BD95D6B332": 1791577932120L + "308a693c2bd84d6b8790670aef5be601:0F416DCD09AC9026": 1791577932397L + "308a693c2bd84d6b8790670aef5be601:4D70A941AC068FD0": 1791578103109L + "308a693c2bd84d6b8790670aef5be601:0FD79DBEA296131D": 1791578103457L + "308a693c2bd84d6b8790670aef5be601:56A84014FED57806": 1791578192644L + "308a693c2bd84d6b8790670aef5be601:264BB4B6A847C599": 1791578193067L + "308a693c2bd84d6b8790670aef5be601:388D3BFF0F54C116": 1791578194641L + "308a693c2bd84d6b8790670aef5be601:7C6705B569F27D46": 1791578195094L + "308a693c2bd84d6b8790670aef5be601:2660677569A147E1": 1791578546253L + "308a693c2bd84d6b8790670aef5be601:0BC0C16377807E51": 1791578546254L + "308a693c2bd84d6b8790670aef5be601:63BC4F2532FA4B8F": 1791578547610L + "308a693c2bd84d6b8790670aef5be601:49C98505A02CF9D9": 1791578548002L + "308a693c2bd84d6b8790670aef5be601:1394A54150017C88": 1791578548442L + "308a693c2bd84d6b8790670aef5be601:11892350D0F57F0A": 1791578548765L + "308a693c2bd84d6b8790670aef5be601:776C92303452C13A": 1791578549109L + "308a693c2bd84d6b8790670aef5be601:0D0F47B9722395FB": 1791578549443L + "308a693c2bd84d6b8790670aef5be601:4E6C67FC60596199": 1791578549796L + "308a693c2bd84d6b8790670aef5be601:76915C0FFCE25CD5": 1791578550164L + "308a693c2bd84d6b8790670aef5be601:089AE645B9673F30": 1791578551160L + "308a693c2bd84d6b8790670aef5be601:48961510103ECFA9": 1791578791642L + "308a693c2bd84d6b8790670aef5be601:3154E935BF7E0C2E": 1791578791643L + "308a693c2bd84d6b8790670aef5be601:082D932BDF87998A": 1791578791993L + "308a693c2bd84d6b8790670aef5be601:7CF35F9ED70945C9": 1791578791995L + "308a693c2bd84d6b8790670aef5be601:600CE8C98D0CF3B2": 1791578791995L + "308a693c2bd84d6b8790670aef5be601:46E642A34AD07DB6": 1791578791996L + "308a693c2bd84d6b8790670aef5be601:7EA74CD7BD2DBD03": 1791579086241L + "308a693c2bd84d6b8790670aef5be601:267158EC8C481DAA": 1791579086543L + "308a693c2bd84d6b8790670aef5be601:45C1AA71947C4995": 1791579089690L + "308a693c2bd84d6b8790670aef5be601:12756A65FE318060": 1791579089956L + "308a693c2bd84d6b8790670aef5be601:5D309655B901C073": 1791579090247L + "308a693c2bd84d6b8790670aef5be601:04CDF937CBD1C53D": 1791579291051L + "308a693c2bd84d6b8790670aef5be601:79532E4325832DA5": 1791579291052L + "308a693c2bd84d6b8790670aef5be601:0A2ACA84C0CA106B": 1791579292567L + "308a693c2bd84d6b8790670aef5be601:5397DC1F400C915F": 1791579292569L + "308a693c2bd84d6b8790670aef5be601:341723A1E8951B15": 1791579292570L + "308a693c2bd84d6b8790670aef5be601:769AEF45B4D10F89": 1791579292570L + "308a693c2bd84d6b8790670aef5be601:2D599F0012C5292F": 1791579292571L + "308a693c2bd84d6b8790670aef5be601:7DA9DE79920ACF0D": 1791579292571L + "308a693c2bd84d6b8790670aef5be601:7905B57A34AF9588": 1791579292572L + "308a693c2bd84d6b8790670aef5be601:50B3CF752D26C095": 1791579292572L + "308a693c2bd84d6b8790670aef5be601:53B3F46D9DF3001B": 1791579887565L + "308a693c2bd84d6b8790670aef5be601:462DEE14F79B8CCF": 1791620911292L + "308a693c2bd84d6b8790670aef5be601:15DDBBD1EDE8A843": 1791620911566L + "308a693c2bd84d6b8790670aef5be601:2697788BE1DA45A5": 1791621043141L + "308a693c2bd84d6b8790670aef5be601:222C1E94BCF9419A": 1791621043647L + "308a693c2bd84d6b8790670aef5be601:43F516E0BB34B39A": 1791621101105L + "308a693c2bd84d6b8790670aef5be601:3650502ECF1ACC45": 1791621101443L + "308a693c2bd84d6b8790670aef5be601:01E691296A3A4432": 1791621178346L + "308a693c2bd84d6b8790670aef5be601:56D64DA716E084ED": 1791621178646L + "308a693c2bd84d6b8790670aef5be601:6B5B40ABA34AD0AC": 1791621179069L + "308a693c2bd84d6b8790670aef5be601:676E10971674E8A6": 1791621235479L + "308a693c2bd84d6b8790670aef5be601:44592C9963CB531A": 1791621235750L + "308a693c2bd84d6b8790670aef5be601:6FCE69EAA8F7D08C": 1791625053339L + "308a693c2bd84d6b8790670aef5be601:66FA05EA0440A12F": 1791625053340L + "308a693c2bd84d6b8790670aef5be601:52211DC3A6C43188": 1791625053340L + "308a693c2bd84d6b8790670aef5be601:3518A0200422A88E": 1791625053340L + "308a693c2bd84d6b8790670aef5be601:36352741729C8BB1": 1791625053340L + "308a693c2bd84d6b8790670aef5be601:18E8AA34DB10D4AF": 1791625053340L + "308a693c2bd84d6b8790670aef5be601:5CBD336F657D5F0A": 1791625053341L + "308a693c2bd84d6b8790670aef5be601:042DA96F1C15434A": 1791625053342L + "308a693c2bd84d6b8790670aef5be601:6A111F9EEADA4BA2": 1791625053744L + "308a693c2bd84d6b8790670aef5be601:275D3078C224D84C": 1791625053746L + "308a693c2bd84d6b8790670aef5be601:47DAFBFECB3B54BE": 1791625053746L + "308a693c2bd84d6b8790670aef5be601:2478604121C909A0": 1791625053747L + "308a693c2bd84d6b8790670aef5be601:6488CAF3EB5587AF": 1791625053747L + "308a693c2bd84d6b8790670aef5be601:21F180313D280A37": 1791625053748L + "308a693c2bd84d6b8790670aef5be601:28C8F5467AC0B3CA": 1791625053748L + "308a693c2bd84d6b8790670aef5be601:03B8FA520122B5F6": 1791720483343L + "308a693c2bd84d6b8790670aef5be601:78BA19D5C2159D39": 1791720483343L + "308a693c2bd84d6b8790670aef5be601:6427BC210554253B": 1791720483343L + "308a693c2bd84d6b8790670aef5be601:59A000198E8EB727": 1791720483343L + "308a693c2bd84d6b8790670aef5be601:3BB86ECD39545201": 1791720483345L + "308a693c2bd84d6b8790670aef5be601:01179EDB257DAF60": 1791720483790L + "308a693c2bd84d6b8790670aef5be601:2E37C878B24D6913": 1791720483792L + "308a693c2bd84d6b8790670aef5be601:750E749A079F915D": 1791795941340L + "308a693c2bd84d6b8790670aef5be601:524BCAEA8EC772F5": 1791795941340L + "308a693c2bd84d6b8790670aef5be601:18AFA7F8A99DB420": 1791795941342L + "308a693c2bd84d6b8790670aef5be601:6D27F6A4BACDE0EB": 1791795941342L + "308a693c2bd84d6b8790670aef5be601:699A98E44F03D330": 1791795941342L + "308a693c2bd84d6b8790670aef5be601:056975CFED74BF64": 1791795941808L + "308a693c2bd84d6b8790670aef5be601:4A6C2A97D769418B": 1791795941812L + "308a693c2bd84d6b8790670aef5be601:4BC0F06D1BAD23AF": 1791795941813L + "308a693c2bd84d6b8790670aef5be601:7068E963D0F4FF58": 1791795941814L + "308a693c2bd84d6b8790670aef5be601:4CF90B91BEEBDA77": 1791795941815L + "308a693c2bd84d6b8790670aef5be601:5712ADF318B8BCA5": 1791795941816L + "308a693c2bd84d6b8790670aef5be601:25E4D4721B55C437": 1791795941817L + "308a693c2bd84d6b8790670aef5be601:001818AFC352F576": 1791795941817L + "308a693c2bd84d6b8790670aef5be601:05476026B011B7F3": 1791795941818L + "308a693c2bd84d6b8790670aef5be601:59D1BBED0E121735": 1791796176996L + "308a693c2bd84d6b8790670aef5be601:592DDA8413504FDB": 1791796177322L + "308a693c2bd84d6b8790670aef5be601:0A7713B4BC19BB41": 1791796742153L + "308a693c2bd84d6b8790670aef5be601:6545A9A44AC20C75": 1791796742456L + "308a693c2bd84d6b8790670aef5be601:55159C553478CDC8": 1791796744242L + "308a693c2bd84d6b8790670aef5be601:38C647CDCD725418": 1791796744788L + "308a693c2bd84d6b8790670aef5be601:4AB2D7D9AA2BE472": 1791796745369L + "308a693c2bd84d6b8790670aef5be601:13D15A968B42F92E": 1791796796944L + "308a693c2bd84d6b8790670aef5be601:7EF9AB970ADA6543": 1791796796944L + "308a693c2bd84d6b8790670aef5be601:06BBE2C3833797ED": 1791797350104L + "308a693c2bd84d6b8790670aef5be601:779C78573F83BFFE": 1791797350107L + "308a693c2bd84d6b8790670aef5be601:6E44439DE3A2B59C": 1791797350108L + "308a693c2bd84d6b8790670aef5be601:67D4792D7C7FF358": 1791797350109L + "308a693c2bd84d6b8790670aef5be601:3217F5BBC9DAF0EC": 1791797350109L + "308a693c2bd84d6b8790670aef5be601:0DFF754CD9DF5964": 1791797350110L + "308a693c2bd84d6b8790670aef5be601:2154BC07C710F358": 1791797350111L + "308a693c2bd84d6b8790670aef5be601:6572C829FB6DF680": 1791797350111L + "308a693c2bd84d6b8790670aef5be601:26272E4B7C8569D4": 1791797350112L + "308a693c2bd84d6b8790670aef5be601:613AEE5D814373FE": 1791797350112L + "308a693c2bd84d6b8790670aef5be601:1C9261B07B99DB1C": 1791797350113L + "308a693c2bd84d6b8790670aef5be601:5F163E54692A7B7C": 1791797350114L + "308a693c2bd84d6b8790670aef5be601:55A16073857CB8E8": 1791797350114L + "308a693c2bd84d6b8790670aef5be601:0694B55D2359E8EC": 1791797350115L + "308a693c2bd84d6b8790670aef5be601:4642175991AD49EB": 1791797350115L + "308a693c2bd84d6b8790670aef5be601:11BD4A6B2F166B9F": 1791797350116L + "308a693c2bd84d6b8790670aef5be601:0BDA77DFE349F0AA": 1791797350117L + "308a693c2bd84d6b8790670aef5be601:5F6FD804DF4C38FB": 1791797350118L + "308a693c2bd84d6b8790670aef5be601:2E34B11E7DAE24E5": 1791797376542L + "308a693c2bd84d6b8790670aef5be601:0D0E955A5FEF26DC": 1791797376544L + "308a693c2bd84d6b8790670aef5be601:33DEE660F8E5A648": 1791798436444L + "308a693c2bd84d6b8790670aef5be601:0D227CD4C7E3E084": 1791798436445L + "308a693c2bd84d6b8790670aef5be601:1C7FBA6844D50ABA": 1791798436445L + "308a693c2bd84d6b8790670aef5be601:7733E4DC78031241": 1791798436445L + "308a693c2bd84d6b8790670aef5be601:0659C1BF97FB5990": 1791798436445L + "308a693c2bd84d6b8790670aef5be601:292F484BF4813BC8": 1791798437949L + "308a693c2bd84d6b8790670aef5be601:4C4717AB0D510404": 1791798437950L + "308a693c2bd84d6b8790670aef5be601:10FD3BDB04012C03": 1791798437951L + "308a693c2bd84d6b8790670aef5be601:083E13034B619449": 1791798437952L + "308a693c2bd84d6b8790670aef5be601:4840E4DD56DBD180": 1791798437952L + "308a693c2bd84d6b8790670aef5be601:7E2E1F9ED0EEB289": 1791798437953L + "308a693c2bd84d6b8790670aef5be601:061B8D59E524F7D1": 1791798437953L + "308a693c2bd84d6b8790670aef5be601:08AC2295DBD8B915": 1791798437954L + "308a693c2bd84d6b8790670aef5be601:17C6667F09D439F1": 1791798437955L + "308a693c2bd84d6b8790670aef5be601:72AE23E26C335677": 1791798829396L + "308a693c2bd84d6b8790670aef5be601:1BAF5B0AF3886C01": 1791798829759L + "308a693c2bd84d6b8790670aef5be601:6FD2CEC0A4595FB3": 1791799810899L + "308a693c2bd84d6b8790670aef5be601:697D22548C9D3584": 1791799811265L + "308a693c2bd84d6b8790670aef5be601:18A77E344F13378B": 1791800043346L + "308a693c2bd84d6b8790670aef5be601:7C0D133450374489": 1791800043699L + "308a693c2bd84d6b8790670aef5be601:4ADA282374D1EB4C": 1791800048488L + "308a693c2bd84d6b8790670aef5be601:1586C79D3B5B7D49": 1791800048806L + "308a693c2bd84d6b8790670aef5be601:240B81A262E2ED90": 1791800404442L + "308a693c2bd84d6b8790670aef5be601:507C22D7813C3F93": 1791800404798L + "308a693c2bd84d6b8790670aef5be601:605DDAF617A01D61": 1791801828298L + "308a693c2bd84d6b8790670aef5be601:559618949309608D": 1791801828601L + "308a693c2bd84d6b8790670aef5be601:235B50103AB0BD45": 1791802211706L + "308a693c2bd84d6b8790670aef5be601:65DB73331E300A4A": 1791802211961L + "308a693c2bd84d6b8790670aef5be601:62E1B3B13EC7F15D": 1791803547369L + "308a693c2bd84d6b8790670aef5be601:1CDD44781C36A87F": 1791803547697L + "308a693c2bd84d6b8790670aef5be601:41DD7494E8A923A2": 1791805194141L + "308a693c2bd84d6b8790670aef5be601:5C6084BC178E569A": 1791805194141L + "308a693c2bd84d6b8790670aef5be601:567F09E54EC6E4F3": 1791805194141L + "308a693c2bd84d6b8790670aef5be601:6D1513E170292C9B": 1791805194603L + "308a693c2bd84d6b8790670aef5be601:17540418EC34A14F": 1791805194604L + "308a693c2bd84d6b8790670aef5be601:13FA6BBB734A88B7": 1791805194605L + "308a693c2bd84d6b8790670aef5be601:273F031F5879F1F4": 1791805194605L + "308a693c2bd84d6b8790670aef5be601:687A19D5531FF279": 1791805194605L + "308a693c2bd84d6b8790670aef5be601:12BD722E8EBAAB4E": 1791805194605L + "308a693c2bd84d6b8790670aef5be601:201EF4EE5FE1D678": 1791805194605L + "308a693c2bd84d6b8790670aef5be601:016ECE27CE386999": 1791805194606L + "308a693c2bd84d6b8790670aef5be601:67424ACDA2F0E9F3": 1791805194606L + "308a693c2bd84d6b8790670aef5be601:658F9D5F27ADBC40": 1791805194607L + "308a693c2bd84d6b8790670aef5be601:7E434B18A1E9A711": 1791805194607L + "308a693c2bd84d6b8790670aef5be601:3C6A8A9FB9D8EC33": 1791805194608L + "308a693c2bd84d6b8790670aef5be601:49492C8F5CDF5A47": 1791805194608L + "308a693c2bd84d6b8790670aef5be601:6BFF97CB04F0CFBD": 1791805194608L + "308a693c2bd84d6b8790670aef5be601:7D222784210DB53F": 1791805194608L + "308a693c2bd84d6b8790670aef5be601:4FEAADB2A35AA5A0": 1791805194608L + "308a693c2bd84d6b8790670aef5be601:3F367C54EECC5722": 1791805194608L + "308a693c2bd84d6b8790670aef5be601:6ACB599EE6DA155F": 1791805194608L + "308a693c2bd84d6b8790670aef5be601:32D632911C900B01": 1791805194608L + "308a693c2bd84d6b8790670aef5be601:52D6922EC6C02511": 1791805194608L + "308a693c2bd84d6b8790670aef5be601:7E155F8EA88C7910": 1791805194609L + "308a693c2bd84d6b8790670aef5be601:320D5048C61B7269": 1791805408117L + "308a693c2bd84d6b8790670aef5be601:71AF8B02290BAABB": 1791805408392L + "308a693c2bd84d6b8790670aef5be601:5E148C50F90489F4": 1791808201844L + "308a693c2bd84d6b8790670aef5be601:109CF4D7629F008A": 1791808202146L + "308a693c2bd84d6b8790670aef5be601:47406628118C4FAC": 1791808202595L + "308a693c2bd84d6b8790670aef5be601:441961E8BDDFDE22": 1791808202896L + "308a693c2bd84d6b8790670aef5be601:5B0D743AA8171F56": 1791810344443L + "308a693c2bd84d6b8790670aef5be601:27BFB09994F1A168": 1791810344443L + "308a693c2bd84d6b8790670aef5be601:42EEEAE5E04CDFD6": 1791810344444L + "308a693c2bd84d6b8790670aef5be601:15DA13E2ABC006AE": 1791810344444L + "308a693c2bd84d6b8790670aef5be601:66E829005EF46BB8": 1791810344444L + "308a693c2bd84d6b8790670aef5be601:478A8E7656398BD5": 1791810344445L + "308a693c2bd84d6b8790670aef5be601:75F8E6B513937C9B": 1791810344911L + "308a693c2bd84d6b8790670aef5be601:72253D5B5719D67C": 1791810344913L + "308a693c2bd84d6b8790670aef5be601:7801AEE59EB6FAB8": 1791812518507L + "308a693c2bd84d6b8790670aef5be601:1524A05A8654DAFD": 1791812518850L + "308a693c2bd84d6b8790670aef5be601:59B12AC887E1CAD7": 1791823561715L + "308a693c2bd84d6b8790670aef5be601:61559C81C57A73C5": 1791823562049L + "308a693c2bd84d6b8790670aef5be601:01C3DA9AFAB946FA": 1791824261192L + "308a693c2bd84d6b8790670aef5be601:45BE4BD6F9CCB927": 1791824261194L + "308a693c2bd84d6b8790670aef5be601:44A4621E31A31C03": 1791824261549L + "308a693c2bd84d6b8790670aef5be601:63594966914DDDEE": 1791824261551L + "308a693c2bd84d6b8790670aef5be601:01B93E1CF0F70D99": 1791824261551L + "308a693c2bd84d6b8790670aef5be601:752FD50E2A94D9EA": 1791824261552L + "308a693c2bd84d6b8790670aef5be601:3B12B107212D8F38": 1791824261552L + "308a693c2bd84d6b8790670aef5be601:2C89B133ECDB0D55": 1791824261553L + "308a693c2bd84d6b8790670aef5be601:3BE11050F61B4BE8": 1791825314220L + "308a693c2bd84d6b8790670aef5be601:23DA6BCAF1D94343": 1791825314699L + "308a693c2bd84d6b8790670aef5be601:79D053A91F786D28": 1791826770092L + "308a693c2bd84d6b8790670aef5be601:674DBCACF5CA41AA": 1791826770414L + "308a693c2bd84d6b8790670aef5be601:35BC84DFB247D4E8": 1791827769647L + "308a693c2bd84d6b8790670aef5be601:5A74443845F5CA1A": 1791835055147L + "308a693c2bd84d6b8790670aef5be601:53B2FF2E783260B1": 1791835055819L + "308a693c2bd84d6b8790670aef5be601:3D9693405E0D7E8D": 1791835056145L + "308a693c2bd84d6b8790670aef5be601:19E1D47A8CFA5B44": 1791836768588L + "308a693c2bd84d6b8790670aef5be601:00D14232DB4BA0BB": 1791836769055L + "308a693c2bd84d6b8790670aef5be601:5FABA10FA912A66F": 1791836769393L + "308a693c2bd84d6b8790670aef5be601:30F6EC82D69B47DF": 1791836769656L + "308a693c2bd84d6b8790670aef5be601:6A5C4EB951F6F10A": 1791836769947L + "308a693c2bd84d6b8790670aef5be601:3761931B6D592980": 1791836770346L + "308a693c2bd84d6b8790670aef5be601:6679E997CFB7A594": 1791836771065L + } + player_data: { + 308a693c2bd84d6b8790670aef5be601: { + pinned_quests: ["22D2CFDDB06C720C"] + } + } +} diff --git a/backend/test/fixtures/ftbquests/c25d9ff0-dba3-48f0-b414-fad2b63473a9.snbt b/backend/test/fixtures/ftbquests/c25d9ff0-dba3-48f0-b414-fad2b63473a9.snbt new file mode 100644 index 0000000..073d671 --- /dev/null +++ b/backend/test/fixtures/ftbquests/c25d9ff0-dba3-48f0-b414-fad2b63473a9.snbt @@ -0,0 +1,1778 @@ +{ + version: 1 + uuid: "c25d9ff0dba348f0b414fad2b63473a9" + name: "KielerLP#c25d9ff0" + lock: false + rewards_blocked: false + task_progress: { + 104E1A4BF3520D67: 1L + 27BABC4910038807: 1L + 1D48B6FC70F5C745: 1L + 5EE73B206A8A3021: 1L + 34424E87D679D3AA: 1L + 28FC7C19E2A3E1B9: 1L + 1ACDF9B8AED8F168: 1L + 298EA976A7627731: 1L + 4B2621F8D8FA7CA7: 1L + 6609E5E7EDE6F92B: 1L + 1B5CF4B0B6D3F6F9: 1L + 50624A39AD15A8A6: 1L + 1CC0E97BB8DC4F35: 1L + 1C677CD515E8862B: 1L + 48A30B08A090BD75: 1L + 38FFA24FDD924194: 1L + 4555EA054687BD5F: 1L + 624ADD79136376C0: 1L + 2E97226C40818239: 1L + 6BD970777103D55D: 1L + 5E51F5B17F77573E: 1L + 4E7FDA9DE2071EE5: 1L + 52BB142F044075B4: 1L + 6118A776B40507B7: 1L + 195338A3977C682E: 1L + 76A7B9E9F755A223: 1L + 2A07AF6A43DCEA8C: 1L + 5FA5A4469CF8A9EF: 1L + 43FDC45CF464047E: 1L + 474E1537C5CA7FB7: 1L + 0008773503CCA262: 1L + 2F56ECC8DBC90C01: 1L + 792B4AC07BA2D148: 1L + 1ADA8EDA9196471C: 1L + 43BC5EBC129DCE92: 1L + 61FFB3326113FE18: 1L + 13F86953093670F6: 1L + 1DCF1EB2F1D2F966: 1L + 5A081977DB9407EA: 1L + 1AB92CF7F0246D21: 1L + 2065DC263D762764: 1L + 77211E454F98A72B: 1L + 4EEF358CAC170CC4: 1L + 78D7794707E4BD83: 1L + 41A47F38D96A9747: 1L + 2BECEC3EFA8F6DA3: 1L + 7CB9C080F0597A4D: 1L + 1F44E7BEA2D191E5: 1L + 0D3DC52244DC7ADD: 1L + 67D42E0DB8AA7578: 1L + 4E4CF3303EF32A51: 1L + 58D16A3D3A480A37: 1L + 5E8A262AF1E9DB0B: 1L + 276930FE3A5CD916: 1L + 2BC0F5A88E2F03C3: 1L + 33B4B2E11DEF74E2: 1L + 46C5C0922839BDCA: 1L + 70367F545A9CB4D0: 1L + 0197CC61699DBE7D: 1L + 2E885362FFDF770D: 1L + 1C1B919636DDC925: 1L + 7E2317305BD5F842: 1L + 4CDD283558108D8B: 1L + 2068F53F091BFD5A: 1L + 012550B8C5B1BBCC: 1L + 432FCFECDAA542F1: 1L + 1C4E971513EF2B15: 1L + 049799C40545AB1C: 1L + 02C93CCE15B79995: 1L + 091B97D20403D516: 1L + 185D97FCA1C83569: 1L + 6877D91D6603E4FD: 1L + 1934789006427AE9: 1L + 6204AF3CD7D2CC37: 1L + 6E9280AEB3B1D973: 1L + 59E3B2A8BA8579C0: 1L + 27DF6EE4B85F1792: 1L + 294D97F3514A382B: 1L + 408C5263C4664195: 1L + 103895C9792183DB: 1L + 23B6BEDDD3B41602: 1L + 0B19D86DE8DCF565: 1L + 3298C53B3E5FAF14: 1L + 28A1D25CDDBF5163: 1L + 620BF9687558DE44: 1L + 695C19A116BBBDBB: 1L + 290255C654A57980: 1L + 05B1A9B5E710DC24: 1L + 0C282319C6EAC76E: 1L + 70A3CED85106362F: 1L + 0A7D66CA65D3EFD6: 1L + 6502D0124314BF25: 1L + 6E3ACC74E0987981: 1L + 78910F1DA5FE3640: 1L + 746C44D800F4E4FB: 1L + 76F3B5C3CB49DDBD: 1L + 38BA6B3FF1EC9185: 1L + 7EDA9BD14A177D8D: 1L + 1BB0FB49B96E22E2: 1L + 17B9A643AFD7EB39: 1L + 6EB023B3AEF61518: 1L + 0B653D464BDF40AD: 1L + 4B274207A9646FE9: 1L + 704D0B6CBDB29691: 1L + 0EEB2867C1D2D227: 1L + 71321938044ADBE4: 1L + 4D389CB7B26F5EB5: 1L + 28277BA9F319240D: 1L + 211A2C594472A9C4: 1L + 333F55C8B6309F73: 1L + 3ABECD22D09DEEFB: 1L + 117AD4ACD2CB198A: 1L + 6AA00AA3ADBA5F34: 1L + 0EBE231AB868C868: 1L + 1693B07767081FB3: 1L + 7F8F61CB2A5279F1: 1L + 400E42F53FDE783C: 1L + 08BA6518F2BBDD85: 1L + 64FADC78A8AD3FE4: 1L + 3B51C96356777FE6: 1L + 4D5FA238E199BECE: 1L + 6D88F09899BB4808: 1L + 7FCF6E8726BCC769: 1L + 16670D70BD63FA37: 1L + 6627658A032CDC95: 1L + 3FD63A725A23D407: 1L + 4795ADB0596D26DA: 1L + 52A3EC00B0885573: 1L + 66013401F4FA44FC: 1L + 1960B985B54B9FBD: 1L + 0045D90350F06441: 1L + 2F6E6A8E20D0884E: 1L + 576227D79B7D383D: 1L + 6A352964478AE499: 1L + 664ADBD5CACCEC65: 1L + 49F46466DB8EDD61: 1L + 3839A67493B2E5C3: 1L + 23553673B9AC2E61: 1L + 61C6CFA5DB18E2DC: 1L + 5CA7453233441165: 1L + 423AF6C6647B1626: 1L + 6C4B8A2662030CB0: 1L + 2CE553B282962E01: 1L + 4F13A02FB0055A62: 1L + 3C45AF8C3DC5A45E: 1L + 20BC17BCC5E270A6: 1L + 03005DEF00AB48F3: 1L + 19C973A3F42D631C: 1L + 292C044797A58CC1: 1L + 25F49B7B8EC6F48B: 1L + 1F2490B2B9A2B5F9: 1L + 39AD121BCB73453D: 1L + 63282E8604721F13: 1L + 2811A593A759354C: 1L + 4448DE84ABA737BE: 1L + 537CABE8F876E65A: 1L + 55D98C63A1A01713: 1L + 297D80507C3A56E1: 1L + 285A53DB9D54595D: 1L + 55628B1EECBB5E94: 1L + 4D6630377238B530: 1L + 7033C2D58700433D: 1L + 7C3D8A97769BBBE3: 1L + 67D884FA6DD22065: 1L + 7D14A6A6ABBA9960: 1L + 52967F83338A3AC3: 1L + 7CC983792E1265FD: 1L + 14E43D7B404B8AD4: 1L + 3CAD6AD06BE4C3D3: 1L + 477AAD8E6A0BB944: 1L + 0F998B5ABA5A3DE8: 1L + 577EC349CB79E93E: 1L + 14C0CA9C756B3ABD: 1L + 6EBC44E6FDA8A350: 1L + 128DC243A78982C3: 1L + 713EFE882A8DF739: 1L + 365C31F15DC05036: 1L + 64D374ADCCF2E14C: 1L + 7570E5AAAC5CCA39: 1L + 7EA092725A784CA7: 1L + 676E0375B6D394DE: 1L + 785BFA29F6285581: 1L + 59DE118DD6597A4E: 1L + 6B84DAA99028ACEA: 1L + 6205B6B93B306A12: 1L + 4E28970476CABACD: 1L + 08082713F1556E3A: 1L + 4A10EADC3914EA38: 1L + 4559773DB06CEDD6: 1L + 16BC496604F56E3B: 1L + 323A434E1AF54408: 1L + 1311C09169CA7055: 1L + 33218EFDD498E9DB: 1L + 00DDA48F9632A5C1: 1L + 58E3131A3EA2661E: 1L + 1869893A4F8E9E9C: 1L + 1AE233B4CCFE99F7: 1L + 17E6AEEE9C081DB1: 1L + 616C07AFBAFA7AE4: 1L + 72CE838EFDDE6479: 1L + 51A617F8FBBFA712: 1L + 4F664D5E6C5FDE3B: 1L + 743C664C4E8EE776: 1L + 14ACE78261F4EE35: 1L + 720CE629EA4E37D0: 1L + 39FE4ED3B2A83A42: 1L + 0A2949304DBB3D75: 1L + 6BD95DE2DB508F9A: 1L + 540DD4E3E568DBF2: 1L + 4FBF146C9301DEC7: 1L + 26FAC6C93C935044: 1L + 0A3853BD4F62B78E: 1L + 5CEB2D56A9EA74D0: 1L + 703D7D142BD7CEC9: 1L + 279DA565E9ADF7B0: 1L + 41FF171C7D97B574: 1L + 46803BBAC2069773: 1L + 1E7A57902EA62332: 1L + 7C48234066589145: 1L + 24BA89BD9898759B: 1L + 1A66B7FFB4E2163E: 1L + 10A16F89D4AD238D: 1L + 073D1C45B3018BBA: 1L + 79FB7E7194336B30: 1L + 78DA1FE8F54FDAE8: 1L + 3768935221C60A6D: 1L + 52747BC064FD3D7F: 1L + 200CE35BF3C04F6F: 1L + 539C6F64C251543B: 1L + 4C0098C62CDF1FF9: 1L + 2ACCBA73F15785AE: 1L + 2E116AC5F25650BE: 1L + 34909E6C5CB47A43: 1L + 155EE6983A63B091: 1L + 4C45690D5EBCAB90: 1L + 09C547CC92623525: 1L + 1A12DBE58B210795: 1L + 43A5CD740F24A3ED: 1L + 4DB11C6F180743E9: 1L + 41C5BF6EDEF88B0B: 1L + 51CC9A8E36385400: 1L + 103C42C743E2A2DB: 1L + 44BDEA5268F6ABE6: 1L + 17C665008B4C0CD7: 1L + 11838D5D25FA3EDD: 1L + 202931B62E242E7F: 1L + 0C99990D814C2823: 1L + 426BED88B406A44B: 1L + 557074D4BFBAB82E: 1L + 53B3983C04A5CB1C: 1L + 0138936FB4DD8A88: 1L + 2A1D9DE362310D7E: 1L + 667004CD0469493D: 1L + 71A3B742D8757C89: 1L + 6467C991A6D45E36: 1L + 79F67E07C28F8E97: 1L + 2E92D23F149C142D: 1L + 045D1087A2692360: 1L + 6CB34AE622DF9CC2: 1L + 5936B8B8EDE0AE55: 1L + 39CEB20C5018F460: 1L + 4558919345C3BE5D: 1L + 759571AAA3323C3C: 1L + 31D0730D68948E3B: 1L + 4B6C5B2099B18AB7: 1L + 347729D52A71A889: 1L + 47DA42491982BD41: 1L + 1C9D63BCE843C437: 1L + 41A5F4BEC3477FEF: 1L + 6B772FA03562537D: 1L + 6D7DD99A02A8F451: 1L + 268319B4D86AF1FA: 1L + 53A6720343E57302: 1L + 02C822663913DFCF: 1L + 499AF83C86984D26: 1L + 1BAC364D2AF426D3: 1L + 5D2B9139C49F5ABA: 1L + 5BCD83C6A9F1AE62: 1L + 7DABA35BECFBE1E2: 1L + 6DCF32EB40B8E9C6: 1L + 4AA065E761BB6A42: 1L + 2027616929A4AA39: 1L + 064B1841F9288CC3: 1L + 1476F390A6789572: 1L + 13D240DA839AE0F9: 1L + 318E7C9C3687A8A4: 1L + 76C92D826994D92B: 1L + 32E6C837728B2EF1: 1L + 3916C13A250BB5E9: 1L + 712552C459B5BD54: 1L + 206791A6A8DA0F16: 1L + 60907E798B65407F: 1L + 3DF1F367C3EA9631: 1L + 042B93D43308564F: 1L + 1E87CE2F633083A4: 1L + 3CC50EAEEE1E8A56: 1L + 7213278CEE1B25A3: 1L + 40B39896B6CD41C6: 1L + 77329B944064E893: 1L + 5801AF8000768808: 2L + 18644A08B73A4B12: 2L + 18980EBAE4ED777A: 2L + 0F94F7427F99DAFE: 2L + 01BBDC6956BD2DD3: 2L + 7E538C87D777B809: 2L + 46974912B1E8E78B: 2L + 381CBCFB71018C25: 2L + 78AC8EC04CDAFFDE: 2L + 0F8ACB07CFA8CB33: 2L + 3621D48E2CFAF2C2: 2L + 5824C93C84312142: 2L + 1D136D138EF82DA9: 2L + 5B35720DF93CE2DB: 3L + 015CB0D172D0712A: 3L + 7F822A21A9F71A4F: 3L + 1AFB7D87A71F862A: 3L + 0CAC6A36AC5A6708: 3L + 5E2F12A4A10B340F: 3L + 7F08D4E77359BF4A: 3L + 6890EB2819D88073: 3L + 31103B425588A4DF: 3L + 36AD2A449FE452E8: 3L + 00655AC734444E54: 3L + 73B6ED422F9292D1: 3L + 2E17443A1BF2A0DA: 3L + 7330FAC338D718B8: 3L + 3824247686C3D875: 4L + 3FBFC3BAACA69389: 4L + 1DF996B4E387DB41: 4L + 73F9C130378195B9: 4L + 5457CD8C1ABA0B9E: 5L + 58BF5D3267CF02F8: 5L + 0EA4B08DAAFA4287: 5L + 6BA6847A172C8DD7: 5L + 53C23DB8B4D1CEE2: 5L + 5056DC37AEF523E5: 6L + 51471254BFEBDAA7: 6L + 3AD10C31CB6BA4C2: 6L + 3D62C020980A2DEC: 6L + 76BCC3C288514737: 6L + 492B866023128B7B: 6L + 6206DC761D4CDDFE: 8L + 03EB390E79866058: 8L + 78082DDA22FC7B9B: 8L + 611CC8005150DC9B: 10L + 4B83A0D1C2C3C226: 10L + 173044E1C1134A80: 16L + 460FA61A72720319: 16L + 21FC6D010E5D5360: 16L + 52874F5B79ECAE65: 16L + 0CA0B32D52395A39: 20L + 3045BF441AC70B19: 24L + 7A7018026E464DF3: 24L + 35D437CE90158B46: 24L + } + started: { + 5B00676D79306EA2: 1791567331464L + 0000000000000001: 1791567331464L + 3BC0A50886A3222B: 1791567331464L + 33218EFDD498E9DB: 1791567331464L + 4293754F9B2D05F0: 1791567331470L + 00DDA48F9632A5C1: 1791567331470L + 2C4299D33D419AA0: 1791567331470L + 78910F1DA5FE3640: 1791567331471L + 11919EBB416B2BD0: 1791567331471L + 730130CCF1CF5119: 1791567331471L + 18AA64DF63F68762: 1791567331474L + 3DF1F367C3EA9631: 1791567331474L + 52A3EC00B0885573: 1791567331474L + 1F748ACA3976A92F: 1791567331474L + 12AD9789D962B179: 1791567331474L + 3009018DEC1EC952: 1791567331475L + 08BA6518F2BBDD85: 1791567331475L + 14E43D7B404B8AD4: 1791567331475L + 4EBB2171D5A3AAAC: 1791567331475L + 692183ACBB2AABF7: 1791567331475L + 76DF5FAA7961E5E7: 1791567331475L + 24151251C8730F2E: 1791567331479L + 40AD1851C6851925: 1791567331479L + 0B653D464BDF40AD: 1791567331479L + 0496E8E786262464: 1791567331480L + 0F998B5ABA5A3DE8: 1791567331480L + 59395B6806F2A98A: 1791567331480L + 0731FA8830F28280: 1791567331876L + 196312DF0D2E2CB6: 1791567331876L + 0A3853BD4F62B78E: 1791567331876L + 44F441765D7CB182: 1791567333197L + 13F86953093670F6: 1791567333197L + 637EF6D9DFC59EE8: 1791567333197L + 689F32883C4E9502: 1791567361115L + 276930FE3A5CD916: 1791567361115L + 5103E2D95685ADFF: 1791567361120L + 43BC5EBC129DCE92: 1791567361120L + 7D359283625C1869: 1791567361120L + 064B1841F9288CC3: 1791567472996L + 6F65216B13E9FFA9: 1791567472996L + 7A5C327771634A7D: 1791567472996L + 0052E5592A6BBEC1: 1791567472997L + 0EBE231AB868C868: 1791567472997L + 44688FC2E34FF09F: 1791567472998L + 46803BBAC2069773: 1791567472998L + 78D7794707E4BD83: 1791567524363L + 05E614FDA677D85E: 1791567524363L + 1827DEEA2DF1B144: 1791567524363L + 6609E5E7EDE6F92B: 1791567529797L + 5577F49A93ADB19C: 1791567529797L + 1FAAEF0B9FD05CB9: 1791567529797L + 0481E975F317F64D: 1791567542086L + 7CC983792E1265FD: 1791567542086L + 5D8E83698AEF1092: 1791567542086L + 61BE7882EE0E6FAF: 1791567543136L + 7213278CEE1B25A3: 1791567543136L + 61FFB3326113FE18: 1791567543137L + 4EA2DB12E8F0068F: 1791567543137L + 732824E03978A934: 1791567543138L + 285A53DB9D54595D: 1791567543138L + 5CF320E9C4C1B1E1: 1791567543138L + 7E49C985A0433B20: 1791567552013L + 712552C459B5BD54: 1791567552013L + 6AEDA2F9BEB57759: 1791567552019L + 2BC0F5A88E2F03C3: 1791567552019L + 33682F4B44950123: 1791567552019L + 7A72E12C3839D165: 1791567708116L + 0CA0B32D52395A39: 1791567708116L + 746C44D800F4E4FB: 1791567722446L + 282CE3A088AE5CFB: 1791567722446L + 2AEC294578952BFD: 1791567774740L + 23553673B9AC2E61: 1791567774740L + 26E6ED94168A05C4: 1791567774740L + 5C990F7E5D345A6F: 1791567774744L + 2E97226C40818239: 1791567774744L + 53A6720343E57302: 1791567826750L + 0C82CE3AFBD48C1E: 1791567826750L + 72717D1135486D7F: 1791567826753L + 73B6ED422F9292D1: 1791567826753L + 64FADC78A8AD3FE4: 1791567826754L + 0F96DC3563DA78EF: 1791567826754L + 54735C5FCD2077B3: 1791567826754L + 3F185554793CCE54: 1791567857393L + 4AA065E761BB6A42: 1791567857393L + 7975C7145572C438: 1791567857394L + 5CA7453233441165: 1791567857394L + 2E885362FFDF770D: 1791567857396L + 2EE4E88FC814AFA4: 1791567857396L + 4B9D26E0087CD163: 1791567857396L + 292C044797A58CC1: 1791567857396L + 5151CDD8FCDE7A07: 1791567857396L + 43BB00A7A7E1042C: 1791567857398L + 21D323D95F5A7DB3: 1791567857398L + 049799C40545AB1C: 1791567857398L + 4EAAE0CEB26378A6: 1791567857400L + 4E9B35DCA018FD42: 1791567857400L + 3CAD6AD06BE4C3D3: 1791567857400L + 6F5F1C525CF08F96: 1791567857403L + 279DA565E9ADF7B0: 1791567857403L + 0460DDEEFDEB0AAF: 1791567857412L + 3ABECD22D09DEEFB: 1791567857412L + 007B547630FF0478: 1791567857412L + 3CC50EAEEE1E8A56: 1791567857420L + 3166CA00032C18F7: 1791567857420L + 09955CF85F579E0F: 1791567860491L + 7D14A6A6ABBA9960: 1791567860491L + 1972B36F72808D55: 1791567860492L + 2BECEC3EFA8F6DA3: 1791567860492L + 173044E1C1134A80: 1791567860493L + 57F47BA495B27F83: 1791567860494L + 02C822663913DFCF: 1791567860494L + 2E6D11A5F5626A6C: 1791567880849L + 4CDD283558108D8B: 1791567880849L + 106884349C9FF3BA: 1791567980697L + 4E28970476CABACD: 1791567980697L + 2094D1CE4BA66091: 1791567980698L + 1E7A57902EA62332: 1791567980698L + 624ADD79136376C0: 1791568137592L + 4B552E9D05FF5415: 1791568137592L + 4EEF358CAC170CC4: 1791568137597L + 219B7A003658D24F: 1791568137597L + 15540271BF17C2E4: 1791568137597L + 3F68BA498E877C8C: 1791568137602L + 59E3B2A8BA8579C0: 1791568137602L + 27BABC4910038807: 1791568144544L + 0EB6ED88895366D0: 1791568144544L + 1ADA8EDA9196471C: 1791568144546L + 4B7D387CEFF9667E: 1791568144546L + 100C477F4E63F20A: 1791568144550L + 57A7A5C79389A96A: 1791568144550L + 2811A593A759354C: 1791568144550L + 539C6F64C251543B: 1791568190644L + 24C9BA6DC32CFAD9: 1791568190644L + 45996B848792379E: 1791568268854L + 1476F390A6789572: 1791568268854L + 092BB595F75D5536: 1791568281289L + 6B772FA03562537D: 1791568281289L + 5B35720DF93CE2DB: 1791568283912L + 015CB0D172D0712A: 1791568283912L + 42822B1E8A53D051: 1791568283912L + 410E0DD607CB3469: 1791568283912L + 18A429E7F56AF5A9: 1791568283912L + 00655AC734444E54: 1791568283912L + 4748831E75A840BE: 1791568283912L + 555957ED58ABF8F8: 1791568283912L + 2E17443A1BF2A0DA: 1791568283912L + 6E3ACC74E0987981: 1791568284093L + 41C0948CD9D50322: 1791568284093L + 6AA00AA3ADBA5F34: 1791568303098L + 7377C657CED885AC: 1791568303098L + 667004CD0469493D: 1791568303103L + 1CC4F8570A7A99EB: 1791568303103L + 08E484DD3E751697: 1791568321896L + 53B3983C04A5CB1C: 1791568321896L + 69F11A94D44AB5CF: 1791568333463L + 08082713F1556E3A: 1791568333463L + 25F49B7B8EC6F48B: 1791568574496L + 3917DECAEF56A06A: 1791568574496L + 23983F4DC524B14B: 1791568656151L + 6DCF32EB40B8E9C6: 1791568656151L + 47F38E606AD3FF53: 1791568656151L + 1C1B919636DDC925: 1791568730542L + 2B9AF0D6ED8BA628: 1791568730542L + 698A959C9E449592: 1791568733643L + 03005DEF00AB48F3: 1791568733643L + 41A47F38D96A9747: 1791568733644L + 70ED849A2EB44DEE: 1791568733644L + 5A94A2664BFDD7B9: 1791568752344L + 51CC9A8E36385400: 1791568752344L + 1DB294A8F8686321: 1791568752344L + 7EDA9BD14A177D8D: 1791568752347L + 0682DC1F2417DAEB: 1791568752347L + 073D1C45B3018BBA: 1791568752351L + 134608BB2B04D96E: 1791568752351L + 07210DDF872160BA: 1791568752354L + 408C5263C4664195: 1791568752354L + 2893F483C10293E6: 1791568752354L + 5E31DF282998B992: 1791568752361L + 28A1D25CDDBF5163: 1791568752361L + 3A9AC6AB4407DC32: 1791568752361L + 3708A4780ACEB34E: 1791568901200L + 52BB142F044075B4: 1791568901200L + 0C93D7A607AB8B83: 1791568907350L + 103C42C743E2A2DB: 1791568907350L + 10A16F89D4AD238D: 1791568910458L + 7658C1C663394E85: 1791568910458L + 5AC1BE754210429E: 1791568913421L + 4F13A02FB0055A62: 1791568913421L + 28FDB2D13D0DDE8E: 1791568956996L + 0138936FB4DD8A88: 1791568956996L + 5FA5A4469CF8A9EF: 1791568956997L + 35C46E6E27C9CB1A: 1791568956997L + 0FE30548F25FEDB1: 1791568956997L + 720CE629EA4E37D0: 1791568956997L + 7CF13A4941F608E5: 1791568956998L + 76C92D826994D92B: 1791568956998L + 06FFBB12CFA87815: 1791568956999L + 1A66B7FFB4E2163E: 1791568956999L + 195338A3977C682E: 1791568957000L + 392F34FD4FCC794A: 1791568957000L + 70B6C9409AE69284: 1791568957001L + 58E3131A3EA2661E: 1791568957001L + 1BE666F01EFFC00D: 1791568957001L + 37B297640F537383: 1791568957006L + 1A12DBE58B210795: 1791568957006L + 460FA61A72720319: 1791568957009L + 2278A98DCDF713D7: 1791568957009L + 55628B1EECBB5E94: 1791568957011L + 497AE687B7E95DE8: 1791568957011L + 6ECB27052FCB874E: 1791569129341L + 759571AAA3323C3C: 1791569129341L + 3EA883C0BB7BD38F: 1791569188104L + 7F08D4E77359BF4A: 1791569188104L + 56EC79AF11B0869E: 1791569188111L + 3621D48E2CFAF2C2: 1791569188111L + 4C507C004144BFEE: 1791569188111L + 0CAC6A36AC5A6708: 1791569199259L + 1697CC05D08B388D: 1791569199259L + 46974912B1E8E78B: 1791569199259L + 1D2EF12FD7FDD217: 1791569199259L + 611CC8005150DC9B: 1791569199259L + 704D0B6CBDB29691: 1791569215364L + 1D42B373285DEF81: 1791569215364L + 6D88F09899BB4808: 1791569215371L + 42D173B9FF8D16E4: 1791569215371L + 5D045EF1AB73DF70: 1791569215371L + 7D38BC3DB3406F51: 1791569423951L + 39AD121BCB73453D: 1791569423951L + 752DD0BF36B70D88: 1791569423954L + 6627658A032CDC95: 1791569423954L + 76C63670624C3C56: 1791569423956L + 0D3DC52244DC7ADD: 1791569423956L + 703D7D142BD7CEC9: 1791569423965L + 47750F3CB020104C: 1791569423965L + 73180B829B960E63: 1791569423965L + 38BA6B3FF1EC9185: 1791569481248L + 2E116AC5F25650BE: 1791569481250L + 051E0C85E7B71CE0: 1791569481250L + 4791C8461B1B4D5F: 1791569481253L + 4DF683461225690F: 1791569481253L + 7C3D8A97769BBBE3: 1791569481253L + 211FD55DEB635A98: 1791569481255L + 14ACE78261F4EE35: 1791569481255L + 711826BBCA832EE2: 1791569481259L + 2A07AF6A43DCEA8C: 1791569481259L + 4555EA054687BD5F: 1791569481261L + 7242F591DA474B37: 1791569481261L + 77329B944064E893: 1791569481267L + 58B125BD4876054C: 1791569481267L + 4F89C04CEBF812F9: 1791569481274L + 400E42F53FDE783C: 1791569481274L + 4EEDECACDE8F5A67: 1791569481274L + 175D943160C10646: 1791569481275L + 297D80507C3A56E1: 1791569481275L + 2FB24A5A597459FC: 1791569481275L + 1F44E7BEA2D191E5: 1791569481282L + 0D990A1D1E088701: 1791569481282L + 5CCF92238B23EDC9: 1791569481282L + 76EA017B12E8F01B: 1791569481290L + 1F81EA5E45424308: 1791569481290L + 128DC243A78982C3: 1791569481290L + 4D6630377238B530: 1791569481290L + 02C93CCE15B79995: 1791569481291L + 66013401F4FA44FC: 1791569481291L + 7CB9C080F0597A4D: 1791569481292L + 0542FE108089A0A2: 1791569481292L + 5936B8B8EDE0AE55: 1791569546443L + 0596F6C70758EEC0: 1791569546443L + 48A30B08A090BD75: 1791569546449L + 6CD7D99AA102A5C3: 1791569546449L + 3DEB33F78398EAD6: 1791569546449L + 6F62B5510FA881CD: 1791569546454L + 52967F83338A3AC3: 1791569546454L + 23631A4A743D4F0C: 1791569565604L + 2E92D23F149C142D: 1791569565604L + 0C282319C6EAC76E: 1791569583902L + 217DB12EA32E1724: 1791569583902L + 372B5BF416CA83A4: 1791569587297L + 1DCF1EB2F1D2F966: 1791569587297L + 23B6BEDDD3B41602: 1791569587303L + 22C20C1827FE6805: 1791569587303L + 2262AB9F934165D0: 1791569587303L + 4B35C01F5D0AAC58: 1791569587305L + 6E9280AEB3B1D973: 1791569587305L + 2027616929A4AA39: 1791569587306L + 25EE7E0119010291: 1791569587306L + 16BC496604F56E3B: 1791569591148L + 3685B52446DEC809: 1791569591148L + 695C19A116BBBDBB: 1791569591157L + 5E8A262AF1E9DB0B: 1791569635303L + 1659A59303C15CC9: 1791569635303L + 042B93D43308564F: 1791569663693L + 67B87570C340C243: 1791569663698L + 40B39896B6CD41C6: 1791569663698L + 2F56ECC8DBC90C01: 1791569670741L + 497D222F74A52DF5: 1791569674003L + 55D98C63A1A01713: 1791569674003L + 034DB3C64F24C7B7: 1791569725496L + 27DF6EE4B85F1792: 1791569725496L + 6501A410F1543C70: 1791569725500L + 0C99990D814C2823: 1791569725500L + 4BA2F4AC2A08C294: 1791569725505L + 01BBDC6956BD2DD3: 1791569725505L + 78AC8EC04CDAFFDE: 1791569725505L + 4F56BFE6E4716F6D: 1791569725505L + 4A6B585C2394A89A: 1791570208547L + 012550B8C5B1BBCC: 1791570208547L + 792AD692F1468B1F: 1791570208547L + 73350AD668200E99: 1791570208549L + 4B2621F8D8FA7CA7: 1791570208549L + 58248AF507380501: 1791570208559L + 16670D70BD63FA37: 1791570208559L + 76A0FBADACAD322B: 1791570208559L + 76F3B5C3CB49DDBD: 1791570565592L + 377A41499D8218C9: 1791570565592L + 13D240DA839AE0F9: 1791570565594L + 73F9C130378195B9: 1791570573091L + 4DE1158931F84F22: 1791570573091L + 426BED88B406A44B: 1791570573094L + 1FC7ABEAB5018E79: 1791570573094L + 0A52D0932DA3F809: 1791570573098L + 26FAC6C93C935044: 1791570573098L + 37EBB8E0D6E5F821: 1791570573098L + 185D97FCA1C83569: 1791570573103L + 0A093D8C4429B627: 1791570573104L + 77211E454F98A72B: 1791570573104L + 7B0764DDE94E73D0: 1791570573104L + 5AEA705D6A64A982: 1791570573104L + 557074D4BFBAB82E: 1791570573104L + 4AB0DD227471FDBF: 1791570573105L + 1F2490B2B9A2B5F9: 1791570573105L + 6AEC23C6D526F362: 1791570573114L + 4D4DE266A2A325B0: 1791570573114L + 47DA42491982BD41: 1791570573114L + 72E1C19156633391: 1791570583951L + 2CE553B282962E01: 1791570583951L + 00F00B0B66059996: 1791570583953L + 67D42E0DB8AA7578: 1791570583953L + 6EBC44E6FDA8A350: 1791570583953L + 41C5BF6EDEF88B0B: 1791570583953L + 70CCE558E03227AB: 1791570583953L + 4D389CB7B26F5EB5: 1791570583958L + 2B5E14A47062F83C: 1791570583958L + 2F6E6A8E20D0884E: 1791570583967L + 7671AFAA1EFD287F: 1791570583967L + 798B592E956AD28C: 1791570583968L + 4559773DB06CEDD6: 1791570583968L + 4CCEC13CC5A5772D: 1791570583969L + 38FFA24FDD924194: 1791570583969L + 1934789006427AE9: 1791570587050L + 022C38096F79EF07: 1791570587050L + 1FFF00B5D90C3905: 1791570587054L + 7F8F61CB2A5279F1: 1791570587054L + 16C74B5F99AD6724: 1791570587057L + 713EFE882A8DF739: 1791570587057L + 6204AF3CD7D2CC37: 1791570587066L + 62C60FF24B2F7290: 1791570587066L + 078B69E9362A5496: 1791570741599L + 7FCF6E8726BCC769: 1791570741599L + 6A352964478AE499: 1791570741599L + 7276892E129A739B: 1791570741599L + 0EEB2867C1D2D227: 1791570741599L + 75E4751F7A802A44: 1791570741599L + 52874F5B79ECAE65: 1791571099715L + 20F9EF925DB1CE05: 1791571099715L + 002B8F2DE2CB4D2C: 1791571802049L + 1E87CE2F633083A4: 1791571802049L + 2A1D9DE362310D7E: 1791572031743L + 5457CD8C1ABA0B9E: 1791572046523L + 3AD10C31CB6BA4C2: 1791572046523L + 444ACE285311ECB4: 1791572046523L + 146232B8504789C1: 1791572046523L + 2B05A29C62676EB2: 1791572046523L + 5056DC37AEF523E5: 1791572046523L + 51471254BFEBDAA7: 1791572046523L + 7D5B36BF3EC0C93F: 1791572046523L + 202931B62E242E7F: 1791572764120L + 4F35D04721DFC9FF: 1791572764120L + 6ED2CDD01659FD10: 1791573106162L + 576227D79B7D383D: 1791573106162L + 3916C13A250BB5E9: 1791573106168L + 5D64250B940BC8FD: 1791573106168L + 2AAF57D1D8AB6BF8: 1791573587720L + 58BF5D3267CF02F8: 1791573587720L + 6BA6847A172C8DD7: 1791573587720L + 2916F873A4DA84E8: 1791573587720L + 479A4E778D8D7317: 1791573587720L + 53C23DB8B4D1CEE2: 1791573587720L + 0EA4B08DAAFA4287: 1791573587721L + 17F3AC15ADCFB175: 1791573587721L + 2A1BFB2E0F1F3312: 1791573591324L + 79F67E07C28F8E97: 1791573591324L + 0BBDB63F944F61A7: 1791573591328L + 7570E5AAAC5CCA39: 1791573591328L + 67D884FA6DD22065: 1791574291727L + 3BF5B8DE7A1CC4D7: 1791574291727L + 1CC0E97BB8DC4F35: 1791574304262L + 204B969DA081056A: 1791574304262L + 340CDF4357E22A43: 1791574305814L + 14C0CA9C756B3ABD: 1791574305814L + 4E7FDA9DE2071EE5: 1791574305820L + 300F2E45D185A9A1: 1791574305820L + 2E9C035EEE7E5C34: 1791575236117L + 63282E8604721F13: 1791575236117L + 0F55D0B4D5094EDB: 1791575629086L + 1D136D138EF82DA9: 1791575629086L + 635620A03E3505BF: 1791575632177L + 5824C93C84312142: 1791575632177L + 0197CC61699DBE7D: 1791575858320L + 3AA836E8EDB52097: 1791575858320L + 1DF996B4E387DB41: 1791575858326L + 2AED15E3AC0346FE: 1791575858326L + 31E7F7464D79F881: 1791575858327L + 7330FAC338D718B8: 1791575858327L + 70367F545A9CB4D0: 1791576936595L + 3ED8365BAD406CC5: 1791576936595L + 5D5563142917495A: 1791629889048L + 4B274207A9646FE9: 1791629889048L + 2171DC0B13C4BD43: 1791629889050L + 3768935221C60A6D: 1791629889050L + 5CEB2D56A9EA74D0: 1791630047693L + 481735FE3164C8D5: 1791630047693L + 76F675A898EAB4E8: 1791630047697L + 0A2949304DBB3D75: 1791630047697L + 27F62C4B42B16015: 1791630047704L + 7033C2D58700433D: 1791630047704L + 71321938044ADBE4: 1791630047714L + 2B2BB53FA86A07B7: 1791630047714L + 034A9FBCA6D79BB6: 1791630115745L + 5712300EF4E5E846: 1791630115745L + 616C07AFBAFA7AE4: 1791630115745L + 43021923E220CF68: 1791630115748L + 323A434E1AF54408: 1791630115748L + 0008773503CCA262: 1791630618493L + 47358ADC1470C82A: 1791630618493L + 5E4BC0F59C90433A: 1791630663593L + 6205B6B93B306A12: 1791630663593L + 7F990FBC1A691020: 1791630669939L + 34909E6C5CB47A43: 1791630669939L + 1FE052F643401232: 1791630726990L + 7C48234066589145: 1791630726990L + 3F3F3174BD9D03BD: 1791630779395L + 6B84DAA99028ACEA: 1791630779395L + 0F3D51FFD2FE8DCB: 1791630792192L + 6206DC761D4CDDFE: 1791630792192L + 230EEE4671B46D0C: 1791630795400L + 43A5CD740F24A3ED: 1791630795400L + 41FF171C7D97B574: 1791630880139L + 3497C2E05DF5A87C: 1791630880139L + 38A1295878B68F83: 1791630954393L + 365C31F15DC05036: 1791630954393L + 71A3B742D8757C89: 1791630954393L + 131EC039435B8878: 1791631020244L + 1C677CD515E8862B: 1791631020244L + 6B2027DA7AA6FF34: 1791631020248L + 3C45AF8C3DC5A45E: 1791631020248L + 2A6EBEEBAB882679: 1791631020248L + 7941938014E97A30: 1791631068898L + 31D0730D68948E3B: 1791631068898L + 44BD0B40EB5451AF: 1791631077193L + 44BDEA5268F6ABE6: 1791631077193L + 4E4CF3303EF32A51: 1791631206494L + 6DBF9CAB37B9BBF3: 1791631206494L + 1693B07767081FB3: 1791631270619L + 7741905EA8380B25: 1791631270619L + 33B4B2E11DEF74E2: 1791631270620L + 3AE0198177A8E91B: 1791631270620L + 72CE838EFDDE6479: 1791631282247L + 166971866A9234C7: 1791631282247L + 7C687DCF71D77761: 1791631282248L + 3B51C96356777FE6: 1791631282248L + 5E51F5B17F77573E: 1791631286399L + 0498A578D0EC3254: 1791631286399L + 19341B01E048ACAB: 1791631286400L + 676E0375B6D394DE: 1791631286400L + 0CD04A00529FE1C9: 1791631303651L + 200CE35BF3C04F6F: 1791631303651L + 3FD63A725A23D407: 1791631336945L + 4935209D870E25F2: 1791631336945L + 6890EB2819D88073: 1791631665298L + 0A1C2A7A8617D3E4: 1791631665298L + 4E895C54651A3CB3: 1791631665302L + 32E6C837728B2EF1: 1791631665302L + 51A617F8FBBFA712: 1791631665303L + 6E0E13806F388D7E: 1791631665303L + 4A10EADC3914EA38: 1791631827602L + 17DC77F7F8C68AE6: 1791631827602L + 1AFB7D87A71F862A: 1791631827608L + 3B4CEE8A8CE0D6CB: 1791631827608L + 0B826D13BAD43EEB: 1791631827608L + 36AD2A449FE452E8: 1791631827608L + 5E2F12A4A10B340F: 1791631827608L + 31103B425588A4DF: 1791631827608L + 1F7D147C9AF6A4FC: 1791631827608L + 2DB2A4A1182FE0BB: 1791631827608L + 131AA933D59D3017: 1791631827608L + 49F46466DB8EDD61: 1791632552641L + 58514FDE153FD971: 1791632552641L + 155EE6983A63B091: 1791633339948L + 1F31EBF49079F9D3: 1791633339948L + 5801AF8000768808: 1791633360302L + 63A1F25DF658928A: 1791633360302L + 283CA6DB77D5D2EB: 1791633360302L + 0F94F7427F99DAFE: 1791633360302L + 2D7E335B9D780E70: 1791633360302L + 1F18B64C84C8809D: 1791633360302L + 18644A08B73A4B12: 1791633360302L + 0F8ACB07CFA8CB33: 1791633360302L + 5C3FF43CF16BCF30: 1791633669100L + 03EB390E79866058: 1791633669100L + 298EA976A7627731: 1791633681225L + 111E4ACF7D570EE8: 1791633681225L + 0B11EAB8D76529E0: 1791633681226L + 0A7D66CA65D3EFD6: 1791633681226L + 13B172FA69904D9F: 1791633681226L + 4D57A07592AED307: 1791633688571L + 52747BC064FD3D7F: 1791633688571L + 17B9A643AFD7EB39: 1791633765646L + 76406EFFF8CBA6B4: 1791633765646L + 17E6AEEE9C081DB1: 1791633765648L + 1682CED78D9D4BA7: 1791633765648L + 290255C654A57980: 1791633765650L + 6B671CB07D046BEC: 1791633765650L + 6601450C63FEEE61: 1791633765652L + 206791A6A8DA0F16: 1791633765652L + 5E41363F9AE243F3: 1791633765658L + 09C547CC92623525: 1791633765658L + 5ED368C926E5F32C: 1791633864166L + 6D7DD99A02A8F451: 1791633864166L + 6EDAB29FBD3C60A3: 1791633864175L + 1D48B6FC70F5C745: 1791633864175L + 423AF6C6647B1626: 1791633880274L + 24BA89BD9898759B: 1791633880274L + 5FCA4FF8C135435E: 1791633880274L + 77FC692AC94D2EEF: 1791633880274L + 1AB92CF7F0246D21: 1791633880274L + 76A29816F19E33A2: 1791633880274L + 045D1087A2692360: 1791633880274L + 65BBA5C0DAEEC31F: 1791633880274L + 361DFDB1E1352D6B: 1791634729598L + 6118A776B40507B7: 1791634729598L + 604F2B68B27885BF: 1791635065146L + 7732CF7AAA63DB3A: 1791635065146L + 70A3CED85106362F: 1791635065146L + 0AB4F9AED808DE48: 1791635073644L + 326C63F7DC713E71: 1791635073644L + 11838D5D25FA3EDD: 1791635073644L + 0F16498769DFB3B0: 1791636519659L + 5D2B9139C49F5ABA: 1791636519659L + 104E1A4BF3520D67: 1791636744238L + 48971CE964CF037F: 1791636744238L + 01A90E2541C91DB2: 1791636744243L + 0B19D86DE8DCF565: 1791636744243L + 6CCE920735187234: 1791636744247L + 6B7E13AB3BF2C5C8: 1791636744247L + 7A7018026E464DF3: 1791636744247L + 35D437CE90158B46: 1791636744247L + 3045BF441AC70B19: 1791636744247L + 492B866023128B7B: 1791636744247L + 008E65AF545A706E: 1791636744247L + 43FDC45CF464047E: 1791636752766L + 0F14270F0A718C01: 1791636752766L + 3331EBE1BB4BF64D: 1791636759642L + 1BB0FB49B96E22E2: 1791636759642L + 39CEB20C5018F460: 1791636759648L + 6D6E07564D8FDD8D: 1791636759648L + 540DD4E3E568DBF2: 1791636759654L + 7C4E4793DA887DE4: 1791636759654L + 3C78926E5D301BA0: 1791636759654L + 46C5C0922839BDCA: 1791636795600L + 3C8D9278B81BB37A: 1791636795600L + 792B4AC07BA2D148: 1791636836102L + 0ACE573560A19309: 1791636904495L + 1B5CF4B0B6D3F6F9: 1791636904495L + 6BD970777103D55D: 1791638807193L + 7C2ED69E07988DF4: 1791638807193L + 00A17728A387B426: 1791659036947L + 05B1A9B5E710DC24: 1791659036947L + 78DA1FE8F54FDAE8: 1791660490593L + 0D01524F249E25D7: 1791660490593L + 7D279FC39DA5C630: 1791662770250L + 41A5F4BEC3477FEF: 1791662770250L + 346860B8EBC4C28C: 1791663602993L + 4DB11C6F180743E9: 1791663602993L + 0FD62827710F0AC6: 1791663602999L + 58D16A3D3A480A37: 1791663602999L + 162CE44400A63575: 1791663609308L + 28277BA9F319240D: 1791663609308L + 2068F53F091BFD5A: 1791663609313L + 1AE233B4CCFE99F7: 1791663609313L + 4C0098C62CDF1FF9: 1791663609313L + 08DDE018A804BFE7: 1791663609313L + 302E9BC711779A4A: 1791663609313L + 091B97D20403D516: 1791663609313L + 103895C9792183DB: 1791663609313L + 4558919345C3BE5D: 1791663609313L + 488DBE69595F38F8: 1791663612949L + 5A081977DB9407EA: 1791663612949L + 119BED334E331C25: 1791663612950L + 7934873E784C4B3C: 1791663612950L + 7EA092725A784CA7: 1791663612950L + 17C665008B4C0CD7: 1791663612950L + 6CB34AE622DF9CC2: 1791663612950L + 4B6C5B2099B18AB7: 1791663612950L + 0095422BC87AA135: 1791663612950L + 67E7A9A65B14C933: 1791663612950L + 1960B985B54B9FBD: 1791663620146L + 4869C413646CC4CC: 1791663620146L + 493D04D954E4FBA0: 1791663620150L + 4448DE84ABA737BE: 1791663620150L + 6877D91D6603E4FD: 1791663620152L + 62827AA1AD29A222: 1791663620152L + 6C4B8A2662030CB0: 1791663620159L + 5C47935A3B2877FF: 1791663620159L + 6EB023B3AEF61518: 1791663684600L + 7E2317305BD5F842: 1791663705649L + 7AF827D2D101D343: 1791663705649L + 29448D08C8777425: 1791663705649L + 474E1537C5CA7FB7: 1791663708747L + 17A68C4E9E9EA2B7: 1791663708747L + 224CE21E56703F6E: 1791663708752L + 347729D52A71A889: 1791663708752L + 40F634AC38F42B88: 1791663708771L + 78082DDA22FC7B9B: 1791663708771L + 211A2C594472A9C4: 1791663892593L + 2CCAA7C0FEDEB573: 1791663892593L + 50624A39AD15A8A6: 1791663892599L + 7778937DF377C1B4: 1791663892599L + 59DE118DD6597A4E: 1791663892606L + 48DC9E8E9D21A2FA: 1791663892606L + 360B150190A5C894: 1791664284594L + 5BCD83C6A9F1AE62: 1791664284594L + 1FAFDB20F504688E: 1791664284600L + 79FB7E7194336B30: 1791664284600L + 333F55C8B6309F73: 1791664284607L + 02F2DF9D9DA7833A: 1791664284607L + 686B25F2E9D8CC97: 1791664296005L + 1C9D63BCE843C437: 1791664296005L + 4F664D5E6C5FDE3B: 1791664425755L + 21D8B88155648EA6: 1791664425755L + 64D374ADCCF2E14C: 1791664505500L + 3298C53B3E5FAF14: 1791669411548L + 56B11606F5F4DAB2: 1791669411548L + 011B8BD02D80DDD4: 1791669411548L + 78D1C97A542133D8: 1791670454199L + 1311C09169CA7055: 1791670454199L + 6502D0124314BF25: 1791670454200L + 615C3E012B2DECD3: 1791670454200L + 3CFD27F273B97FDD: 1791670499996L + 785BFA29F6285581: 1791670499996L + 432FCFECDAA542F1: 1791670500004L + 79AF63E77AD70A93: 1791670500004L + 3D62C020980A2DEC: 1791670500004L + 0F3A0A4DC18E5E55: 1791670500004L + 4B83A0D1C2C3C226: 1791671535896L + 4FA6BEA4E646B742: 1791671535896L + 3824247686C3D875: 1791672156797L + 0C9F9196CA49FF8D: 1791672156797L + 35ABB0DEE70DF7FD: 1791672389967L + 60907E798B65407F: 1791672389967L + 6EE68EEFE388CF8A: 1791672897743L + 477AAD8E6A0BB944: 1791672897743L + 0E81CBCD6B1D1895: 1791672932651L + 3839A67493B2E5C3: 1791672932651L + 1102B27CBF564127: 1791672932651L + 4D3379D38237F830: 1791672942913L + 61C6CFA5DB18E2DC: 1791672942913L + 318E7C9C3687A8A4: 1791672942914L + 28FC7C19E2A3E1B9: 1791672946045L + 221C9EDA651144BA: 1791672946045L + 2ACCBA73F15785AE: 1791672960749L + 537CABE8F876E65A: 1791672964093L + 28408BFAA54E4FC6: 1791672964093L + 5EE73B206A8A3021: 1791672964095L + 50B127137AA9E660: 1791672964095L + 4E5238F00CEED8B2: 1791672968640L + 1869893A4F8E9E9C: 1791672968640L + 094D28B7A0170039: 1791672990249L + 620BF9687558DE44: 1791672990249L + 4D5FA238E199BECE: 1791672990251L + 41B99DBD03EB74FF: 1791672990251L + 34424E87D679D3AA: 1791672990251L + 32384DE59539557D: 1791672990251L + 1036837C9AD3F301: 1791673103051L + 7F822A21A9F71A4F: 1791673103051L + 1C4E971513EF2B15: 1791673103053L + 5FE507DAEE770507: 1791673103053L + 0D18BBB04693885A: 1791755031271L + 39FE4ED3B2A83A42: 1791755031271L + 3456E0C530C0038E: 1791755405350L + 753B3EF7CC94A6DD: 1791755405350L + 19C973A3F42D631C: 1791755405350L + 122E6E7CABCB17E7: 1791756922445L + 294D97F3514A382B: 1791756922445L + 6BD95DE2DB508F9A: 1791756922448L + 22151D56616559CB: 1791756922448L + 7D81F2381516204D: 1791756922453L + 7E538C87D777B809: 1791756922453L + 4A7AEBEB87CEC274: 1791756925649L + 7DABA35BECFBE1E2: 1791756925649L + 5EF54E0EB14024D1: 1791756959196L + 18980EBAE4ED777A: 1791756959196L + 381CBCFB71018C25: 1791756959196L + 7992C63F063BE908: 1791756959196L + 3FF97A4B5029C0E7: 1791756959196L + 664ADBD5CACCEC65: 1791756959196L + 117AD4ACD2CB198A: 1791756995756L + 31AD9826FB036C98: 1791756995756L + 2065DC263D762764: 1791757049794L + 792990DC06DB98F9: 1791757049794L + 74B352457BF1541B: 1791757049808L + 268319B4D86AF1FA: 1791757049808L + 24042326B4632DD4: 1791757049815L + 743C664C4E8EE776: 1791757049815L + 499AF83C86984D26: 1791757049817L + 6D88C19F47D0D469: 1791757049825L + 1BAC364D2AF426D3: 1791757049825L + 004D61425172324F: 1791757278997L + 20BC17BCC5E270A6: 1791757278997L + 0045D90350F06441: 1791757333198L + 12449D23295CF9ED: 1791757333198L + 3FBFC3BAACA69389: 1791757417143L + 2DDA04088C8220D2: 1791757417143L + 76A7B9E9F755A223: 1791757458396L + 7E4367252A39BE6C: 1791757458396L + 1ACDF9B8AED8F168: 1791757458399L + 3F5B5D6B788E0A73: 1791757458399L + 4795ADB0596D26DA: 1791757458402L + 1D91E37BFD8CC911: 1791757458402L + 26DF1427AC3966DE: 1791757458409L + 76BCC3C288514737: 1791757458409L + 21FC6D010E5D5360: 1791757458420L + 7E92ED270C67FDE5: 1791757458420L + 6467C991A6D45E36: 1791757458432L + 31FD9EA513E0D010: 1791757458432L + 4FBF146C9301DEC7: 1791757539012L + 3AAB71E9BDFD4C1E: 1791757539012L + 452DC5EB02D592B8: 1791757539015L + 4C45690D5EBCAB90: 1791757539015L + 577EC349CB79E93E: 1791757539023L + 371A34B297C8A8EF: 1791757539023L + } + completed: { + 3BC0A50886A3222B: 1791567331465L + 33218EFDD498E9DB: 1791567331465L + 3009018DEC1EC952: 1791567331475L + 08BA6518F2BBDD85: 1791567331475L + 196312DF0D2E2CB6: 1791567331876L + 0A3853BD4F62B78E: 1791567331876L + 13F86953093670F6: 1791567333198L + 637EF6D9DFC59EE8: 1791567333198L + 689F32883C4E9502: 1791567361115L + 0496E8E786262464: 1791567361115L + 0F998B5ABA5A3DE8: 1791567361115L + 276930FE3A5CD916: 1791567361115L + 43BC5EBC129DCE92: 1791567361120L + 7D359283625C1869: 1791567361120L + 064B1841F9288CC3: 1791567472997L + 0052E5592A6BBEC1: 1791567472997L + 6F65216B13E9FFA9: 1791567472997L + 0EBE231AB868C868: 1791567472997L + 78D7794707E4BD83: 1791567524364L + 1827DEEA2DF1B144: 1791567524364L + 6609E5E7EDE6F92B: 1791567529798L + 5577F49A93ADB19C: 1791567529798L + 61FFB3326113FE18: 1791567543137L + 4EA2DB12E8F0068F: 1791567543137L + 61BE7882EE0E6FAF: 1791567543137L + 7213278CEE1B25A3: 1791567543137L + 285A53DB9D54595D: 1791567543138L + 5CF320E9C4C1B1E1: 1791567543138L + 7E49C985A0433B20: 1791567552014L + 712552C459B5BD54: 1791567552014L + 746C44D800F4E4FB: 1791567722447L + 282CE3A088AE5CFB: 1791567722447L + 64FADC78A8AD3FE4: 1791567826754L + 54735C5FCD2077B3: 1791567826754L + 44688FC2E34FF09F: 1791567857393L + 3F185554793CCE54: 1791567857393L + 46803BBAC2069773: 1791567857393L + 4AA065E761BB6A42: 1791567857393L + 7975C7145572C438: 1791567857394L + 5CA7453233441165: 1791567857394L + 2E885362FFDF770D: 1791567857396L + 2EE4E88FC814AFA4: 1791567857396L + 292C044797A58CC1: 1791567857396L + 5151CDD8FCDE7A07: 1791567857396L + 6F5F1C525CF08F96: 1791567857403L + 279DA565E9ADF7B0: 1791567857403L + 0460DDEEFDEB0AAF: 1791567857412L + 3ABECD22D09DEEFB: 1791567857412L + 3166CA00032C18F7: 1791567857421L + 3CC50EAEEE1E8A56: 1791567857421L + 09955CF85F579E0F: 1791567860491L + 7D14A6A6ABBA9960: 1791567860491L + 1972B36F72808D55: 1791567860492L + 2BECEC3EFA8F6DA3: 1791567860492L + 57F47BA495B27F83: 1791567860494L + 02C822663913DFCF: 1791567860494L + 2E6D11A5F5626A6C: 1791567880849L + 4CDD283558108D8B: 1791567880849L + 173044E1C1134A80: 1791567980699L + 4EEF358CAC170CC4: 1791568137597L + 15540271BF17C2E4: 1791568137597L + 3F68BA498E877C8C: 1791568137602L + 59E3B2A8BA8579C0: 1791568137602L + 27BABC4910038807: 1791568144545L + 0EB6ED88895366D0: 1791568144545L + 1ADA8EDA9196471C: 1791568144546L + 4B7D387CEFF9667E: 1791568144546L + 57A7A5C79389A96A: 1791568144550L + 2811A593A759354C: 1791568144550L + 45996B848792379E: 1791568268855L + 1476F390A6789572: 1791568268855L + 14E43D7B404B8AD4: 1791568281290L + 092BB595F75D5536: 1791568281290L + 6B772FA03562537D: 1791568281290L + 692183ACBB2AABF7: 1791568281290L + 6E3ACC74E0987981: 1791568284093L + 41C0948CD9D50322: 1791568284093L + 6AA00AA3ADBA5F34: 1791568303098L + 7377C657CED885AC: 1791568303098L + 667004CD0469493D: 1791568303103L + 1CC4F8570A7A99EB: 1791568303103L + 08E484DD3E751697: 1791568321896L + 53B3983C04A5CB1C: 1791568321896L + 69F11A94D44AB5CF: 1791568333463L + 08082713F1556E3A: 1791568333463L + 25F49B7B8EC6F48B: 1791568574496L + 3917DECAEF56A06A: 1791568574496L + 1C1B919636DDC925: 1791568730542L + 2B9AF0D6ED8BA628: 1791568730542L + 41A47F38D96A9747: 1791568733644L + 70ED849A2EB44DEE: 1791568733644L + 5A94A2664BFDD7B9: 1791568752344L + 51CC9A8E36385400: 1791568752344L + 7EDA9BD14A177D8D: 1791568752347L + 0682DC1F2417DAEB: 1791568752347L + 073D1C45B3018BBA: 1791568752351L + 134608BB2B04D96E: 1791568752351L + 408C5263C4664195: 1791568752354L + 2893F483C10293E6: 1791568752354L + 3708A4780ACEB34E: 1791568901201L + 52BB142F044075B4: 1791568901201L + 0C93D7A607AB8B83: 1791568907350L + 103C42C743E2A2DB: 1791568907350L + 10A16F89D4AD238D: 1791568910458L + 7658C1C663394E85: 1791568910458L + 5B00676D79306EA2: 1791568913421L + 5AC1BE754210429E: 1791568913421L + 4F13A02FB0055A62: 1791568913421L + 28FDB2D13D0DDE8E: 1791568956996L + 0138936FB4DD8A88: 1791568956996L + 5FA5A4469CF8A9EF: 1791568956997L + 35C46E6E27C9CB1A: 1791568956997L + 0FE30548F25FEDB1: 1791568956997L + 720CE629EA4E37D0: 1791568956997L + 7CF13A4941F608E5: 1791568956998L + 76C92D826994D92B: 1791568956998L + 06FFBB12CFA87815: 1791568956999L + 1A66B7FFB4E2163E: 1791568956999L + 70B6C9409AE69284: 1791568957001L + 58E3131A3EA2661E: 1791568957001L + 6ECB27052FCB874E: 1791569129342L + 759571AAA3323C3C: 1791569129342L + 7D38BC3DB3406F51: 1791569423951L + 539C6F64C251543B: 1791569423951L + 24C9BA6DC32CFAD9: 1791569423951L + 39AD121BCB73453D: 1791569423951L + 752DD0BF36B70D88: 1791569423954L + 6627658A032CDC95: 1791569423954L + 76C63670624C3C56: 1791569423956L + 0D3DC52244DC7ADD: 1791569423956L + 703D7D142BD7CEC9: 1791569423965L + 47750F3CB020104C: 1791569423965L + 38BA6B3FF1EC9185: 1791569481248L + 698A959C9E449592: 1791569481250L + 03005DEF00AB48F3: 1791569481250L + 2E116AC5F25650BE: 1791569481250L + 051E0C85E7B71CE0: 1791569481250L + 4DF683461225690F: 1791569481253L + 7C3D8A97769BBBE3: 1791569481253L + 211FD55DEB635A98: 1791569481255L + 14ACE78261F4EE35: 1791569481255L + 4555EA054687BD5F: 1791569481261L + 7242F591DA474B37: 1791569481261L + 58B125BD4876054C: 1791569481267L + 77329B944064E893: 1791569481267L + 175D943160C10646: 1791569481275L + 297D80507C3A56E1: 1791569481275L + 1F44E7BEA2D191E5: 1791569481282L + 5CCF92238B23EDC9: 1791569481282L + 7CB9C080F0597A4D: 1791569481292L + 0542FE108089A0A2: 1791569481292L + 195338A3977C682E: 1791569546443L + 392F34FD4FCC794A: 1791569546443L + 5936B8B8EDE0AE55: 1791569546443L + 0596F6C70758EEC0: 1791569546443L + 48A30B08A090BD75: 1791569546449L + 6CD7D99AA102A5C3: 1791569546449L + 6F62B5510FA881CD: 1791569546454L + 52967F83338A3AC3: 1791569546454L + 23631A4A743D4F0C: 1791569565605L + 2E92D23F149C142D: 1791569565605L + 0C282319C6EAC76E: 1791569583902L + 217DB12EA32E1724: 1791569583902L + 372B5BF416CA83A4: 1791569587297L + 1DCF1EB2F1D2F966: 1791569587297L + 16BC496604F56E3B: 1791569591148L + 3685B52446DEC809: 1791569591148L + 5E8A262AF1E9DB0B: 1791569635303L + 1659A59303C15CC9: 1791569635303L + 042B93D43308564F: 1791569663693L + 2F56ECC8DBC90C01: 1791569670742L + 55D98C63A1A01713: 1791569674003L + 034DB3C64F24C7B7: 1791569725496L + 27DF6EE4B85F1792: 1791569725496L + 6501A410F1543C70: 1791569725500L + 0C99990D814C2823: 1791569725500L + 73350AD668200E99: 1791570208549L + 4B2621F8D8FA7CA7: 1791570208549L + 76F3B5C3CB49DDBD: 1791570565592L + 377A41499D8218C9: 1791570565592L + 13D240DA839AE0F9: 1791570565594L + 73F9C130378195B9: 1791570573091L + 4DE1158931F84F22: 1791570573091L + 426BED88B406A44B: 1791570573094L + 1FC7ABEAB5018E79: 1791570573094L + 26FAC6C93C935044: 1791570573098L + 37EBB8E0D6E5F821: 1791570573098L + 6D88F09899BB4808: 1791570573105L + 42D173B9FF8D16E4: 1791570573105L + 4AB0DD227471FDBF: 1791570573105L + 1F2490B2B9A2B5F9: 1791570573105L + 76EA017B12E8F01B: 1791570573115L + 4D6630377238B530: 1791570573115L + 1F81EA5E45424308: 1791570573124L + 128DC243A78982C3: 1791570573124L + 72E1C19156633391: 1791570583951L + 2CE553B282962E01: 1791570583951L + 4D389CB7B26F5EB5: 1791570583958L + 2B5E14A47062F83C: 1791570583958L + 1934789006427AE9: 1791570587051L + 022C38096F79EF07: 1791570587051L + 1FFF00B5D90C3905: 1791570587055L + 7F8F61CB2A5279F1: 1791570587055L + 6204AF3CD7D2CC37: 1791570587067L + 62C60FF24B2F7290: 1791570587067L + 6A352964478AE499: 1791570741599L + 2A1D9DE362310D7E: 1791572031743L + 576227D79B7D383D: 1791573106163L + 67D884FA6DD22065: 1791574291728L + 3BF5B8DE7A1CC4D7: 1791574291728L + 340CDF4357E22A43: 1791574305814L + 14C0CA9C756B3ABD: 1791574305814L + 4E7FDA9DE2071EE5: 1791574305820L + 300F2E45D185A9A1: 1791574305820L + 2E9C035EEE7E5C34: 1791575236118L + 63282E8604721F13: 1791575236118L + 0197CC61699DBE7D: 1791575858321L + 3AA836E8EDB52097: 1791575858321L + 2171DC0B13C4BD43: 1791629889050L + 3768935221C60A6D: 1791629889050L + 76F675A898EAB4E8: 1791630047697L + 0A2949304DBB3D75: 1791630047697L + 7033C2D58700433D: 1791630047705L + 034A9FBCA6D79BB6: 1791630115745L + 616C07AFBAFA7AE4: 1791630115745L + 43021923E220CF68: 1791630115748L + 323A434E1AF54408: 1791630115748L + 5E4BC0F59C90433A: 1791630663594L + 6205B6B93B306A12: 1791630663594L + 7F990FBC1A691020: 1791630669939L + 34909E6C5CB47A43: 1791630669939L + 230EEE4671B46D0C: 1791630795401L + 43A5CD740F24A3ED: 1791630795401L + 41FF171C7D97B574: 1791630880139L + 3497C2E05DF5A87C: 1791630880139L + 6B2027DA7AA6FF34: 1791631020248L + 3C45AF8C3DC5A45E: 1791631020248L + 33B4B2E11DEF74E2: 1791631270620L + 3AE0198177A8E91B: 1791631270620L + 0CD04A00529FE1C9: 1791631303652L + 200CE35BF3C04F6F: 1791631303652L + 51A617F8FBBFA712: 1791631665303L + 6E0E13806F388D7E: 1791631665303L + 1D136D138EF82DA9: 1791631814616L + 4A10EADC3914EA38: 1791631827602L + 49F46466DB8EDD61: 1791632552642L + 58514FDE153FD971: 1791632552642L + 0B11EAB8D76529E0: 1791633681226L + 0A7D66CA65D3EFD6: 1791633681226L + 17B9A643AFD7EB39: 1791633765646L + 76406EFFF8CBA6B4: 1791633765646L + 17E6AEEE9C081DB1: 1791633765648L + 1682CED78D9D4BA7: 1791633765648L + 290255C654A57980: 1791633765650L + 6B671CB07D046BEC: 1791633765650L + 6601450C63FEEE61: 1791633765652L + 206791A6A8DA0F16: 1791633765652L + 5E41363F9AE243F3: 1791633765658L + 09C547CC92623525: 1791633765658L + 5ED368C926E5F32C: 1791633864167L + 6D7DD99A02A8F451: 1791633864167L + 0F16498769DFB3B0: 1791636519659L + 5D2B9139C49F5ABA: 1791636519659L + 104E1A4BF3520D67: 1791636744239L + 48971CE964CF037F: 1791636744239L + 01A90E2541C91DB2: 1791636744243L + 0B19D86DE8DCF565: 1791636744243L + 3331EBE1BB4BF64D: 1791636759642L + 1BB0FB49B96E22E2: 1791636759642L + 39CEB20C5018F460: 1791636759648L + 6D6E07564D8FDD8D: 1791636759648L + 540DD4E3E568DBF2: 1791636759654L + 7C4E4793DA887DE4: 1791636759654L + 6BD970777103D55D: 1791638807194L + 53A6720343E57302: 1791638807194L + 0C82CE3AFBD48C1E: 1791638807194L + 70367F545A9CB4D0: 1791638807194L + 7C2ED69E07988DF4: 1791638807194L + 3ED8365BAD406CC5: 1791638807194L + 5D5563142917495A: 1791638807202L + 4B274207A9646FE9: 1791638807202L + 2094D1CE4BA66091: 1791638807217L + 106884349C9FF3BA: 1791638807217L + 4E28970476CABACD: 1791638807217L + 1E7A57902EA62332: 1791638807217L + 5457CD8C1ABA0B9E: 1791639256595L + 2B05A29C62676EB2: 1791639256595L + 78DA1FE8F54FDAE8: 1791660490593L + 0D01524F249E25D7: 1791660490593L + 346860B8EBC4C28C: 1791663602993L + 4DB11C6F180743E9: 1791663602993L + 5E51F5B17F77573E: 1791663609309L + 162CE44400A63575: 1791663609309L + 28277BA9F319240D: 1791663609309L + 0498A578D0EC3254: 1791663609309L + 4B35C01F5D0AAC58: 1791663609316L + 6E9280AEB3B1D973: 1791663609316L + 72CE838EFDDE6479: 1791663609322L + 166971866A9234C7: 1791663609322L + 3FD63A725A23D407: 1791663609322L + 4935209D870E25F2: 1791663609322L + 185D97FCA1C83569: 1791663609328L + 3B51C96356777FE6: 1791663609334L + 19341B01E048ACAB: 1791663609334L + 676E0375B6D394DE: 1791663609334L + 1960B985B54B9FBD: 1791663620147L + 4869C413646CC4CC: 1791663620147L + 6877D91D6603E4FD: 1791663620152L + 62827AA1AD29A222: 1791663620152L + 6C4B8A2662030CB0: 1791663620160L + 5C47935A3B2877FF: 1791663620160L + 6EB023B3AEF61518: 1791663684601L + 7E2317305BD5F842: 1791663705649L + 29448D08C8777425: 1791663705649L + 474E1537C5CA7FB7: 1791663708748L + 224CE21E56703F6E: 1791663708752L + 347729D52A71A889: 1791663708752L + 211A2C594472A9C4: 1791663892593L + 2CCAA7C0FEDEB573: 1791663892593L + 50624A39AD15A8A6: 1791663892599L + 7778937DF377C1B4: 1791663892599L + 59DE118DD6597A4E: 1791663892606L + 48DC9E8E9D21A2FA: 1791663892606L + 5BCD83C6A9F1AE62: 1791664284594L + 1FAFDB20F504688E: 1791664284600L + 79FB7E7194336B30: 1791664284600L + 333F55C8B6309F73: 1791664284607L + 02F2DF9D9DA7833A: 1791664284607L + 488DBE69595F38F8: 1791664425755L + 5A081977DB9407EA: 1791664425755L + 4F664D5E6C5FDE3B: 1791664425755L + 21D8B88155648EA6: 1791664425755L + 1AE233B4CCFE99F7: 1791664425761L + 08DDE018A804BFE7: 1791664425761L + 6DCF32EB40B8E9C6: 1791664425762L + 47F38E606AD3FF53: 1791664425762L + 0ACE573560A19309: 1791664425773L + 1B5CF4B0B6D3F6F9: 1791664425773L + 46C5C0922839BDCA: 1791664425779L + 3C8D9278B81BB37A: 1791664425779L + 4558919345C3BE5D: 1791664425785L + 119BED334E331C25: 1791664425792L + 4B6C5B2099B18AB7: 1791664425792L + 7FCF6E8726BCC769: 1791664425799L + 6502D0124314BF25: 1791670454200L + 615C3E012B2DECD3: 1791670454200L + 3CFD27F273B97FDD: 1791670499996L + 785BFA29F6285581: 1791670499996L + 3824247686C3D875: 1791672156797L + 0C9F9196CA49FF8D: 1791672156797L + 35ABB0DEE70DF7FD: 1791672389967L + 60907E798B65407F: 1791672389967L + 6EE68EEFE388CF8A: 1791672897744L + 477AAD8E6A0BB944: 1791672897744L + 4D3379D38237F830: 1791672942913L + 61C6CFA5DB18E2DC: 1791672942913L + 318E7C9C3687A8A4: 1791672942914L + 28FC7C19E2A3E1B9: 1791672946045L + 221C9EDA651144BA: 1791672946045L + 2ACCBA73F15785AE: 1791672960750L + 537CABE8F876E65A: 1791672964094L + 5EE73B206A8A3021: 1791672964095L + 4E5238F00CEED8B2: 1791672968640L + 1869893A4F8E9E9C: 1791672968640L + 0EA4B08DAAFA4287: 1791756882808L + 17F3AC15ADCFB175: 1791756882808L + 122E6E7CABCB17E7: 1791756922445L + 294D97F3514A382B: 1791756922445L + 6BD95DE2DB508F9A: 1791756922448L + 22151D56616559CB: 1791756922448L + 4A7AEBEB87CEC274: 1791756925649L + 7DABA35BECFBE1E2: 1791756925649L + 117AD4ACD2CB198A: 1791756995757L + 31AD9826FB036C98: 1791756995757L + 2065DC263D762764: 1791757049794L + 792990DC06DB98F9: 1791757049794L + 74B352457BF1541B: 1791757049808L + 268319B4D86AF1FA: 1791757049808L + 499AF83C86984D26: 1791757049817L + 20BC17BCC5E270A6: 1791757278997L + 3FBFC3BAACA69389: 1791757417143L + 2DDA04088C8220D2: 1791757417143L + 76A7B9E9F755A223: 1791757458396L + 7E4367252A39BE6C: 1791757458396L + 1ACDF9B8AED8F168: 1791757458399L + 3F5B5D6B788E0A73: 1791757458399L + 4795ADB0596D26DA: 1791757458402L + 1D91E37BFD8CC911: 1791757458402L + 26DF1427AC3966DE: 1791757458409L + 76BCC3C288514737: 1791757458409L + 6D88C19F47D0D469: 1791757458420L + 21FC6D010E5D5360: 1791757458420L + 1BAC364D2AF426D3: 1791757458420L + 7E92ED270C67FDE5: 1791757458420L + 0FD62827710F0AC6: 1791757458421L + 6467C991A6D45E36: 1791757458432L + 31FD9EA513E0D010: 1791757458432L + 58D16A3D3A480A37: 1791757458461L + 4FBF146C9301DEC7: 1791757539013L + 3AAB71E9BDFD4C1E: 1791757539013L + 452DC5EB02D592B8: 1791757539016L + 4C45690D5EBCAB90: 1791757539016L + 1DF996B4E387DB41: 1791757539023L + 2AED15E3AC0346FE: 1791757539023L + 577EC349CB79E93E: 1791757539023L + 371A34B297C8A8EF: 1791757539023L + 3916C13A250BB5E9: 1791757539033L + } + repeatable: { } + completion_count: { } + claimed_rewards: { + "c25d9ff0dba348f0b414fad2b63473a9:4956CB79E44FB523": 1791567616697L + "c25d9ff0dba348f0b414fad2b63473a9:4A56DF6E0204666C": 1791568902542L + "c25d9ff0dba348f0b414fad2b63473a9:1B044C35466AFAEF": 1791568908314L + "c25d9ff0dba348f0b414fad2b63473a9:5007C73E170C4FF6": 1791568911464L + "c25d9ff0dba348f0b414fad2b63473a9:101D2FD24AB845AC": 1791568913812L + "c25d9ff0dba348f0b414fad2b63473a9:55CF29758364D4B2": 1791568953951L + "c25d9ff0dba348f0b414fad2b63473a9:15A6447533AF449F": 1791568953951L + "c25d9ff0dba348f0b414fad2b63473a9:67EAB551221214EB": 1791568953953L + "c25d9ff0dba348f0b414fad2b63473a9:4067337B7C0CBED9": 1791568953954L + "c25d9ff0dba348f0b414fad2b63473a9:5B62D0159D7C3B10": 1791568953954L + "c25d9ff0dba348f0b414fad2b63473a9:191F9CEDFD4FB19A": 1791568953955L + "c25d9ff0dba348f0b414fad2b63473a9:376D6593DD76419C": 1791568953955L + "c25d9ff0dba348f0b414fad2b63473a9:61FE13BC7E94766D": 1791568953955L + "c25d9ff0dba348f0b414fad2b63473a9:08DE7FBC4C5D9B73": 1791568953956L + "c25d9ff0dba348f0b414fad2b63473a9:55E472D4A2508908": 1791568953956L + "c25d9ff0dba348f0b414fad2b63473a9:291CEDE02889DB80": 1791568953956L + "c25d9ff0dba348f0b414fad2b63473a9:358C852C894DD059": 1791568953957L + "c25d9ff0dba348f0b414fad2b63473a9:66F8E6A333BC9BF6": 1791568953957L + "c25d9ff0dba348f0b414fad2b63473a9:7A8E604C248E9E0B": 1791568953958L + "c25d9ff0dba348f0b414fad2b63473a9:64451D3624C2085E": 1791568953958L + "c25d9ff0dba348f0b414fad2b63473a9:5C8109AE8C1BAD29": 1791568953958L + "c25d9ff0dba348f0b414fad2b63473a9:09CAD4B958A24BEB": 1791568953959L + "c25d9ff0dba348f0b414fad2b63473a9:01B466208AD081D8": 1791568953959L + "c25d9ff0dba348f0b414fad2b63473a9:4CF2F5980ADD8DBC": 1791568953959L + "c25d9ff0dba348f0b414fad2b63473a9:2AD8F6EAE8EF2A73": 1791568953960L + "c25d9ff0dba348f0b414fad2b63473a9:0BC721E7CF41B60A": 1791568953960L + "c25d9ff0dba348f0b414fad2b63473a9:4311F03D24305238": 1791568953960L + "c25d9ff0dba348f0b414fad2b63473a9:6019B82199039FAD": 1791568953960L + "c25d9ff0dba348f0b414fad2b63473a9:7E8BD57F76515A08": 1791568953961L + "c25d9ff0dba348f0b414fad2b63473a9:2BA08F25D16B9421": 1791568953961L + "c25d9ff0dba348f0b414fad2b63473a9:49795AB869D53FBA": 1791568953961L + "c25d9ff0dba348f0b414fad2b63473a9:14E486767CD3D466": 1791568953961L + "c25d9ff0dba348f0b414fad2b63473a9:0920B905A30B389B": 1791568953961L + "c25d9ff0dba348f0b414fad2b63473a9:16E6A3E5A0BF91BA": 1791568953961L + "c25d9ff0dba348f0b414fad2b63473a9:49B86F222F7D3761": 1791568953962L + "c25d9ff0dba348f0b414fad2b63473a9:570FFAC4E65BBF46": 1791568953962L + "c25d9ff0dba348f0b414fad2b63473a9:2EFEA345691F570D": 1791568953962L + "c25d9ff0dba348f0b414fad2b63473a9:04731B877AA067A3": 1791568953962L + "c25d9ff0dba348f0b414fad2b63473a9:123E45DFFDB5E287": 1791568953962L + "c25d9ff0dba348f0b414fad2b63473a9:6EF5E55A7F55CA38": 1791568953962L + "c25d9ff0dba348f0b414fad2b63473a9:769A50AFA27134F2": 1791568953963L + "c25d9ff0dba348f0b414fad2b63473a9:2B066A28F827460C": 1791568953963L + "c25d9ff0dba348f0b414fad2b63473a9:75C1B7A9C8487826": 1791568953963L + "c25d9ff0dba348f0b414fad2b63473a9:03845A435E464696": 1791568953963L + "c25d9ff0dba348f0b414fad2b63473a9:05E42C3F0F26061A": 1791568953964L + "c25d9ff0dba348f0b414fad2b63473a9:3A060AC1249E4D3A": 1791568953964L + "c25d9ff0dba348f0b414fad2b63473a9:3CC6453EEA185696": 1791568953964L + "c25d9ff0dba348f0b414fad2b63473a9:7D046DD10C7BF023": 1791568953965L + "c25d9ff0dba348f0b414fad2b63473a9:187352DA383B7DF6": 1791568953966L + "c25d9ff0dba348f0b414fad2b63473a9:0CDD4192853BADC6": 1791568953966L + "c25d9ff0dba348f0b414fad2b63473a9:462DEE14F79B8CCF": 1791568953966L + "c25d9ff0dba348f0b414fad2b63473a9:15DDBBD1EDE8A843": 1791568953966L + "c25d9ff0dba348f0b414fad2b63473a9:3E0C9AD83C190CC8": 1791568953967L + "c25d9ff0dba348f0b414fad2b63473a9:15F2DA96D08F45EE": 1791568953967L + "c25d9ff0dba348f0b414fad2b63473a9:2D116FDCEA070CBA": 1791568953968L + "c25d9ff0dba348f0b414fad2b63473a9:3E9F25BFE28D3AE6": 1791568953968L + "c25d9ff0dba348f0b414fad2b63473a9:30318BC56B093D00": 1791568953968L + "c25d9ff0dba348f0b414fad2b63473a9:64CD59A0372C3B5C": 1791568953969L + "c25d9ff0dba348f0b414fad2b63473a9:3F9A3E2F866320C0": 1791568953970L + "c25d9ff0dba348f0b414fad2b63473a9:08E663CBC638D862": 1791568953970L + "c25d9ff0dba348f0b414fad2b63473a9:5E49AB96CBF9431C": 1791568953971L + "c25d9ff0dba348f0b414fad2b63473a9:2C2CD4E098E4EA6D": 1791568953971L + "c25d9ff0dba348f0b414fad2b63473a9:213FD686870FAC06": 1791568953972L + "c25d9ff0dba348f0b414fad2b63473a9:5B2B212F50BC74F6": 1791568953972L + "c25d9ff0dba348f0b414fad2b63473a9:041339FBDC3E9181": 1791568953972L + "c25d9ff0dba348f0b414fad2b63473a9:6A4886FA200C2410": 1791568980158L + "c25d9ff0dba348f0b414fad2b63473a9:3E3209519A6FBB68": 1791568980162L + "c25d9ff0dba348f0b414fad2b63473a9:5397DC1F400C915F": 1791568980163L + "c25d9ff0dba348f0b414fad2b63473a9:0A2ACA84C0CA106B": 1791568980163L + "c25d9ff0dba348f0b414fad2b63473a9:262AC9191FD415A3": 1791568980164L + "c25d9ff0dba348f0b414fad2b63473a9:727954DC34582543": 1791568980165L + "c25d9ff0dba348f0b414fad2b63473a9:221B83F8C189F1A1": 1791568988563L + "c25d9ff0dba348f0b414fad2b63473a9:5834AF3E0BD94AD7": 1791568990602L + "c25d9ff0dba348f0b414fad2b63473a9:2A0643B0A0581849": 1791568991249L + "c25d9ff0dba348f0b414fad2b63473a9:410DC661CC5D20F4": 1791568991619L + "c25d9ff0dba348f0b414fad2b63473a9:5FECBB62586961F5": 1791568992004L + "c25d9ff0dba348f0b414fad2b63473a9:7FC4EB84CCBE32D9": 1791568992395L + "c25d9ff0dba348f0b414fad2b63473a9:03B9F9A7171A4881": 1791568992622L + "c25d9ff0dba348f0b414fad2b63473a9:0D935234F9A13C00": 1791569140244L + "c25d9ff0dba348f0b414fad2b63473a9:5EAECB0D188553DE": 1791569141292L + "c25d9ff0dba348f0b414fad2b63473a9:2919BD4FA223CAD8": 1791569543392L + "c25d9ff0dba348f0b414fad2b63473a9:47B8B05732B2F09F": 1791569543392L + "c25d9ff0dba348f0b414fad2b63473a9:77D1205EEAD47367": 1791569543392L + "c25d9ff0dba348f0b414fad2b63473a9:36A9FD7873103E63": 1791569543392L + "c25d9ff0dba348f0b414fad2b63473a9:6A795EF7CBA25DF7": 1791569543394L + "c25d9ff0dba348f0b414fad2b63473a9:1409306D81BE3A18": 1791569543395L + "c25d9ff0dba348f0b414fad2b63473a9:54C82B7BE56A13B4": 1791569543395L + "c25d9ff0dba348f0b414fad2b63473a9:371FBF809ACA40B9": 1791569543395L + "c25d9ff0dba348f0b414fad2b63473a9:2C2A7012D1F87F52": 1791569543395L + "c25d9ff0dba348f0b414fad2b63473a9:457FAB2DDE9FBC18": 1791569543396L + "c25d9ff0dba348f0b414fad2b63473a9:56D78BABD9B9E1ED": 1791569543396L + "c25d9ff0dba348f0b414fad2b63473a9:07B7AFADD4C305C1": 1791569543396L + "c25d9ff0dba348f0b414fad2b63473a9:289C97B3D45F7CEA": 1791569543397L + "c25d9ff0dba348f0b414fad2b63473a9:37C90FAC7748AF50": 1791569543397L + "c25d9ff0dba348f0b414fad2b63473a9:37E4611817E3B7D1": 1791569543398L + "c25d9ff0dba348f0b414fad2b63473a9:60C5AF9A89C97164": 1791569543399L + "c25d9ff0dba348f0b414fad2b63473a9:31CA66909C70BBD2": 1791569543399L + "c25d9ff0dba348f0b414fad2b63473a9:48352E1D8C5885B8": 1791569562559L + "c25d9ff0dba348f0b414fad2b63473a9:204938E4E04A0F20": 1791569562562L + "c25d9ff0dba348f0b414fad2b63473a9:5BC324F4449F0732": 1791569562563L + "c25d9ff0dba348f0b414fad2b63473a9:15B09C5CB616774A": 1791569562563L + "c25d9ff0dba348f0b414fad2b63473a9:05E6E064A356BD50": 1791569562564L + "c25d9ff0dba348f0b414fad2b63473a9:62734FEFB35CAA89": 1791569566899L + "c25d9ff0dba348f0b414fad2b63473a9:4EB9D9828FE1B815": 1791569568205L + "c25d9ff0dba348f0b414fad2b63473a9:6CFA6ECF64B0F11F": 1791569574860L + "c25d9ff0dba348f0b414fad2b63473a9:1C7F617A451B33EB": 1791569576166L + "c25d9ff0dba348f0b414fad2b63473a9:667534F77B0D08A0": 1791569722464L + "c25d9ff0dba348f0b414fad2b63473a9:2B7302E781A6AC1B": 1791569722466L + "c25d9ff0dba348f0b414fad2b63473a9:1FAEEED2AA6A76A1": 1791569722466L + "c25d9ff0dba348f0b414fad2b63473a9:350E7B5C5FA843A3": 1791569722467L + "c25d9ff0dba348f0b414fad2b63473a9:04358096D006C946": 1791569722467L + "c25d9ff0dba348f0b414fad2b63473a9:46BD9205AE5E6871": 1791569722470L + "c25d9ff0dba348f0b414fad2b63473a9:63BC4F2532FA4B8F": 1791570205497L + "c25d9ff0dba348f0b414fad2b63473a9:567F09E54EC6E4F3": 1791570206370L + "c25d9ff0dba348f0b414fad2b63473a9:52D6922EC6C02511": 1791570206998L + "c25d9ff0dba348f0b414fad2b63473a9:4D2D3786CD1BEA64": 1791570208719L + "c25d9ff0dba348f0b414fad2b63473a9:14FB326FBF335179": 1791570208721L + "c25d9ff0dba348f0b414fad2b63473a9:2C46B4017AC5F3AE": 1791570948856L + "c25d9ff0dba348f0b414fad2b63473a9:1D78E2179270F54A": 1791570948857L + "c25d9ff0dba348f0b414fad2b63473a9:5920FF9F85E86EB6": 1791570948858L + "c25d9ff0dba348f0b414fad2b63473a9:2AE7BDD533E3F839": 1791570948859L + "c25d9ff0dba348f0b414fad2b63473a9:370989F0ADFEE0EC": 1791570948859L + "c25d9ff0dba348f0b414fad2b63473a9:1B59BB41155FFE55": 1791570948859L + "c25d9ff0dba348f0b414fad2b63473a9:54AEEA17E0FA8135": 1791570948860L + "c25d9ff0dba348f0b414fad2b63473a9:0BE74809343EA491": 1791570948860L + "c25d9ff0dba348f0b414fad2b63473a9:2162B90127FA6656": 1791570948860L + "c25d9ff0dba348f0b414fad2b63473a9:7924BB8EEB78DE0D": 1791570948860L + "c25d9ff0dba348f0b414fad2b63473a9:2DA383B7A79FDE9A": 1791570948860L + "c25d9ff0dba348f0b414fad2b63473a9:5BE2B64C89F21435": 1791570948861L + "c25d9ff0dba348f0b414fad2b63473a9:3C8F065AEE4CDB80": 1791570948861L + "c25d9ff0dba348f0b414fad2b63473a9:5C863FCC138617D2": 1791570948861L + "c25d9ff0dba348f0b414fad2b63473a9:477F18566EC6FB2A": 1791570948861L + "c25d9ff0dba348f0b414fad2b63473a9:7E3B99D53B518048": 1791570948861L + "c25d9ff0dba348f0b414fad2b63473a9:0CE525ECCA99C748": 1791570948861L + "c25d9ff0dba348f0b414fad2b63473a9:4D8A5DE5D26D5838": 1791574579966L + "c25d9ff0dba348f0b414fad2b63473a9:539532A204C98799": 1791574579968L + "c25d9ff0dba348f0b414fad2b63473a9:5D265B41D964221E": 1791574579968L + "c25d9ff0dba348f0b414fad2b63473a9:4063209399EDD574": 1791574579969L + "c25d9ff0dba348f0b414fad2b63473a9:5BEC54791A7352FB": 1791574579969L + "c25d9ff0dba348f0b414fad2b63473a9:04DF879E17A497AD": 1791575245967L + "c25d9ff0dba348f0b414fad2b63473a9:358E378F267AFB26": 1791575867411L + "c25d9ff0dba348f0b414fad2b63473a9:7801AEE59EB6FAB8": 1791630060108L + "c25d9ff0dba348f0b414fad2b63473a9:1524A05A8654DAFD": 1791630060109L + "c25d9ff0dba348f0b414fad2b63473a9:521AFCFE3F8C0B95": 1791630060111L + "c25d9ff0dba348f0b414fad2b63473a9:06CEE001120093E8": 1791631824563L + "c25d9ff0dba348f0b414fad2b63473a9:0F263F73458E8C1A": 1791631824565L + "c25d9ff0dba348f0b414fad2b63473a9:28111616B1136DA6": 1791631824566L + "c25d9ff0dba348f0b414fad2b63473a9:289392DA9F79BE65": 1791631824567L + "c25d9ff0dba348f0b414fad2b63473a9:21F2E6AD4371C6B7": 1791631824568L + "c25d9ff0dba348f0b414fad2b63473a9:4D70A941AC068FD0": 1791631824569L + "c25d9ff0dba348f0b414fad2b63473a9:2478604121C909A0": 1791631824573L + "c25d9ff0dba348f0b414fad2b63473a9:0FD79DBEA296131D": 1791631824573L + "c25d9ff0dba348f0b414fad2b63473a9:7C6705B569F27D46": 1791631824577L + "c25d9ff0dba348f0b414fad2b63473a9:42FFCBCEDF641E38": 1791631824577L + "c25d9ff0dba348f0b414fad2b63473a9:388D3BFF0F54C116": 1791631824577L + "c25d9ff0dba348f0b414fad2b63473a9:70556C33AE952F49": 1791631824577L + "c25d9ff0dba348f0b414fad2b63473a9:6F04437283A384E5": 1791631824577L + "c25d9ff0dba348f0b414fad2b63473a9:116FA96039FC2359": 1791631824577L + "c25d9ff0dba348f0b414fad2b63473a9:28C8F5467AC0B3CA": 1791631824578L + "c25d9ff0dba348f0b414fad2b63473a9:0A20CAF72F0E3E11": 1791631824578L + "c25d9ff0dba348f0b414fad2b63473a9:24AA489F2E015748": 1791631824578L + "c25d9ff0dba348f0b414fad2b63473a9:344F5D87627A95C7": 1791631824578L + "c25d9ff0dba348f0b414fad2b63473a9:0CBEB0DD40A9CB80": 1791631824578L + "c25d9ff0dba348f0b414fad2b63473a9:56A84014FED57806": 1791632560494L + "c25d9ff0dba348f0b414fad2b63473a9:264BB4B6A847C599": 1791632560496L + "c25d9ff0dba348f0b414fad2b63473a9:22EE7E7246E8E87E": 1791635062101L + "c25d9ff0dba348f0b414fad2b63473a9:4EF4E3620A7DE746": 1791635062102L + "c25d9ff0dba348f0b414fad2b63473a9:5E13A5A23EC1E79F": 1791635062103L + "c25d9ff0dba348f0b414fad2b63473a9:2399FCE4B2CC24CB": 1791635062104L + "c25d9ff0dba348f0b414fad2b63473a9:4C83D836AF808F98": 1791635062104L + "c25d9ff0dba348f0b414fad2b63473a9:7168285F8D52B227": 1791635062105L + "c25d9ff0dba348f0b414fad2b63473a9:5E00FAF6499D09FC": 1791635062105L + "c25d9ff0dba348f0b414fad2b63473a9:6A6F29F26CF6D4D6": 1791635062106L + "c25d9ff0dba348f0b414fad2b63473a9:197F4FAA40D360D9": 1791635070639L + "c25d9ff0dba348f0b414fad2b63473a9:4CAE7A515FD1C1E3": 1791636524960L + "c25d9ff0dba348f0b414fad2b63473a9:687590B4586241E2": 1791636749751L + "c25d9ff0dba348f0b414fad2b63473a9:02FF9639E87BF709": 1791636749754L + "c25d9ff0dba348f0b414fad2b63473a9:7FABE2A6BAA7DF66": 1791636749754L + "c25d9ff0dba348f0b414fad2b63473a9:4247F14C46042AFC": 1791664292946L + "c25d9ff0dba348f0b414fad2b63473a9:6D1513E170292C9B": 1791664292948L + "c25d9ff0dba348f0b414fad2b63473a9:716EC603C1FC926F": 1791664292948L + "c25d9ff0dba348f0b414fad2b63473a9:17540418EC34A14F": 1791664292949L + "c25d9ff0dba348f0b414fad2b63473a9:13FA6BBB734A88B7": 1791664292950L + "c25d9ff0dba348f0b414fad2b63473a9:201EF4EE5FE1D678": 1791664292952L + "c25d9ff0dba348f0b414fad2b63473a9:687A19D5531FF279": 1791664292953L + "c25d9ff0dba348f0b414fad2b63473a9:44A4621E31A31C03": 1791664292954L + "c25d9ff0dba348f0b414fad2b63473a9:63594966914DDDEE": 1791664292955L + "c25d9ff0dba348f0b414fad2b63473a9:01B93E1CF0F70D99": 1791664292955L + "c25d9ff0dba348f0b414fad2b63473a9:752FD50E2A94D9EA": 1791664292956L + "c25d9ff0dba348f0b414fad2b63473a9:7D222784210DB53F": 1791664292956L + "c25d9ff0dba348f0b414fad2b63473a9:48961510103ECFA9": 1791664292957L + "c25d9ff0dba348f0b414fad2b63473a9:082D932BDF87998A": 1791664292957L + "c25d9ff0dba348f0b414fad2b63473a9:7611CDA3EC2EC2F0": 1791664292958L + "c25d9ff0dba348f0b414fad2b63473a9:49C98505A02CF9D9": 1791664292958L + "c25d9ff0dba348f0b414fad2b63473a9:12183564E777E5E0": 1791664292960L + "c25d9ff0dba348f0b414fad2b63473a9:13AE19C718166401": 1791664292960L + "c25d9ff0dba348f0b414fad2b63473a9:67650422D108EDAC": 1791664292961L + "c25d9ff0dba348f0b414fad2b63473a9:0D227CD4C7E3E084": 1791664292962L + "c25d9ff0dba348f0b414fad2b63473a9:738FEBB9C29A0B70": 1791664292962L + "c25d9ff0dba348f0b414fad2b63473a9:4C4717AB0D510404": 1791664292962L + "c25d9ff0dba348f0b414fad2b63473a9:29AFF06DC385D68F": 1791664292963L + "c25d9ff0dba348f0b414fad2b63473a9:6492368F4FF254A7": 1791664292963L + "c25d9ff0dba348f0b414fad2b63473a9:51A5CFAC8F959189": 1791664292963L + "c25d9ff0dba348f0b414fad2b63473a9:0289C12C1A4022C4": 1791664292964L + "c25d9ff0dba348f0b414fad2b63473a9:4B45935DA547D7B4": 1791664292964L + "c25d9ff0dba348f0b414fad2b63473a9:776C92303452C13A": 1791664292965L + "c25d9ff0dba348f0b414fad2b63473a9:1394A54150017C88": 1791664292965L + "c25d9ff0dba348f0b414fad2b63473a9:0D0F47B9722395FB": 1791664292965L + "c25d9ff0dba348f0b414fad2b63473a9:748014A68CC69AA4": 1791664292965L + "c25d9ff0dba348f0b414fad2b63473a9:11892350D0F57F0A": 1791664292965L + "c25d9ff0dba348f0b414fad2b63473a9:4E6C67FC60596199": 1791664292966L + "c25d9ff0dba348f0b414fad2b63473a9:76915C0FFCE25CD5": 1791664292966L + "c25d9ff0dba348f0b414fad2b63473a9:2660677569A147E1": 1791664292966L + "c25d9ff0dba348f0b414fad2b63473a9:47406628118C4FAC": 1791664292967L + "c25d9ff0dba348f0b414fad2b63473a9:600CE8C98D0CF3B2": 1791664292967L + "c25d9ff0dba348f0b414fad2b63473a9:7CF35F9ED70945C9": 1791664292967L + "c25d9ff0dba348f0b414fad2b63473a9:17C6667F09D439F1": 1791664292968L + "c25d9ff0dba348f0b414fad2b63473a9:1C7FBA6844D50ABA": 1791664292968L + "c25d9ff0dba348f0b414fad2b63473a9:7733E4DC78031241": 1791664292968L + "c25d9ff0dba348f0b414fad2b63473a9:3154E935BF7E0C2E": 1791664292968L + "c25d9ff0dba348f0b414fad2b63473a9:60F215C2152F90F5": 1791664292968L + "c25d9ff0dba348f0b414fad2b63473a9:0BC0C16377807E51": 1791664292968L + "c25d9ff0dba348f0b414fad2b63473a9:08AC2295DBD8B915": 1791664292968L + "c25d9ff0dba348f0b414fad2b63473a9:441961E8BDDFDE22": 1791664292968L + "c25d9ff0dba348f0b414fad2b63473a9:46E642A34AD07DB6": 1791664292968L + "c25d9ff0dba348f0b414fad2b63473a9:089AE645B9673F30": 1791664292968L + "c25d9ff0dba348f0b414fad2b63473a9:56F26978B443541E": 1791664292969L + "c25d9ff0dba348f0b414fad2b63473a9:07A1BED0B55C47C6": 1791664292969L + "c25d9ff0dba348f0b414fad2b63473a9:5D309655B901C073": 1791664502452L + "c25d9ff0dba348f0b414fad2b63473a9:12756A65FE318060": 1791664502453L + "c25d9ff0dba348f0b414fad2b63473a9:341723A1E8951B15": 1791664502454L + "c25d9ff0dba348f0b414fad2b63473a9:45C1AA71947C4995": 1791664502454L + "c25d9ff0dba348f0b414fad2b63473a9:43031D02E152F43D": 1791664502455L + "c25d9ff0dba348f0b414fad2b63473a9:04CDF937CBD1C53D": 1791664502455L + "c25d9ff0dba348f0b414fad2b63473a9:769AEF45B4D10F89": 1791664502455L + "c25d9ff0dba348f0b414fad2b63473a9:2D10CC62E829D608": 1791664502456L + "c25d9ff0dba348f0b414fad2b63473a9:7CEFECD1244BFF92": 1791664502456L + "c25d9ff0dba348f0b414fad2b63473a9:728A5E0B2CC417CC": 1791664502457L + "c25d9ff0dba348f0b414fad2b63473a9:0B8ECCC73D708913": 1791664502457L + "c25d9ff0dba348f0b414fad2b63473a9:7EA74CD7BD2DBD03": 1791664502458L + "c25d9ff0dba348f0b414fad2b63473a9:4E6A4D72CF6BECA4": 1791664502458L + "c25d9ff0dba348f0b414fad2b63473a9:267158EC8C481DAA": 1791664502458L + "c25d9ff0dba348f0b414fad2b63473a9:7DA9DE79920ACF0D": 1791664502459L + "c25d9ff0dba348f0b414fad2b63473a9:7905B57A34AF9588": 1791664502460L + "c25d9ff0dba348f0b414fad2b63473a9:79532E4325832DA5": 1791664502460L + "c25d9ff0dba348f0b414fad2b63473a9:2D599F0012C5292F": 1791664502460L + "c25d9ff0dba348f0b414fad2b63473a9:50B3CF752D26C095": 1791664502461L + "c25d9ff0dba348f0b414fad2b63473a9:6B5B40ABA34AD0AC": 1791673100000L + "c25d9ff0dba348f0b414fad2b63473a9:56D64DA716E084ED": 1791673100002L + "c25d9ff0dba348f0b414fad2b63473a9:01E691296A3A4432": 1791673100003L + "c25d9ff0dba348f0b414fad2b63473a9:509E6D8B500C555F": 1791673100003L + "c25d9ff0dba348f0b414fad2b63473a9:2BD31DE66546CFDA": 1791673100004L + "c25d9ff0dba348f0b414fad2b63473a9:61AF877D7328B938": 1791673100004L + "c25d9ff0dba348f0b414fad2b63473a9:65922BB4E641D525": 1791673100004L + "c25d9ff0dba348f0b414fad2b63473a9:2613BC08CDB9516F": 1791673100004L + "c25d9ff0dba348f0b414fad2b63473a9:53B3F46D9DF3001B": 1791673100005L + "c25d9ff0dba348f0b414fad2b63473a9:231B5B9731B07E53": 1791673100005L + "c25d9ff0dba348f0b414fad2b63473a9:2374BE0128AF2ED8": 1791673100005L + } + player_data: { } +} diff --git a/backend/test/fixtures/ftbquests/config/chapters/getting_started.snbt b/backend/test/fixtures/ftbquests/config/chapters/getting_started.snbt new file mode 100644 index 0000000..bcadbc5 --- /dev/null +++ b/backend/test/fixtures/ftbquests/config/chapters/getting_started.snbt @@ -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 + } + ] +} diff --git a/backend/test/fixtures/ftbquests/config/lang/en_us/chapter.snbt_merged b/backend/test/fixtures/ftbquests/config/lang/en_us/chapter.snbt_merged new file mode 100644 index 0000000..1a30ca0 --- /dev/null +++ b/backend/test/fixtures/ftbquests/config/lang/en_us/chapter.snbt_merged @@ -0,0 +1,4 @@ +{ + chapter.1A2B3C4D5E6F7081.title: "Getting Started" + chapter.1A2B3C4D5E6F7081.chapter_subtitle: ["A handwritten test chapter"] +} diff --git a/backend/test/fixtures/ftbquests/config/lang/en_us/chapters/getting_started.snbt_merged b/backend/test/fixtures/ftbquests/config/lang/en_us/chapters/getting_started.snbt_merged new file mode 100644 index 0000000..c0afddf --- /dev/null +++ b/backend/test/fixtures/ftbquests/config/lang/en_us/chapters/getting_started.snbt_merged @@ -0,0 +1,5 @@ +{ + quest.3BC0A50886A3222B.title: "Punch a Tree" + quest.3BC0A50886A3222B.quest_desc: ["Line one", "Line two"] + quest.33218EFDD498E9DB.title: "From Lang" +} diff --git a/backend/test/fixtures/ftbteams/party/5e0c1a2b-0000-4000-8000-00000000abcd.snbt b/backend/test/fixtures/ftbteams/party/5e0c1a2b-0000-4000-8000-00000000abcd.snbt new file mode 100644 index 0000000..df75396 --- /dev/null +++ b/backend/test/fixtures/ftbteams/party/5e0c1a2b-0000-4000-8000-00000000abcd.snbt @@ -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: { } +} diff --git a/backend/test/fixtures/ftbteams/player/308a693c-2bd8-4d6b-8790-670aef5be601.snbt b/backend/test/fixtures/ftbteams/player/308a693c-2bd8-4d6b-8790-670aef5be601.snbt new file mode 100644 index 0000000..5f17a46 --- /dev/null +++ b/backend/test/fixtures/ftbteams/player/308a693c-2bd8-4d6b-8790-670aef5be601.snbt @@ -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: { } +} diff --git a/backend/test/fixtures/stats/308a693c-2bd8-4d6b-8790-670aef5be601.json b/backend/test/fixtures/stats/308a693c-2bd8-4d6b-8790-670aef5be601.json new file mode 100644 index 0000000..9ebfc8b --- /dev/null +++ b/backend/test/fixtures/stats/308a693c-2bd8-4d6b-8790-670aef5be601.json @@ -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:diamond_essence":53,"minecraft:oak_button":1,"functionalstorage:spruce_1":4,"minecraft:ender_pearl":1,"irons_spellbooks:scroll":1,"sophisticatedstorage:iron_barrel":1,"minecraft:wheat":96,"mysticalagriculture:iron_seeds":5,"mysticalagriculture:dirt_essence":12,"minecraft:stone_brick_stairs":1,"minecraft:raw_copper":114,"irons_spellbooks:arcane_essence":8,"minecraft:bucket":1,"reliquary:zombie_heart":1,"mekanism:purification_chamber":1,"minecraft:redstone_torch":4,"draconicevolution:draconium_dust":17,"minecraft:wooden_shovel":1,"minecraft:iron_pickaxe":1,"xycraft_world:xychorium_gem_green":27,"minecraft:furnace":2,"powah:magmator_starter":1,"minecraft:stick":1,"rootsclassic:whitecurrant":59,"apotheosis:gem":7,"mysticalagriculture:imperium_essence":3,"minecraft:torch":111,"tombstone:grave_dust":12,"minecraft:diorite":70,"actuallyadditions:black_quartz":15,"minecraft:quartz_block":2,"silentgear:leather_scrap":15,"minecraft:rotten_flesh":34,"create:asurine":5,"xycraft_world:kivi":15,"minecraft:obsidian":13,"silentgems:garnet_glowrose":7,"minecraft:cobblestone":1458,"occultism:datura_seeds":3,"minecraft:leather_chestplate":1,"mysticalagriculture:wand":22,"minecraft:andesite":71,"minecraft:potato":16,"silentgems:garnet":19,"minecraft:tuff":157,"supplementaries:flax_seeds":7,"minecraft:dirt":253,"actuallyadditions:worm":7,"minecraft:lapis_lazuli":160,"mysticalagriculture:dirt_seeds":1,"alltheores:raw_tin":12,"trophymanager:trophy":6,"minecraft:glass":10,"minecraft:bone":21,"minecraft:wheat_seeds":109,"minecraft:raw_gold":36,"mysticalagriculture:prosperity_block":1,"silentgems:chaos_essence":110,"minecraft:oak_planks":5,"herbsandharvest:green_pepper":5,"mysticalagriculture:inferium_growth_accelerator":9,"alltheores:raw_lead":11,"auroral:guide":1,"minecraft:sand":4,"herbsandharvest:thistle":1,"minecraft:grass_block":1,"forbidden_arcanus:ender_pearl_fragment":2,"alltheores:raw_nickel":8,"xycraft_world:xychorium_gem_light":28,"mekanism:bio_fuel":20,"minecraft:diamond":85,"alltheores:raw_aluminum":10,"minecraft:emerald":102,"minecraft:spruce_sign":2,"silentgems:heliodor":3,"minecraft:netherrack":473,"minecraft:charcoal":4,"mysticalagriculture:soulstone_cobble":27,"herbsandharvest:raspberry":5,"rootsclassic:old_root":2,"alltheores:sulfur":112,"sophisticatedstorage:basic_to_iron_tier_upgrade":1,"rootsclassic:redcurrant":4,"mekanism:energized_smelter":2,"minecraft:blackstone":17,"minecraft:candle":3,"herbsandharvest:zucchini_seeds":12,"minecraft:light_gray_wool":3,"mysticalagriculture:inferium_block":5,"sophisticatedstorage:iron_chest":3,"minecraft:dark_oak_sapling":4,"minecraft:raw_iron":102,"herbsandharvest:cauliflower":5,"mysticalagriculture:netherite_seeds":4,"minecraft:copper_ingot":102,"minecraft:bowl":3,"minecraft:brown_dye":3,"powah:capacitor_basic_large":1,"minecraft:arrow":10,"xycraft_world:xychorium_gem_blue":21,"powah:wrench":1,"relics:midnight_mantle":1,"modern_industrialization:raw_antimony":22,"minecraft:basalt":67,"minecraft:leather":1,"minecraft:chainmail_chestplate":1,"minecraft:gunpowder":1,"mekanism:enrichment_chamber":2,"rftoolsbase:dimensionalshard":18,"minecraft:pearlescent_froglight":10,"mysticalagriculture:supremium_essence":5,"minecraft:acacia_sapling":1,"alltheores:steel_block":2,"minecraft:quartz":29,"sushigocrafting:cucumber_seeds":1,"mekanism:basic_chemical_tank":7,"alltheores:raw_uranium":54,"minecraft:amethyst_shard":24,"herbsandharvest:green_bean_seeds":11,"mysticalagriculture:wood_seeds":2,"minecraft:spider_eye":3,"herbsandharvest:turnip":8,"mekanismgenerators:wind_generator":1,"evilcraft:dark_gem":107,"minecraft:cherry_sapling":1,"sophisticatedstorage:copper_barrel":1,"tombstone:magic_scroll":1,"herbsandharvest:zucchini":15,"silentgems:topaz":25,"minecraft:coal":190},"minecraft:mined":{"mekanismgenerators:wind_generator":1,"herbsandharvest:cauliflower_crop":8,"alltheores:deepslate_aluminum_ore":10,"minecraft:obsidian":13,"minecraft:granite":27,"silentgear:crimson_iron_ore":8,"mekanism:purification_chamber":1,"mysticalagriculture:inferium_ore":22,"alltheores:deepslate_cinnabar_ore":2,"herbsandharvest:pineapple_crop":7,"theurgy:deepslate_sal_ammoniac_ore":19,"xycraft_world:xychorium_ore_stone_green":7,"regions_unexplored:stone_bud":7,"minecraft:cobbled_deepslate":29,"minecraft:deepslate_lapis_ore":23,"mekanism:basic_logistical_transporter":5,"xycraft_world:xychorium_ore_deepslate_blue":8,"alltheores:deepslate_iridium_ore":3,"powah:uraninite_ore_poor":3,"mysticalagradditions:nether_prosperity_ore":20,"alltheores:nickel_ore":1,"xycraft_world:xychorium_ore_stone_light":5,"silentgems:deepslate_opal_ore":5,"mekanism:enrichment_chamber":2,"minecraft:amethyst_cluster":6,"mekanism:formulaic_assemblicator":1,"minecraft:torch":23,"silentgems:deepslate_alexandrite_ore":4,"create:scoria":2,"minecraft:spruce_wall_sign":1,"minecraft:glass":1,"minecraft:amethyst_block":1,"powah:deepslate_uraninite_ore_poor":12,"rftoolsbase:dimensionalshard_overworld":4,"herbsandharvest:zucchini_crop":5,"regions_unexplored:glister_bulb":1,"forbidden_arcanus:darkstone":321,"mysticalagriculture:soulium_ore":3,"alltheores:deepslate_osmium_ore":159,"minecraft:wheat":12,"alltheores:peridot_ore":1,"herbsandharvest:turnip_crop":9,"mysticalagriculture:deepslate_inferium_ore":39,"minecraft:deepslate":1913,"minecraft:infested_deepslate":6,"mysticalagriculture:iron_crop":1,"powah:deepslate_uraninite_ore":5,"silentgems:garnet_glowrose":7,"mysticalagriculture:soulstone":28,"alltheores:osmium_ore":20,"xycraft_world:xychorium_ore_stone_red":1,"sophisticatedstorage:iron_chest":3,"forbidden_arcanus:runic_darkstone":5,"minecraft:crafter":3,"minecraft:spruce_fence":1,"mysticalagriculture:netherite_crop":1,"alltheores:deepslate_silver_ore":16,"mekanism:basic_chemical_tank":6,"silentgems:aquamarine_ore":1,"minecraft:moss_block":4,"herbsandharvest:green_pepper_crop":5,"minecraft:redstone_ore":4,"forbidden_arcanus:deepslate_arcane_crystal_ore":7,"minecraft:andesite":71,"minecraft:potatoes":5,"minecraft:vine":4,"alltheores:silver_ore":19,"minecraft:white_wool":11,"lootr:lootr_barrel":1,"xycraft_world:kivi":15,"herbsandharvest:raspberry_crop":5,"minecraft:short_grass":120,"oritech:uranium_crystal":2,"xycraft_world:xychorium_ore_deepslate_red":11,"minecraft:tall_grass":8,"minecraft:copper_ore":17,"silentgems:deepslate_garnet_ore":19,"minecraft:deepslate_gold_ore":29,"minecraft:diorite":70,"silentgems:deepslate_turquoise_ore":1,"herbsandharvest:thistle":1,"minecraft:spruce_log":22,"silentgems:deepslate_topaz_ore":25,"minecraft:dripstone_block":1,"minecraft:gravel":99,"minecraft:stone_bricks":16,"alltheores:deepslate_zinc_ore":38,"minecraft:fern":11,"oritech:deepslate_uranium_ore":8,"minecraft:large_amethyst_bud":1,"silentgems:deepslate_aquamarine_ore":9,"minecraft:farmland":13,"mekanism:energized_smelter":2,"alltheores:nether_fluorite_ore":7,"minecraft:grass_block":15,"draconicevolution:deepslate_draconium_ore":5,"xycraft_world:geyser":1,"silentgems:deepslate_chaos_ore":58,"silentgems:nether_black_diamond_ore":12,"minecraft:iron_ore":30,"alltheores:deepslate_lead_ore":11,"minecraft:azure_bluet":1,"silentgear:deepslate_bort_ore":10,"create:asurine":5,"sophisticatedstorage:chest":1,"minecraft:iron_bars":2,"mysticalagriculture:wood_crop":1,"alltheores:fluorite_ore":3,"xycraft_world:xychorium_ore_deepslate_green":6,"minecraft:deepslate_redstone_ore":80,"modern_industrialization:deepslate_tungsten_ore":11,"minecraft:blackstone":36,"mysticalagriculture:inferium_growth_accelerator":6,"alltheores:nether_lead_ore":1,"minecraft:clay":20,"mysticalagriculture:inferium_crop":44,"minecraft:poppy":7,"minecraft:chest":4,"sophisticatedstorage:copper_barrel":1,"minecraft:nether_quartz_ore":33,"herbsandharvest:green_bean_crop":4,"minecraft:cobblestone":41,"minecraft:oak_planks":2,"alltheores:deepslate_nickel_ore":7,"minecraft:wall_torch":52,"minecraft:deepslate_copper_ore":14,"biomeswevegone:kovan_flower":35,"minecraft:dark_oak_slab":1,"evilcraft:dark_ore_deepslate":22,"alltheores:deepslate_uranium_ore":54,"alltheores:deepslate_peridot_ore":2,"minecraft:sand":4,"minecraft:dirt":198,"mysticalagriculture:deepslate_prosperity_ore":237,"powah:magmator_starter":1,"mekanism:basic_universal_cable":2,"minecraft:netherrack":543,"minecraft:ancient_debris":11,"regions_unexplored:glistering_nylium":1,"mysticalagriculture:prosperity_ore":19,"minecraft:tuff":155,"refinedstorage:autocrafter":1,"alltheores:zinc_ore":47,"minecraft:deepslate_diamond_ore":79,"powah:deepslate_uraninite_ore_dense":1,"silentgems:deepslate_heliodor_ore":3,"modern_industrialization:deepslate_antimony_ore":17,"mekanism:basic_mechanical_pipe":3,"minecraft:glow_lichen":1,"minecraft:spruce_leaves":11,"xycraft_world:xychorium_ore_deepslate_light":7,"alltheores:deepslate_tin_ore":12,"minecraft:large_fern":11,"minecraft:hay_block":2,"minecraft:furnace":2,"minecraft:carrots":35,"alltheores:deepslate_fluorite_ore":3,"alltheores:deepslate_platinum_ore":12,"minecraft:deepslate_iron_ore":72,"alltheores:deepslate_sulfur_ore":112,"sophisticatedstorage:iron_barrel":1,"regions_unexplored:medium_grass":26,"minecraft:stone":1379,"minecraft:redstone_wall_torch":4,"functionalstorage:spruce_1":4,"minecraft:basalt":67,"actuallyadditions:black_quartz_ore":16,"xycraft_world:xychorium_ore_stone_dark":2,"minecraft:deepslate_coal_ore":1,"theurgy:sal_ammoniac_ore":17,"minecraft:lapis_ore":1,"mekanism:metallurgic_infuser":2,"supplementaries:urn":34,"mysticalagriculture:dirt_crop":1,"modern_industrialization:antimony_ore":5,"minecraft:coal_ore":115,"farmingforblockheads:fertilized_farmland_healthy":2,"forbidden_arcanus:runic_deepslate":12},"minecraft:broken":{"minecraft:diamond_pickaxe":3,"minecraft:iron_pickaxe":1,"minecraft:stone_pickaxe":1},"minecraft:crafted":{"mekanism:configurator":1,"mekanism:electrolytic_separator":1,"minecraft:lever":12,"minecraft:iron_bars":16,"mysticalagriculture:supremium_farmland":5,"minecraft:glass":10,"minecraft:quartz_block":6,"minecraft:diamond_helmet":1,"minecraft:diamond_leggings":1,"mysticalagriculture:watering_can":1,"minecraft:polished_blackstone":4,"powah:magmator_starter":1,"refinedstorage:processor_binding":8,"refinedstorage:autocrafter":1,"mekanism:energy_tablet":5,"refinedstorage:raw_advanced_processor":2,"sophisticatedstorage:copper_barrel":1,"minecraft:wooden_shovel":1,"mysticalagriculture:prudentium_watering_can":1,"farmingforblockheads:red_fertilizer":32,"minecraft:copper_ingot":3,"mysticalagriculture:supremium_essence":43,"minecraft:crafter":1,"minecraft:iron_pickaxe":2,"mysticalagriculture:prosperity_shard":1,"minecraft:netherite_ingot":37,"mysticalagriculture:infusion_crystal":3,"refinedstorage:machine_casing":1,"minecraft:spruce_fence_gate":1,"powah:capacitor_basic_tiny":2,"mekanism:basic_logistical_transporter":8,"powah:dielectric_casing":1,"sophisticatedstorage:basic_tier_upgrade":1,"mysticalagriculture:inferium_farmland":14,"minecraft:diamond_hoe":14,"minecraft:bone_meal":31,"minecraft:spruce_fence":9,"mysticalagriculture:inferium_ingot":10,"functionalstorage:linking_tool":1,"mekanism:basic_pressurized_tube":8,"sophisticatedstorage:copper_chest":6,"minecraft:diamond":181,"minecraft:spruce_slab":6,"minecraft:stone_pickaxe":3,"mekanism:electric_pump":1,"minecraft:diamond_boots":1,"mekanism:formulaic_assemblicator":1,"minecraft:quartz_slab":6,"functionalstorage:spruce_1":1,"minecraft:spruce_boat":1,"alltheores:steel_ingot":5,"minecraft:redstone_block":1,"powah:dielectric_paste":2,"mysticalagriculture:inferium_seeds":70,"alltheores:osmium_ingot":33,"mysticalagriculture:prosperity_gemstone":148,"refinedstorage:raw_basic_processor":2,"minecraft:wooden_axe":1,"minecraft:iron_shovel":1,"minecraft:iron_leggings":1,"mekanism:basic_mechanical_pipe":8,"powah:capacitor_basic":4,"minecraft:farmland":60,"mekanism:free_runners":1,"minecraft:oak_planks":24,"minecraft:barrel":1,"mysticalagriculture:mystical_fertilizer":5,"mekanism:electrolytic_core":1,"sophisticatedstorage:barrel":1,"mekanism:basic_tier_installer":4,"mysticalagriculture:prosperity_seed_base":11,"minecraft:iron_boots":1,"mysticalagriculture:inferium_watering_can":1,"mysticalagriculture:tertium_essence":709,"minecraft:chest":9,"pylons:harvester_pylon":1,"minecraft:oak_button":1,"minecraft:stone_hoe":1,"mysticalagriculture:supremium_gemstone":3,"mysticalagriculture:inferium_gemstone":143,"minecraft:dropper":1,"mekanism:crafting_formula":2,"minecraft:crafting_table":4,"minecraft:spruce_planks":227,"minecraft:wooden_pickaxe":2,"minecraft:iron_helmet":1,"minecraft:red_wool":9,"mekanism:purification_chamber":1,"minecraft:iron_chestplate":1,"minecraft:diamond_pickaxe":4,"minecraft:charcoal":1,"mekanism:steel_casing":3,"minecraft:stick":220,"mekanism:crusher":1,"minecraft:iron_ingot":71,"mysticalagriculture:prudentium_gemstone":2,"minecraft:redstone_torch":8,"mysticalagriculture:inferium_hoe":3,"mysticalagriculture:master_infusion_crystal":1,"mysticalagriculture:imperium_essence":174,"minecraft:shears":1,"mekanism:basic_universal_cable":48,"minecraft:raw_iron":5,"mekanismgenerators:wind_generator":1,"minecraft:spruce_button":2,"mysticalagriculture:infusion_pedestal":8,"mysticalagriculture:inferium_growth_accelerator":168,"sophisticatedstorage:iron_chest":6,"minecraft:furnace":4,"mekanism:energized_smelter":1,"mysticalagriculture:prosperity_ingot":19,"refinedstorage:quartz_enriched_iron":12,"mekanism:jetpack":1,"minecraft:flint_and_steel":1,"refinedstorage:destruction_core":1,"mysticalagriculture:inferium_essence":8,"powah:dielectric_rod_horizontal":8,"sophisticatedstorage:iron_barrel":1,"minecraft:red_dye":45,"mysticalagriculture:prudentium_essence":2853,"minecraft:diamond_sword":1,"minecraft:spruce_sign":3,"mekanism:metallurgic_infuser":1,"minecraft:iron_sword":2,"minecraft:bowl":4,"silentgear:hoe":1,"minecraft:dirt":24,"minecraft:bucket":7,"minecraft:bread":4,"minecraft:air":6421,"mysticalagriculture:supremium_ingot":2,"powah:dielectric_rod":8,"sophisticatedstorage:chest":10,"mekanism:basic_energy_cube":1,"mekanism:enrichment_chamber":2,"mysticalagriculture:prudentium_ingot":7,"mysticalagriculture:infusion_altar":1,"refinedstorage:construction_core":1,"mekanism:advanced_control_circuit":2,"minecraft:torch":134,"minecraft:diamond_chestplate":1},"minecraft:used":{"farmersdelight:onion_crate":3,"mekanism:advanced_logistical_transporter":3,"trashcans:item_trash_can":1,"minecraft:chest":10,"minecraft:stone":21,"mekanism:basic_logistical_transporter":7,"minecraft:crafter":3,"mekanism:basic_energy_cube":1,"minecraft:spruce_boat":1,"minecraft:furnace":4,"powah:magmator_starter":1,"actuallyadditions:worm":1,"minecraft:blackstone":2,"minecraft:wooden_pickaxe":26,"herbsandharvest:cauliflower_seeds":13,"mekanism:advanced_energy_cube":1,"forbidden_arcanus:darkstone":30,"trophymanager:trophy":5,"sophisticatedstorage:chest":5,"minecraft:diamond_sword":250,"mysticalagriculture:wood_seeds":2,"minecraft:netherrack":35,"herbsandharvest:zucchini_seeds":5,"minecraft:torch":574,"mysticalagriculture:inferium_hoe":1,"sophisticatedstorage:iron_barrel":2,"mysticalagriculture:infusion_pedestal":8,"mekanism:purification_chamber":2,"farmersdelight:cabbage_crate":3,"sophisticatedstorage:copper_barrel":1,"minecraft:basalt":3,"tombstone:gift":6,"minecraft:cobbled_deepslate":139,"minecraft:wheat_seeds":23,"minecraft:water_bucket":12,"mekanism:formulaic_assemblicator":2,"minecraft:spruce_fence_gate":1,"minecraft:crafting_table":4,"minecraft:spruce_log":4,"mysticalagriculture:netherite_seeds":4,"mysticalagriculture:dirt_seeds":2,"minecraft:diamond_pickaxe":6052,"functionalstorage:spruce_1":4,"minecraft:oak_button":1,"minecraft:iron_pickaxe":249,"mysticalagriculture:diamond_seeds":3,"mysticalagriculture:iron_seeds":5,"refinedstorage:autocrafter":1,"minecraft:cobblestone":278,"sophisticatedstorage:iron_chest":9,"minecraft:iron_shovel":222,"minecraft:diamond_hoe":1,"minecraft:iron_sword":246,"mysticalagriculture:infusion_altar":1,"mekanism:basic_mechanical_pipe":3,"minecraft:carrot":235,"mekanism:basic_universal_cable":67,"minecraft:tuff":4,"mysticalagriculture:inferium_seeds":114,"minecraft:obsidian":13,"minecraft:glass":1,"mekanismgenerators:wind_generator":5,"mekanism:basic_tier_installer":4,"herbsandharvest:blueberry_seeds":3,"minecraft:stone_bricks":5,"pylons:harvester_pylon":1,"mysticalagriculture:wand":295,"minecraft:stone_pickaxe":287,"minecraft:oak_planks":2,"herbsandharvest:pineapple_seeds":7,"minecraft:white_wool":10,"mysticalagriculture:inferium_farmland":9,"sophisticatedstorage:basic_tier_upgrade":1,"minecraft:shears":1,"minecraft:redstone_torch":4,"mekanism:crusher":1,"farmingforblockheads:red_fertilizer":32,"minecraft:bread":53,"mekanism:metallurgic_infuser":3,"mekanism:basic_pressurized_tube":1,"minecraft:andesite":6,"herbsandharvest:sweet_potato":3,"herbsandharvest:turnip_seeds":4,"minecraft:bone_meal":60,"mekanism:advanced_tier_installer":1,"mekanism:enrichment_chamber":3,"mysticalagriculture:inferium_growth_accelerator":423,"minecraft:flint_and_steel":1,"mekanism:configurator":79,"silentgear:hoe":1,"minecraft:wooden_shovel":23,"minecraft:stone_hoe":44,"minecraft:dirt":111,"minecraft:spruce_fence":19,"minecraft:granite":8,"minecraft:iron_hoe":83,"herbsandharvest:green_bean_seeds":5,"herbsandharvest:raspberry_seeds":5,"mekanism:electrolytic_separator":1,"minecraft:cooked_beef":3,"sophisticatedstorage:basic_to_copper_tier_upgrade":1,"minecraft:cooked_mutton":2,"mekanism:electric_pump":1,"minecraft:spruce_sign":4,"farmersdelight:onion":6,"herbsandharvest:green_pepper_seeds":9,"mekanism:basic_chemical_tank":7,"mekanism:energized_smelter":3,"mysticalagriculture:supremium_farmland":5,"minecraft:bucket":12},"minecraft:dropped":{"minecraft:iron_sword":1,"minecraft:short_grass":1,"actuallyadditions:worm":3,"minecraft:dirt":26,"minecraft:iron_pickaxe":1,"minecraft:bone":2,"minecraft:cobbled_deepslate":78,"minecraft:rotten_flesh":17,"mysticalagradditions:supremium_apple":2,"minecraft:charcoal":2,"mysticalagriculture:inferium_essence":319,"minecraft:quartz_block":3,"minecraft:wheat_seeds":12,"sushigocrafting:wasabi_seeds":1,"minecraft:cobblestone":53,"minecraft:chainmail_helmet":1,"silentgear:bort":2,"minecraft:redstone_ore":6,"minecraft:stone_sword":1,"minecraft:bowl":3,"minecraft:diamond_sword":1,"xycraft_world:xychorium_gem_red":2,"minecraft:stone":3,"minecraft:gravel":39,"minecraft:carrot":58,"minecraft:leather_chestplate":1,"supplementaries:ash":7,"silentgear:leather_scrap":6,"minecraft:basalt":1,"minecraft:arrow":4,"minecraft:quartz":2,"minecraft:netherrack":6,"minecraft:bread":1,"herbsandharvest:dill_seeds":1,"minecraft:grass_block":1,"minecraft:bucket":1,"minecraft:tuff":2,"mysticalagriculture:imperium_essence":3,"minecraft:stone_pickaxe":2,"minecraft:wooden_pickaxe":1},"minecraft:killed_by":{"minecraft:zombie":2,"minecraft:cave_spider":1,"artifacts:mimic":1}},"DataVersion":3955} \ No newline at end of file diff --git a/backend/test/fixtures/stats/c25d9ff0-dba3-48f0-b414-fad2b63473a9.json b/backend/test/fixtures/stats/c25d9ff0-dba3-48f0-b414-fad2b63473a9.json new file mode 100644 index 0000000..2fa20d8 --- /dev/null +++ b/backend/test/fixtures/stats/c25d9ff0-dba3-48f0-b414-fad2b63473a9.json @@ -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,"sophisticatedstorage:iron_barrel":2,"minecraft:iron_chestplate":1,"minecraft:chainmail_boots":1,"bigreactors:graphite_ingot":10,"minecraft:lead":8,"aether:iron_gloves":1,"minecraft:potion":2,"minecraft:brown_bed":3,"herbsandharvest:pineapple":50,"minecraft:enchanted_book":10,"minecraft:golden_shovel":3,"herbsandharvest:turnip_seeds":55,"minecraft:golden_sword":1,"alltheores:platinum_ingot":12,"minecraft:amethyst_block":18,"minecraft:string":4,"evilcraft:ender_tear":1,"minecraft:raw_copper":165,"sushigocrafting:sesame_seeds":1,"mekanism:basic_universal_cable":17,"irons_spellbooks:arcane_essence":1,"mysticalagriculture:inferium_essence":41,"minecraft:gold_nugget":14,"minecraft:cobbled_deepslate":532,"minecraft:bucket":11,"alltheores:nickel_ingot":4,"artifacts:aqua_dashers":1,"minecraft:wooden_shovel":2,"minecraft:azalea":2,"pamhc2crops:tomatoitem":1,"minecraft:iron_pickaxe":2,"ars_elemental:flashpine_pod":3,"xycraft_world:xychorium_gem_green":20,"minecraft:furnace":5,"minecraft:dark_oak_door":1,"powah:magmator_starter":1,"minecraft:stick":232,"rootsclassic:whitecurrant":57,"apotheosis:gem":1,"rootsclassic:verdant_sprig":2,"minecraft:torch":315,"everythingcopper:copper_leggings":2,"powah:book":2,"rootsclassic:nightshade":71,"mysticalagriculture:infusion_crystal":1,"tombstone:grave_dust":2,"minecraft:diorite":75,"mekanism:energy_tablet":1,"actuallyadditions:black_quartz":4,"minecraft:quartz_block":8,"silentgear:leather_scrap":6,"minecraft:rotten_flesh":56,"farmersdelight:onion":22,"ars_nouveau:worn_notebook":2,"minecraft:wolf_spawn_egg":3,"alltheores:silver_ingot":24,"ars_nouveau:sourceberry_bush":44,"supplementaries:ash":2,"minecraft:iron_bars":12,"minecraft:obsidian":8,"minecraft:cobblestone":1975,"minecraft:nautilus_shell":6,"minecraft:enchanting_table":1,"minecraft:leather_chestplate":4,"minecraft:feather":13,"minecraft:water_bucket":1,"ars_nouveau:archwood_door":1,"minecraft:andesite":195,"minecraft:potato":21,"silentgems:garnet":1,"minecraft:flint":32,"minecraft:tuff":41,"ars_elemental:yellow_archwood_sapling":1,"evilcraft:flesh_humanoid":1,"trashcans:item_trash_can":2,"actuallyadditions:worm":3,"minecraft:lapis_lazuli":70,"minecraft:dirt":1435,"minecraft:name_tag":3,"trophymanager:trophy":20,"bhc:red_heart":15,"sushigocrafting:wasabi_seeds":1,"xycraft_world:xychorium_storage_dark":2,"minecraft:bone":29,"farmersdelight:iron_knife":1,"theurgy:sal_ammoniac_crystal":8,"herbsandharvest:pineapple_seeds":100,"minecraft:wheat_seeds":9,"minecraft:raw_gold":9,"herbsandharvest:lime_fruit_sapling":1,"everythingcopper:copper_helmet":2,"minecraft:moss_block":20,"herbsandharvest:broccoli_seeds":8,"minecraft:stone_hoe":1,"alltheores:fluorite":11,"minecraft:oak_planks":221,"merrymaking:christmas_music_disc_1":2,"mekanism:metallurgic_infuser":2,"herbsandharvest:green_pepper":14,"minecraft:cooked_porkchop":3,"minecraft:music_disc_13":1,"patchouli:guide_book":1,"minecraft:poisonous_potato":1,"minecraft:bamboo_raft":2,"minecraft:sand":641,"everythingcopper:copper_horse_armor":1,"ars_elemental:yellow_archwood_log":26,"minecraft:grass_block":4,"minecraft:dark_oak_log":684,"xycraft_world:xychorium_gem_dark":11,"mekanism:basic_control_circuit":8,"minecraft:iron_hoe":1,"alltheores:raw_nickel":8,"forbidden_arcanus:ender_pearl_fragment":10,"xycraft_world:xychorium_gem_light":7,"minecraft:spruce_fence_gate":1,"rootsclassic:blackcurrant":58,"minecraft:diamond":48,"herbsandharvest:green_bean":12,"minecraft:granite":98,"minecraft:spruce_sign":1,"minecraft:golden_horse_armor":2,"biomeswevegone:zelkova_log":2,"minecraft:oak_sapling":122,"modern_industrialization:wrench":2,"minecraft:charcoal":36,"alltheores:steel_ingot":6,"minecraft:crafting_table":9,"minecraft:coarse_dirt":2,"minecraft:clay_ball":36,"minecraft:chicken":3,"minecraft:ladder":10,"minecraft:leather_leggings":1,"minecraft:brown_mushroom":2,"herbsandharvest:raspberry":14,"ars_nouveau:blue_archwood_sapling":1,"mekanism:alloy_infused":5,"minecraft:diamond_horse_armor":1,"minecraft:shield":2,"rootsclassic:elderberry":64,"sushigocrafting:soy_seeds":2,"aether:leather_gloves":2,"rootsclassic:redcurrant":69,"minecraft:moss_carpet":4,"minecraft:iron_axe":1,"minecraft:compass":1,"pneumaticcraft:compressed_stone":39,"mekanism:energized_smelter":1,"reliquary:chelicerae":2,"modern_industrialization:guidebook":1,"irons_jewelry:necklace":1,"minecraft:jungle_sapling":1,"herbsandharvest:zucchini_seeds":34,"merrymaking:christmas_music_disc_2":3,"mekanism:structural_glass":4,"minecraft:sugar_cane":3,"minecraft:chest":27,"minecraft:birch_sapling":1,"sophisticatedstorage:iron_chest":12,"minecraft:egg":4,"minecraft:dark_oak_sapling":86,"minecraft:raw_iron":37,"ars_nouveau:purple_archwood_log":53,"herbsandharvest:cauliflower":17,"artifacts:charm_of_sinking":2,"alltheores:iridium_ingot":4,"minecraft:copper_ingot":191,"minecraft:spruce_fence":22,"ars_nouveau:purple_archwood_sapling":10,"sophisticatedstorage:copper_chest":2,"everythingcopper:copper_boots":2,"minecraft:carrot":222,"minecraft:redstone":484,"minecraft:dark_oak_planks":1,"minecraft:arrow":12,"powah:wrench":4,"xycraft_world:xychorium_gem_blue":8,"minecraft:beetroot_seeds":2,"minecraft:saddle":3,"minecraft:gold_ingot":5,"chipped:chisel":2,"minecraft:glow_berries":2,"minecraft:mutton":2,"minecraft:white_wool":4,"modern_industrialization:raw_antimony":2,"sushigocrafting:rice_seeds":3,"minecraft:leather":3,"minecraft:ochre_froglight":10,"sophisticatedstorage:basic_to_copper_tier_upgrade":1,"biomeswevegone:kovan_flower":2,"minecraft:oak_log":207,"minecraft:chainmail_chestplate":1,"pamhc2crops:juteitem":1,"minecraft:golden_boots":1,"productivebees:dark_oak_wood_nest":11,"minecraft:gunpowder":16,"farmersdelight:flint_knife":1,"livingthings:crab_shell":1,"minecraft:paper":78,"chipped:saw":2,"pamhc2crops:tealeafitem":2,"pamhc2crops:leekitem":2,"mekanism:enrichment_chamber":1,"minecraft:coast_armor_trim_smithing_template":10,"minecraft:pearlescent_froglight":10,"minecraft:apple":20,"minecraft:stone_bricks":116,"minecraft:stone_pickaxe":1,"modern_industrialization:antimony_ingot":13,"herbsandharvest:raspberry_seeds":27,"minecraft:cobweb":3,"minecraft:verdant_froglight":10,"minecraft:iron_sword":1,"minecraft:acacia_sapling":1,"forbidden_arcanus:darkstone":181,"minecraft:dark_oak_stairs":2,"pamhc2crops:zucchiniitem":1,"minecraft:book":20,"alltheores:steel_block":1,"powah:uraninite":7,"minecraft:dark_oak_slab":37,"modern_industrialization:raw_tungsten":9,"herbsandharvest:green_pepper_seeds":27,"ars_nouveau:red_archwood_sapling":1,"chipped:clear_leaded_glass":32,"minecraft:lava_bucket":1,"minecraft:gravel":182,"alltheores:uranium_ingot":90,"alltheores:raw_uranium":3,"minecraft:spruce_sapling":179,"herbsandharvest:green_bean_seeds":30,"silentgear:bort":2,"minecraft:white_bed":2,"minecraft:spruce_log":444,"minecraft:crying_obsidian":4,"minecraft:spider_eye":6,"herbsandharvest:turnip":30,"minecraft:leather_helmet":4,"mekanismgenerators:wind_generator":7,"minecraft:stone_axe":1,"mekanism:advanced_universal_cable":8,"chipped:large_diamond_leaded_glass":12,"herbsandharvest:broccoli":3,"minecraft:painting":1,"minecraft:cherry_sapling":1,"alltheores:raw_silver":3,"tombstone:magic_scroll":1,"herbsandharvest:zucchini":16,"minecraft:hopper":1,"alltheores:copper_ore_hammer":1,"minecraft:coal":257,"minecraft:ender_pearl":18},"minecraft:broken":{"minecraft:iron_shovel":5,"minecraft:stone_pickaxe":1,"minecraft:iron_axe":7,"minecraft:iron_pickaxe":9},"minecraft:crafted":{"minecraft:oak_planks":248,"ars_nouveau:archwood_planks":22,"minecraft:dark_oak_boat":1,"minecraft:cooked_chicken":3,"minecraft:stone":1045,"xycraft_world:xychorium_gem_green":2,"minecraft:lever":5,"minecraft:bookshelf":6,"minecraft:cooked_mutton":2,"alltheores:tin_ingot":2,"minecraft:dark_oak_door":2,"minecraft:glass":32,"minecraft:iron_boots":1,"alltheores:silver_ingot":19,"ars_nouveau:archwood_door":1,"minecraft:chest":8,"modern_industrialization:antimony_ingot":2,"xycraft_world:xychorium_gem_red":2,"minecraft:stone_bricks":319,"minecraft:item_frame":2,"minecraft:crafting_table":3,"minecraft:spruce_planks":8,"minecraft:wooden_pickaxe":1,"minecraft:iron_helmet":1,"minecraft:copper_ingot":118,"livingthings:cooked_crab":5,"alltheores:zinc_nugget":9,"mekanism:structural_glass":20,"minecraft:dark_oak_slab":93,"minecraft:iron_chestplate":1,"minecraft:charcoal":137,"minecraft:shield":2,"minecraft:stick":3,"minecraft:iron_ingot":69,"minecraft:iron_pickaxe":7,"minecraft:gold_ingot":4,"minecraft:redstone":8,"minecraft:coal":8,"alltheores:aluminum_ingot":10,"minecraft:furnace":4,"minecraft:bone_meal":3,"minecraft:spruce_fence":16,"mysticalagriculture:inferium_essence":2,"alltheores:uranium_ingot":2,"minecraft:leather":1,"minecraft:iron_axe":9,"minecraft:ladder":6,"minecraft:iron_hoe":1,"minecraft:stone_pickaxe":2,"alltheores:steel_ingot":8,"minecraft:dark_oak_planks":124,"create:andesite_alloy":37,"minecraft:dark_oak_stairs":15,"minecraft:enchanting_table":1,"sophisticatedstorage:basic_to_iron_tier_upgrade":6,"minecraft:stone_axe":1,"minecraft:air":4505,"mekanism:upgrade_speed":4,"minecraft:white_wool":5,"sophisticatedstorage:basic_to_copper_tier_upgrade":5,"mekanism:upgrade_energy":4,"minecraft:iron_shovel":6,"minecraft:iron_leggings":1,"minecraft:white_bed":2,"minecraft:andesite":16,"minecraft:torch":102,"forbidden_arcanus:arcane_crystal":1,"xycraft_world:xychorium_gem_light":4,"minecraft:lapis_lazuli":2},"minecraft:used":{"productivebees:dark_oak_wood_nest":11,"minecraft:iron_shovel":1275,"minecraft:ladder":26,"trashcans:item_trash_can":2,"minecraft:chest":50,"minecraft:shield":16,"minecraft:stone":1,"minecraft:iron_sword":208,"minecraft:carrot":61,"rootsclassic:redcurrant":2,"mekanism:basic_universal_cable":18,"minecraft:tuff":13,"herbsandharvest:pineapple":43,"mekanismgenerators:wind_generator":7,"minecraft:dark_oak_slab":596,"minecraft:stone_bricks":1305,"minecraft:item_frame":2,"minecraft:dark_oak_boat":1,"ars_nouveau:archwood_door":3,"ars_nouveau:sourceberry_bush":6,"minecraft:oak_sapling":72,"minecraft:furnace":7,"minecraft:potato":6,"rootsclassic:elderberry":15,"powah:magmator_starter":1,"minecraft:dark_oak_sapling":80,"minecraft:dark_oak_stairs":62,"minecraft:diorite":7,"actuallyadditions:worm":3,"minecraft:stone_pickaxe":114,"minecraft:oak_planks":1129,"herbsandharvest:pineapple_seeds":24,"minecraft:wooden_pickaxe":35,"herbsandharvest:peas":8,"minecraft:sand":7,"ars_nouveau:archwood_planks":32,"herbsandharvest:cauliflower_seeds":12,"ars_elemental:flashpine_pod":1,"minecraft:dark_oak_log":562,"mekanism:metallurgic_infuser":2,"sophisticatedstorage:basic_to_iron_tier_upgrade":4,"herbsandharvest:green_pepper":2,"trophymanager:trophy":21,"minecraft:gravel":2,"minecraft:andesite":2,"herbsandharvest:turnip_seeds":24,"minecraft:bone_meal":9,"mekanism:enrichment_chamber":1,"minecraft:enchanting_table":2,"herbsandharvest:zucchini_seeds":11,"minecraft:torch":374,"minecraft:dirt":411,"minecraft:granite":8,"minecraft:spruce_fence":69,"sophisticatedstorage:iron_barrel":2,"minecraft:iron_hoe":63,"minecraft:dark_oak_planks":80,"minecraft:bookshelf":8,"minecraft:iron_axe":1818,"herbsandharvest:green_bean_seeds":8,"herbsandharvest:raspberry_seeds":9,"livingthings:cooked_crab":2,"tombstone:gift":4,"minecraft:cooked_beef":15,"chipped:large_diamond_leaded_glass":13,"minecraft:cobbled_deepslate":85,"minecraft:brown_bed":3,"herbsandharvest:lime_fruit_sapling":2,"minecraft:water_bucket":2,"herbsandharvest:cauliflower":4,"herbsandharvest:broccoli_seeds":3,"minecraft:crafting_table":13,"minecraft:white_bed":4,"herbsandharvest:green_pepper_seeds":9,"minecraft:painting":1,"minecraft:spruce_log":2,"rootsclassic:blackcurrant":1,"minecraft:dark_oak_door":6,"herbsandharvest:broccoli":3,"minecraft:apple":16,"tombstone:tablet_of_assistance":1,"minecraft:iron_pickaxe":2272,"minecraft:map":2,"livingthings:crab":1,"mekanism:energized_smelter":1,"chipped:clear_leaded_glass":142,"minecraft:spruce_sapling":2,"sophisticatedstorage:copper_chest":2,"minecraft:stone_axe":20,"sophisticatedstorage:iron_chest":12,"minecraft:cobblestone":16,"herbsandharvest:zucchini":11},"minecraft:dropped":{"alltheores:raw_silver":1,"actuallyadditions:worm":3,"ars_nouveau:blue_archwood_sapling":1,"forbidden_arcanus:darkstone":32,"trashcans:item_trash_can":1,"powah:book":1,"powah:wrench":1,"rootsclassic:nightshade":26,"ars_nouveau:red_archwood_sapling":1,"minecraft:enchanted_book":1,"minecraft:wheat_seeds":2,"sushigocrafting:wasabi_seeds":1,"minecraft:amethyst_block":9,"minecraft:granite":18,"modern_industrialization:guidebook":1,"minecraft:cobweb":1,"ars_nouveau:worn_notebook":1,"pamhc2crops:tealeafitem":1,"minecraft:string":1,"minecraft:raw_copper":22,"sushigocrafting:rice_seeds":2,"biomeswevegone:kovan_flower":1,"minecraft:wooden_pickaxe":1,"bhc:red_heart":1},"minecraft:killed_by":{"minecraft:skeleton":1}},"DataVersion":3955} \ No newline at end of file diff --git a/backend/test/ftbquests.test.ts b/backend/test/ftbquests.test.ts new file mode 100644 index 0000000..6ba8a89 --- /dev/null +++ b/backend/test/ftbquests.test.ts @@ -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'); + }); +}); diff --git a/backend/test/helpers/app.ts b/backend/test/helpers/app.ts new file mode 100644 index 0000000..f1da69b --- /dev/null +++ b/backend/test/helpers/app.ts @@ -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 }; +} diff --git a/backend/test/helpers/db.ts b/backend/test/helpers/db.ts new file mode 100644 index 0000000..98e6658 --- /dev/null +++ b/backend/test/helpers/db.ts @@ -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 { + if (!pool) { + pool = createPool(process.env.TEST_DATABASE_URL!); + await migrate(pool); + } + return pool; +} + +export async function resetDb(p: Pool): Promise { + 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 { + await pool?.end(); + pool = undefined; +} diff --git a/backend/test/helpers/events.ts b/backend/test/helpers/events.ts new file mode 100644 index 0000000..f06964e --- /dev/null +++ b/backend/test/helpers/events.ts @@ -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 = Omit, 'id' | 'type' | 'ts'> & { ts?: number; id?: string }; + +/** Test factory: ev('player_join', { ...ALICE, ts }) */ +export function ev(type: T, payload: Payload): AtmEvent { + const { ts, id, ...rest } = payload as Payload; + 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; diff --git a/backend/test/helpers/sign.ts b/backend/test/helpers/sign.ts new file mode 100644 index 0000000..46d2b90 --- /dev/null +++ b/backend/test/helpers/sign.ts @@ -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; 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 = { + 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 }; +} diff --git a/backend/test/ingest.test.ts b/backend/test/ingest.test.ts new file mode 100644 index 0000000..5366d68 --- /dev/null +++ b/backend/test/ingest.test.ts @@ -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[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', + ); + }); +}); + diff --git a/backend/test/leaderboards.test.ts b/backend/test/leaderboards.test.ts new file mode 100644 index 0000000..2cad55d --- /dev/null +++ b/backend/test/leaderboards.test.ts @@ -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>, 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(); + }); +}); diff --git a/backend/test/live.test.ts b/backend/test/live.test.ts new file mode 100644 index 0000000..0ecc78b --- /dev/null +++ b/backend/test/live.test.ts @@ -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; +} + +/** Opens /api/live and collects parsed SSE messages until `until` returns true. */ +async function collect(until: (msgs: Sse[]) => boolean, headers: Record = {}, trigger?: () => Promise) { + 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]); + }); +}); diff --git a/backend/test/log.test.ts b/backend/test/log.test.ts new file mode 100644 index 0000000..18b85f3 --- /dev/null +++ b/backend/test/log.test.ts @@ -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/]: 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]); + }); +}); diff --git a/backend/test/projections.test.ts b/backend/test/projections.test.ts new file mode 100644 index 0000000..273e553 --- /dev/null +++ b/backend/test/projections.test.ts @@ -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[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'); + }); +}); diff --git a/backend/test/public-api.test.ts b/backend/test/public-api.test.ts new file mode 100644 index 0000000..76e8260 --- /dev/null +++ b/backend/test/public-api.test.ts @@ -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' }); + }); +}); diff --git a/backend/test/scheduler.test.ts b/backend/test/scheduler.test.ts new file mode 100644 index 0000000..765be81 --- /dev/null +++ b/backend/test/scheduler.test.ts @@ -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' }]); + }); +}); diff --git a/backend/test/snapshots.test.ts b/backend/test/snapshots.test.ts new file mode 100644 index 0000000..b781af9 --- /dev/null +++ b/backend/test/snapshots.test.ts @@ -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']); + }); +}); diff --git a/backend/test/snbt.test.ts b/backend/test/snbt.test.ts new file mode 100644 index 0000000..c672f5f --- /dev/null +++ b/backend/test/snbt.test.ts @@ -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); + } + }); +}); diff --git a/backend/test/vanilla.test.ts b/backend/test/vanilla.test.ts new file mode 100644 index 0000000..513091f --- /dev/null +++ b/backend/test/vanilla.test.ts @@ -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); + }); +}); diff --git a/backend/test/worker.test.ts b/backend/test/worker.test.ts new file mode 100644 index 0000000..5e9e0be --- /dev/null +++ b/backend/test/worker.test.ts @@ -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); + }); +}); diff --git a/backend/tsconfig.json b/backend/tsconfig.json new file mode 100644 index 0000000..1c88b13 --- /dev/null +++ b/backend/tsconfig.json @@ -0,0 +1 @@ +{ "extends": "../tsconfig.base.json", "include": ["src", "test"] } diff --git a/backend/tsup.config.ts b/backend/tsup.config.ts new file mode 100644 index 0000000..fccfd3e --- /dev/null +++ b/backend/tsup.config.ts @@ -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'], +}); diff --git a/backend/vitest.config.ts b/backend/vitest.config.ts new file mode 100644 index 0000000..89cc0ce --- /dev/null +++ b/backend/vitest.config.ts @@ -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', + }, + }, +}); diff --git a/deploy/.env.example b/deploy/.env.example new file mode 100644 index 0000000..4f8406a --- /dev/null +++ b/deploy/.env.example @@ -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 diff --git a/deploy/backup.sh b/deploy/backup.sh new file mode 100644 index 0000000..81e1bc7 --- /dev/null +++ b/deploy/backup.sh @@ -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 diff --git a/deploy/docker-compose.local.yml b/deploy/docker-compose.local.yml new file mode 100644 index 0000000..9048a9e --- /dev/null +++ b/deploy/docker-compose.local.yml @@ -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"] diff --git a/deploy/docker-compose.test.yml b/deploy/docker-compose.test.yml new file mode 100644 index 0000000..ae65789 --- /dev/null +++ b/deploy/docker-compose.test.yml @@ -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 diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml new file mode 100644 index 0000000..1bb44b9 --- /dev/null +++ b/deploy/docker-compose.yml @@ -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: diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..2f3a4e2 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -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-.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 +X-ATM-Server: +X-ATM-Timestamp: +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 | diff --git a/docs/DATA-SOURCES.md b/docs/DATA-SOURCES.md new file mode 100644 index 0000000..36a0ef8 --- /dev/null +++ b/docs/DATA-SOURCES.md @@ -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/.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 | diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md new file mode 100644 index 0000000..223f59f --- /dev/null +++ b/docs/OPERATIONS.md @@ -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 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 = "" + hmacSecret = "" + 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/.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. diff --git a/docs/superpowers/specs/2026-10-13-atm10-stats-design.md b/docs/superpowers/specs/2026-10-13-atm10-stats-design.md index adc9909..d685695 100644 --- a/docs/superpowers/specs/2026-10-13-atm10-stats-design.md +++ b/docs/superpowers/specs/2026-10-13-atm10-stats-design.md @@ -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). diff --git a/mod/.gitattributes b/mod/.gitattributes new file mode 100644 index 0000000..b7bbcc4 --- /dev/null +++ b/mod/.gitattributes @@ -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 diff --git a/mod/.github/workflows/build.yml b/mod/.github/workflows/build.yml new file mode 100644 index 0000000..63c3cde --- /dev/null +++ b/mod/.github/workflows/build.yml @@ -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 \ No newline at end of file diff --git a/mod/.gitignore b/mod/.gitignore new file mode 100644 index 0000000..3e72c7a --- /dev/null +++ b/mod/.gitignore @@ -0,0 +1,3 @@ +run/ +build/ +.gradle/ diff --git a/mod/README.md b/mod/README.md new file mode 100644 index 0000000..cdf5de1 --- /dev/null +++ b/mod/README.md @@ -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-.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 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 diff --git a/mod/build.gradle b/mod/build.gradle new file mode 100644 index 0000000..fde2ced --- /dev/null +++ b/mod/build.gradle @@ -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 diff --git a/mod/gradle.properties b/mod/gradle.properties new file mode 100644 index 0000000..d9c72f3 --- /dev/null +++ b/mod/gradle.properties @@ -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 diff --git a/mod/gradle/wrapper/gradle-wrapper.jar b/mod/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..1b33c55 Binary files /dev/null and b/mod/gradle/wrapper/gradle-wrapper.jar differ diff --git a/mod/gradle/wrapper/gradle-wrapper.properties b/mod/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..23449a2 --- /dev/null +++ b/mod/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/mod/gradlew b/mod/gradlew new file mode 100644 index 0000000..23d15a9 --- /dev/null +++ b/mod/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/mod/gradlew.bat b/mod/gradlew.bat new file mode 100644 index 0000000..db3a6ac --- /dev/null +++ b/mod/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/mod/settings.gradle b/mod/settings.gradle new file mode 100644 index 0000000..7a488e3 --- /dev/null +++ b/mod/settings.gradle @@ -0,0 +1,9 @@ +pluginManagement { + repositories { + gradlePluginPortal() + } +} + +plugins { + id 'org.gradle.toolchains.foojay-resolver-convention' version '1.0.0' +} diff --git a/mod/src/main/java/de/hahmann/atmstats/core/Backoff.java b/mod/src/main/java/de/hahmann/atmstats/core/Backoff.java new file mode 100644 index 0000000..40216e2 --- /dev/null +++ b/mod/src/main/java/de/hahmann/atmstats/core/Backoff.java @@ -0,0 +1,21 @@ +package de.hahmann.atmstats.core; + +import java.util.Random; + +/** Exponential backoff with jitter: delay in [d/2, d] where d = min(max, base * 2^attempt). */ +public final class Backoff { + private final long baseMs; + private final long maxMs; + + public Backoff(long baseMs, long maxMs) { + this.baseMs = baseMs; + this.maxMs = maxMs; + } + + public long delayMs(int attempt, Random rnd) { + long d = attempt >= 40 ? maxMs : Math.min(maxMs, baseMs << Math.max(0, attempt)); + if (d <= 0) d = maxMs; // overflow guard + long half = d / 2; + return half + (long) (rnd.nextDouble() * (d - half + 1)); + } +} diff --git a/mod/src/main/java/de/hahmann/atmstats/core/EventJson.java b/mod/src/main/java/de/hahmann/atmstats/core/EventJson.java new file mode 100644 index 0000000..085dfdc --- /dev/null +++ b/mod/src/main/java/de/hahmann/atmstats/core/EventJson.java @@ -0,0 +1,19 @@ +package de.hahmann.atmstats.core; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonObject; +import de.hahmann.atmstats.core.Events.AtmEvent; + +/** Serializes protocol events: the record's components plus {@code type}; null fields are written explicitly. */ +public final class EventJson { + private static final Gson GSON = new GsonBuilder().serializeNulls().disableHtmlEscaping().create(); + + private EventJson() {} + + public static String toJson(AtmEvent event) { + JsonObject obj = GSON.toJsonTree(event).getAsJsonObject(); + obj.addProperty("type", event.type()); + return GSON.toJson(obj); + } +} diff --git a/mod/src/main/java/de/hahmann/atmstats/core/Events.java b/mod/src/main/java/de/hahmann/atmstats/core/Events.java new file mode 100644 index 0000000..41efee9 --- /dev/null +++ b/mod/src/main/java/de/hahmann/atmstats/core/Events.java @@ -0,0 +1,73 @@ +package de.hahmann.atmstats.core; + +import java.util.List; +import java.util.Map; +import java.util.UUID; + +/** + * Protocol v1 events. Field names are the JSON names; they must match shared/src/protocol.ts + * (verified by EventJsonTest against shared/fixtures/protocol). + */ +public final class Events { + private Events() {} + + public interface AtmEvent { + UUID id(); + + long ts(); + + String type(); + } + + public record ModInfo(String id, String version, String name) {} + + public record ServerStart(UUID id, long ts, String mcVersion, String neoforgeVersion, String javaVersion, List mods) + implements AtmEvent { + public String type() { return "server_start"; } + } + + public record ServerStop(UUID id, long ts) implements AtmEvent { + public String type() { return "server_stop"; } + } + + public record PlayerJoin(UUID id, long ts, String uuid, String name) implements AtmEvent { + public String type() { return "player_join"; } + } + + public record PlayerLeave(UUID id, long ts, String uuid, String name) implements AtmEvent { + public String type() { return "player_leave"; } + } + + public record PlayerDeath(UUID id, long ts, String uuid, String name, String dimension, String cause, String killerType, + String killerName) implements AtmEvent { + public String type() { return "player_death"; } + } + + public record EntityKill(UUID id, long ts, String uuid, String name, String dimension, String victimType, boolean isBoss, + String weapon) implements AtmEvent { + public String type() { return "entity_kill"; } + } + + public record Advancement(UUID id, long ts, String uuid, String name, String advancementId, String title) implements AtmEvent { + public String type() { return "advancement"; } + } + + public record DimensionChange(UUID id, long ts, String uuid, String name, String from, String to) implements AtmEvent { + public String type() { return "dimension_change"; } + } + + /** Absolute counter values; {@code full} = all counters of the player, otherwise only changed ones. */ + public record StatsSnapshot(UUID id, long ts, String uuid, String name, boolean full, Map> stats) + implements AtmEvent { + public String type() { return "stats_snapshot"; } + } + + public record FileSync(UUID id, long ts, String path, String sha256, String content) implements AtmEvent { + public String type() { return "file_sync"; } + } + + public record Health(UUID id, long ts, long windowStart, long windowEnd, double msptAvg, double msptP95, double msptMax, + double tps, int players, int chunks, double heapUsedMb, double heapMaxMb) implements AtmEvent { + public String type() { return "health"; } + } +} diff --git a/mod/src/main/java/de/hahmann/atmstats/core/FileChangeTracker.java b/mod/src/main/java/de/hahmann/atmstats/core/FileChangeTracker.java new file mode 100644 index 0000000..ae62e0e --- /dev/null +++ b/mod/src/main/java/de/hahmann/atmstats/core/FileChangeTracker.java @@ -0,0 +1,37 @@ +package de.hahmann.atmstats.core; + +import java.util.Collection; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; + +/** + * Remembers size, mtime and content hash per file so unchanged files are neither read nor sent again. + * Used by the worker thread only. + */ +public final class FileChangeTracker { + private record State(long size, long mtime, String sha256) {} + + private final Map states = new HashMap<>(); + + /** True when size or mtime differ from what was last committed (or the file is new). */ + public boolean needsRead(String path, long size, long mtime) { + State s = states.get(path); + return s == null || s.size != size || s.mtime != mtime; + } + + /** Records the file's current state; true when its content differs from the last committed content. */ + public boolean commit(String path, long size, long mtime, String sha256) { + State prev = states.put(path, new State(size, mtime, sha256)); + return prev == null || !prev.sha256.equals(sha256); + } + + /** Drops state of files that no longer exist. */ + public void retainOnly(Collection existing) { + states.keySet().retainAll(new HashSet<>(existing)); + } + + public int size() { + return states.size(); + } +} diff --git a/mod/src/main/java/de/hahmann/atmstats/core/HealthWindow.java b/mod/src/main/java/de/hahmann/atmstats/core/HealthWindow.java new file mode 100644 index 0000000..15ee745 --- /dev/null +++ b/mod/src/main/java/de/hahmann/atmstats/core/HealthWindow.java @@ -0,0 +1,44 @@ +package de.hahmann.atmstats.core; + +import java.util.Arrays; + +/** + * Aggregates tick durations of one reporting window (avg, p95, max). O(1) per tick; the p95 uses the most recent + * {@code capacity} samples. Server thread only. + */ +public final class HealthWindow { + public record Stats(int ticks, double msptAvg, double msptP95, double msptMax, double tps) {} + + private final long[] samples; + private int next; + private int stored; + private long sumNanos; + private long maxNanos; + private int ticks; + + public HealthWindow(int capacity) { + this.samples = new long[capacity]; + } + + public void record(long tickNanos) { + samples[next] = tickNanos; + next = (next + 1) % samples.length; + stored = Math.min(stored + 1, samples.length); + sumNanos += tickNanos; + maxNanos = Math.max(maxNanos, tickNanos); + ticks++; + } + + /** Returns the window's statistics and starts a new window. */ + public Stats drain() { + if (ticks == 0) return new Stats(0, 0, 0, 0, 0); + long[] sorted = Arrays.copyOf(samples, stored); + Arrays.sort(sorted); + double p95 = sorted[Math.max(0, (int) Math.ceil(0.95 * stored) - 1)] / 1e6; + double avg = sumNanos / 1e6 / ticks; + Stats s = new Stats(ticks, avg, p95, maxNanos / 1e6, Math.min(20.0, 1000.0 / Math.max(avg, 1e-9))); + next = stored = ticks = 0; + sumNanos = maxNanos = 0; + return s; + } +} diff --git a/mod/src/main/java/de/hahmann/atmstats/core/HttpIngestClient.java b/mod/src/main/java/de/hahmann/atmstats/core/HttpIngestClient.java new file mode 100644 index 0000000..0b3b26c --- /dev/null +++ b/mod/src/main/java/de/hahmann/atmstats/core/HttpIngestClient.java @@ -0,0 +1,104 @@ +package de.hahmann.atmstats.core; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +/** HTTPS transport (Java 11 HttpClient, default certificate validation). */ +public final class HttpIngestClient implements IngestClient { + private final HttpClient http; + private final URI base; + private final String token; + private final String serverId; + private final byte[] secret; + private final Duration timeout; + + public HttpIngestClient(URI base, String token, String serverId, byte[] secret, Duration timeout) { + this.base = base; + this.token = token; + this.serverId = serverId; + this.secret = secret; + this.timeout = timeout; + this.http = HttpClient.newBuilder().version(HttpClient.Version.HTTP_2).connectTimeout(timeout).build(); + } + + @Override + public Response send(byte[] gzBody, long ts, String signature) throws IOException { + HttpRequest req = HttpRequest.newBuilder(base.resolve("/api/ingest")) + .timeout(timeout) + .header("Authorization", "Bearer " + token) + .header("Content-Type", "application/json") + .header("Content-Encoding", "gzip") + .header("X-ATM-Server", serverId) + .header("X-ATM-Timestamp", Long.toString(ts)) + .header("X-ATM-Signature", signature) + .POST(HttpRequest.BodyPublishers.ofByteArray(gzBody)) + .build(); + HttpResponse res = exchange(req); + return parse(res.statusCode(), res.body()); + } + + /** Startup self-test: returns "reachable" or a human-readable reason. */ + public String ping(long now) { + try { + HttpRequest req = HttpRequest.newBuilder(base.resolve("/api/ingest/ping")) + .timeout(timeout) + .header("Authorization", "Bearer " + token) + .header("X-ATM-Timestamp", Long.toString(now)) + .header("X-ATM-Signature", Signer.sign(secret, now, new byte[0])) + .GET() + .build(); + HttpResponse res = exchange(req); + if (res.statusCode() == 200) return "reachable"; + Response r = parse(res.statusCode(), res.body()); + return "HTTP " + res.statusCode() + (r.error() != null ? " " + r.error() : ""); + } catch (IOException e) { + return e.toString(); + } + } + + private HttpResponse exchange(HttpRequest req) throws IOException { + try { + return http.send(req, HttpResponse.BodyHandlers.ofString()); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException("interrupted", e); + } + } + + static Response parse(int status, String body) { + Set accepted = new HashSet<>(); + Set duplicate = new HashSet<>(); + Map rejected = new HashMap<>(); + String error = null; + try { + JsonElement el = JsonParser.parseString(body == null || body.isEmpty() ? "{}" : body); + if (el.isJsonObject()) { + JsonObject o = el.getAsJsonObject(); + if (o.has("accepted")) o.getAsJsonArray("accepted").forEach(e -> accepted.add(e.getAsString())); + if (o.has("duplicate")) o.getAsJsonArray("duplicate").forEach(e -> duplicate.add(e.getAsString())); + if (o.has("rejected")) { + o.getAsJsonArray("rejected").forEach(e -> { + JsonObject r = e.getAsJsonObject(); + String id = r.has("id") && !r.get("id").isJsonNull() ? r.get("id").getAsString() : "?"; + rejected.put(id, r.has("reason") ? r.get("reason").getAsString() : ""); + }); + } + if (o.has("error")) error = o.get("error").getAsString(); + } + } catch (RuntimeException ignored) { + // non-JSON answer (e.g. proxy error page) + } + return new Response(status, accepted, duplicate, rejected, error); + } +} diff --git a/mod/src/main/java/de/hahmann/atmstats/core/IngestClient.java b/mod/src/main/java/de/hahmann/atmstats/core/IngestClient.java new file mode 100644 index 0000000..27af673 --- /dev/null +++ b/mod/src/main/java/de/hahmann/atmstats/core/IngestClient.java @@ -0,0 +1,15 @@ +package de.hahmann.atmstats.core; + +import java.io.IOException; +import java.util.Map; +import java.util.Set; + +public interface IngestClient { + /** POST /api/ingest with a gzipped protocol batch. */ + Response send(byte[] gzBody, long ts, String signature) throws IOException; + + /** + * @param error backend error code for non-2xx answers (e.g. {@code timestamp_out_of_range}), else null + */ + record Response(int status, Set accepted, Set duplicate, Map rejected, String error) {} +} diff --git a/mod/src/main/java/de/hahmann/atmstats/core/LogSink.java b/mod/src/main/java/de/hahmann/atmstats/core/LogSink.java new file mode 100644 index 0000000..3acfb5d --- /dev/null +++ b/mod/src/main/java/de/hahmann/atmstats/core/LogSink.java @@ -0,0 +1,8 @@ +package de.hahmann.atmstats.core; + +/** Minimal logging seam so the core does not depend on the game's logging setup. */ +public interface LogSink { + void info(String msg); + + void warn(String msg); +} diff --git a/mod/src/main/java/de/hahmann/atmstats/core/OutboxJournal.java b/mod/src/main/java/de/hahmann/atmstats/core/OutboxJournal.java new file mode 100644 index 0000000..12e019a --- /dev/null +++ b/mod/src/main/java/de/hahmann/atmstats/core/OutboxJournal.java @@ -0,0 +1,199 @@ +package de.hahmann.atmstats.core; + +import java.io.IOException; +import java.nio.channels.FileChannel; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; +import java.nio.file.attribute.FileTime; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.stream.Stream; + +/** + * Durable outbox on disk. Events are appended as JSON lines to {@code current.jsonl}; full segments are rotated + * to {@code seg-.jsonl} and deleted only after the backend acknowledged them. A segment is one ingest batch. + * Not thread-safe: used by the single worker thread only. + */ +public final class OutboxJournal { + public static final int MAX_LINES = 500; + public static final long MAX_SEGMENT_BYTES = 256 * 1024; + private static final String CURRENT = "current.jsonl"; + private static final String SNAPSHOT_MARKER = "\"type\":\"stats_snapshot\""; + + public record Segment(Path file, List lines) {} + + private final Path dir; + private final long maxBytes; + private final Duration maxAge; + private long nextSeq; + private int currentLines; + private long currentBytes; + + public OutboxJournal(Path dir, long maxBytes, Duration maxAge) throws IOException { + this.dir = dir; + this.maxBytes = maxBytes; + this.maxAge = maxAge; + Files.createDirectories(dir); + nextSeq = closedSegments().stream().mapToLong(OutboxJournal::seqOf).max().orElse(0) + 1; + Path current = dir.resolve(CURRENT); + if (Files.exists(current)) { + byte[] data = Files.readAllBytes(current); + int end = lastNewline(data); + if (end + 1 != data.length) { + // A crash mid-write leaves a partial last line; drop it. + try (FileChannel ch = FileChannel.open(current, StandardOpenOption.WRITE)) { + ch.truncate(end + 1L); + } + } + List lines = readLines(current); + currentLines = lines.size(); + currentBytes = end + 1L; + } + } + + /** Appends lines durably-enough (flushed, fsync on rotation). Returns the number of lines evicted by limits. */ + public int append(List lines) throws IOException { + Path current = dir.resolve(CURRENT); + StringBuilder pending = new StringBuilder(); + for (String line : lines) { + long len = line.getBytes(StandardCharsets.UTF_8).length + 1L; + if (currentLines > 0 && (currentLines >= MAX_LINES || currentBytes + len > MAX_SEGMENT_BYTES)) { + write(current, pending); + pending.setLength(0); + rotate(); + } + pending.append(line).append('\n'); + currentLines++; + currentBytes += len; + } + write(current, pending); + return enforceLimits(); + } + + /** The oldest closed segment; rotates the current one first when nothing else is waiting. */ + public Optional nextSendable() throws IOException { + List closed = closedSegments(); + if (closed.isEmpty() && currentLines > 0) { + rotate(); + closed = closedSegments(); + } + if (closed.isEmpty()) return Optional.empty(); + Path file = closed.get(0); + return Optional.of(new Segment(file, readLines(file))); + } + + public void ack(Segment segment) throws IOException { + Files.deleteIfExists(segment.file()); + } + + /** Moves a segment the backend refused permanently to rejected/ together with the reason. */ + public void reject(Segment segment, String reason) throws IOException { + Path rejected = dir.resolve("rejected"); + Files.createDirectories(rejected); + Path target = rejected.resolve(segment.file().getFileName()); + Files.move(segment.file(), target, StandardCopyOption.REPLACE_EXISTING); + Files.writeString(rejected.resolve(segment.file().getFileName() + ".reason.txt"), reason, StandardCharsets.UTF_8); + } + + /** Keeps single events the backend refused (while the rest of their batch was accepted) for inspection. */ + public void rejectLines(List lines, String reason) throws IOException { + Path rejected = dir.resolve("rejected"); + Files.createDirectories(rejected); + String name = "events-" + System.currentTimeMillis() + "-" + Integer.toHexString(lines.hashCode()); + Files.writeString(rejected.resolve(name + ".jsonl"), String.join("\n", lines) + "\n", StandardCharsets.UTF_8); + Files.writeString(rejected.resolve(name + ".reason.txt"), reason, StandardCharsets.UTF_8); + } + + public long totalBytes() throws IOException { + long total = currentBytes; + for (Path p : closedSegments()) total += Files.size(p); + return total; + } + + public int pendingSegments() throws IOException { + return closedSegments().size() + (currentLines > 0 ? 1 : 0); + } + + // --- internals --- + + private void rotate() throws IOException { + Path current = dir.resolve(CURRENT); + if (currentLines == 0 || !Files.exists(current)) return; + try (FileChannel ch = FileChannel.open(current, StandardOpenOption.WRITE)) { + ch.force(true); + } + Files.move(current, dir.resolve(String.format("seg-%020d.jsonl", nextSeq++)), StandardCopyOption.ATOMIC_MOVE); + currentLines = 0; + currentBytes = 0; + } + + /** + * Over the size or age limit, stats snapshots go first (a later snapshot supersedes them), oldest segments first; + * only then whole segments are dropped. + */ + private int enforceLimits() throws IOException { + int dropped = 0; + Instant oldest = Instant.now().minus(maxAge); + for (Path p : closedSegments()) { + if (Files.getLastModifiedTime(p).toInstant().isBefore(oldest)) { + dropped += readLines(p).size(); + Files.delete(p); + } + } + if (totalBytes() <= maxBytes) return dropped; + rotate(); + for (Path p : closedSegments()) { + if (totalBytes() <= maxBytes) return dropped; + List lines = readLines(p); + List kept = lines.stream().filter(l -> !l.contains(SNAPSHOT_MARKER)).toList(); + if (kept.size() == lines.size()) continue; + dropped += lines.size() - kept.size(); + FileTime mtime = Files.getLastModifiedTime(p); + if (kept.isEmpty()) { + Files.delete(p); + } else { + Files.writeString(p, String.join("\n", kept) + "\n", StandardCharsets.UTF_8); + Files.setLastModifiedTime(p, mtime); + } + } + for (Path p : closedSegments()) { + if (totalBytes() <= maxBytes) break; + dropped += readLines(p).size(); + Files.delete(p); + } + return dropped; + } + + private void write(Path current, CharSequence text) throws IOException { + if (text.isEmpty()) return; + Files.writeString(current, text, StandardCharsets.UTF_8, StandardOpenOption.CREATE, StandardOpenOption.APPEND); + } + + private List closedSegments() throws IOException { + try (Stream files = Files.list(dir)) { + return files.filter(p -> p.getFileName().toString().matches("seg-\\d{20}\\.jsonl")).sorted().toList(); + } + } + + private static long seqOf(Path p) { + String n = p.getFileName().toString(); + return Long.parseLong(n.substring(4, 24)); + } + + private static List readLines(Path p) throws IOException { + List out = new ArrayList<>(); + for (String l : Files.readAllLines(p, StandardCharsets.UTF_8)) if (!l.isBlank()) out.add(l); + return out; + } + + private static int lastNewline(byte[] data) { + for (int i = data.length - 1; i >= 0; i--) if (data[i] == '\n') return i; + return -1; + } +} diff --git a/mod/src/main/java/de/hahmann/atmstats/core/Shipper.java b/mod/src/main/java/de/hahmann/atmstats/core/Shipper.java new file mode 100644 index 0000000..e3579ee --- /dev/null +++ b/mod/src/main/java/de/hahmann/atmstats/core/Shipper.java @@ -0,0 +1,145 @@ +package de.hahmann.atmstats.core; + +import com.google.gson.JsonElement; +import com.google.gson.JsonParser; +import de.hahmann.atmstats.core.Events.AtmEvent; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.Queue; +import java.util.Random; +import java.util.function.LongSupplier; +import java.util.zip.GZIPOutputStream; + +/** + * Moves events from the in-memory queue into the durable journal and sends one journal segment per call. + * Runs on the mod's worker thread only. Returns how long the caller should wait before the next call. + */ +public final class Shipper { + public static final long IDLE_MS = 2000; + + private final Queue queue; + private final OutboxJournal journal; + private final IngestClient client; + private final byte[] secret; + private final Backoff backoff; + private final LongSupplier clock; + private final LogSink log; + private final Random random = new Random(); + private int attempt; + private boolean clockSkewLogged; + + public Shipper(Queue queue, OutboxJournal journal, IngestClient client, byte[] secret, Backoff backoff, + LongSupplier clock, LogSink log) { + this.queue = queue; + this.journal = journal; + this.client = client; + this.secret = secret; + this.backoff = backoff; + this.clock = clock; + this.log = log; + } + + /** Persists everything still queued; used on shutdown and before each send. On failure the events stay queued. */ + public void flushToJournal() throws IOException { + List events = new ArrayList<>(); + AtmEvent e; + while ((e = queue.poll()) != null) events.add(e); + if (events.isEmpty()) return; + List lines = new ArrayList<>(events.size()); + for (AtmEvent ev : events) lines.add(EventJson.toJson(ev)); + int dropped; + try { + dropped = journal.append(lines); + } catch (IOException | RuntimeException ex) { + queue.addAll(events); + throw ex; + } + if (dropped > 0) log.warn("atmstats: outbox limit reached, dropped " + dropped + " old entries (stats snapshots first)"); + } + + public long runOnce() throws IOException { + flushToJournal(); + var next = journal.nextSendable(); + if (next.isEmpty()) return IDLE_MS; + OutboxJournal.Segment segment = next.get(); + + byte[] body = gzip("{\"protocol\":1,\"events\":[" + String.join(",", segment.lines()) + "]}"); + long ts = clock.getAsLong(); + IngestClient.Response res; + try { + res = client.send(body, ts, Signer.sign(secret, ts, body)); + } catch (IOException e) { + return retry("backend not reachable: " + e); + } + + int status = res.status(); + if (status == 200) { + // Delete the segment only when the backend accounted for every event in it. + Set confirmed = new HashSet<>(res.accepted()); + confirmed.addAll(res.duplicate()); + confirmed.addAll(res.rejected().keySet()); + List rejectedLines = new ArrayList<>(); + for (String line : segment.lines()) { + String id = idOf(line); + if (id == null || !confirmed.contains(id)) return retry("backend did not confirm all events of the batch (unexpected response)"); + if (res.rejected().containsKey(id)) rejectedLines.add(line); + } + if (!rejectedLines.isEmpty()) { + journal.rejectLines(rejectedLines, res.rejected().toString()); + log.warn("atmstats: backend rejected " + rejectedLines.size() + " invalid event(s), kept in outbox/rejected: " + res.rejected()); + } + journal.ack(segment); + attempt = 0; + clockSkewLogged = false; + return 0; + } + if (status == 401 && "timestamp_out_of_range".equals(res.error())) { + if (!clockSkewLogged) { + log.warn("atmstats: clock skew: server clock differs from backend by more than 5 minutes; check the system time"); + clockSkewLogged = true; + } + return retry(null); + } + if (status == 401 || status == 403) { + return retry("authentication failed (" + res.error() + "): check token and hmacSecret in config/atmstats-server.toml"); + } + if (status == 408 || status == 429 || status >= 500) { + return retry("backend answered HTTP " + status); + } + // Other 4xx: the batch itself is unacceptable; retrying would block the outbox forever. + journal.reject(segment, "HTTP " + status + " " + res.error()); + log.warn("atmstats: batch rejected by backend (HTTP " + status + ", " + res.error() + "), moved to outbox/rejected"); + attempt = 0; + return 0; + } + + private long retry(String reason) { + long delay = backoff.delayMs(attempt++, random); + if (reason != null && (attempt == 1 || attempt % 10 == 0)) { + log.warn("atmstats: " + reason + " – retrying in " + delay / 1000 + " s (attempt " + attempt + ")"); + } + return delay; + } + + private static String idOf(String line) { + try { + JsonElement id = JsonParser.parseString(line).getAsJsonObject().get("id"); + return id == null || id.isJsonNull() ? null : id.getAsString(); + } catch (RuntimeException e) { + return null; + } + } + + private static byte[] gzip(String s) throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + try (GZIPOutputStream gz = new GZIPOutputStream(out)) { + gz.write(s.getBytes(StandardCharsets.UTF_8)); + } + return out.toByteArray(); + } +} diff --git a/mod/src/main/java/de/hahmann/atmstats/core/Signer.java b/mod/src/main/java/de/hahmann/atmstats/core/Signer.java new file mode 100644 index 0000000..51a7d18 --- /dev/null +++ b/mod/src/main/java/de/hahmann/atmstats/core/Signer.java @@ -0,0 +1,33 @@ +package de.hahmann.atmstats.core; + +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.security.MessageDigest; +import java.util.HexFormat; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; + +/** Request signature: hex(HMAC-SHA256(secret, timestamp + "." + hex(sha256(body)))), identical to the backend. */ +public final class Signer { + private Signer() {} + + public static String sign(byte[] secret, long timestampMs, byte[] body) { + try { + String bodyHash = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(body)); + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(secret, "HmacSHA256")); + byte[] sig = mac.doFinal((timestampMs + "." + bodyHash).getBytes(StandardCharsets.UTF_8)); + return HexFormat.of().formatHex(sig); + } catch (GeneralSecurityException e) { + throw new IllegalStateException("HmacSHA256 unavailable", e); + } + } + + public static String sha256Hex(byte[] data) { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(data)); + } catch (GeneralSecurityException e) { + throw new IllegalStateException("SHA-256 unavailable", e); + } + } +} diff --git a/mod/src/main/java/de/hahmann/atmstats/core/StatsDiff.java b/mod/src/main/java/de/hahmann/atmstats/core/StatsDiff.java new file mode 100644 index 0000000..eab7988 --- /dev/null +++ b/mod/src/main/java/de/hahmann/atmstats/core/StatsDiff.java @@ -0,0 +1,36 @@ +package de.hahmann.atmstats.core; + +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Tracks the last counters sent per player. The first snapshot after start (or after {@link #forget}) is full; + * later ones contain only changed counters. Values are always absolute. Worker thread only. + */ +public final class StatsDiff { + public record Result(boolean full, Map> changed) {} + + private final Map>> lastSent = new HashMap<>(); + + public Result apply(String player, Map> current) { + Map> prev = lastSent.get(player); + Map> copy = new HashMap<>(); + current.forEach((cat, m) -> copy.put(cat, new HashMap<>(m))); + lastSent.put(player, copy); + if (prev == null) return new Result(true, current); + + Map> changed = new LinkedHashMap<>(); + current.forEach((cat, entries) -> { + Map before = prev.getOrDefault(cat, Map.of()); + entries.forEach((key, value) -> { + if (!value.equals(before.get(key))) changed.computeIfAbsent(cat, c -> new LinkedHashMap<>()).put(key, value); + }); + }); + return new Result(false, changed); + } + + public void forget(String player) { + lastSent.remove(player); + } +} diff --git a/mod/src/main/java/de/hahmann/atmstats/core/SyncPaths.java b/mod/src/main/java/de/hahmann/atmstats/core/SyncPaths.java new file mode 100644 index 0000000..c3791b1 --- /dev/null +++ b/mod/src/main/java/de/hahmann/atmstats/core/SyncPaths.java @@ -0,0 +1,23 @@ +package de.hahmann.atmstats.core; + +import java.util.regex.Pattern; + +/** + * Which FTB files are forwarded as file_sync (paths relative to the server, "/"-separated). Mirrors the backend's + * isQuestConfigFile: chapters and the English texts split by ftbquestslangsplitter; other languages (~35 MB), + * reward tables and the combined lang/en_us.snbt are not sent. + */ +public final class SyncPaths { + private static final Pattern WANTED = Pattern.compile( + "world/ftbquests/[0-9a-f-]{32,36}\\.snbt" + + "|world/ftbteams/(party|player)/[0-9a-f-]{32,36}\\.snbt" + + "|config/ftbquests/quests/chapters/[^/]+\\.snbt" + + "|config/ftbquests/quests/lang/en_us/.+\\.snbt_merged", + Pattern.CASE_INSENSITIVE); + + private SyncPaths() {} + + public static boolean isWanted(String relativePath) { + return WANTED.matcher(relativePath).matches(); + } +} diff --git a/mod/src/main/java/de/hahmann/atmstats/core/UuidV7.java b/mod/src/main/java/de/hahmann/atmstats/core/UuidV7.java new file mode 100644 index 0000000..600c10d --- /dev/null +++ b/mod/src/main/java/de/hahmann/atmstats/core/UuidV7.java @@ -0,0 +1,30 @@ +package de.hahmann.atmstats.core; + +import java.security.SecureRandom; +import java.util.UUID; + +/** Time-ordered UUID version 7 (RFC 9562); monotonic within this process via a 12-bit counter per millisecond. */ +public final class UuidV7 { + private static final SecureRandom RANDOM = new SecureRandom(); + private static long lastMs = -1; + private static int counter; + + private UuidV7() {} + + public static synchronized UUID next() { + long ms = System.currentTimeMillis(); + if (ms <= lastMs) { + ms = lastMs; + if (++counter > 0xFFF) { + ms++; + counter = 0; + } + } else { + counter = RANDOM.nextInt(0x800); // leave headroom for increments + } + lastMs = ms; + long msb = (ms << 16) | (0x7L << 12) | counter; + long lsb = (RANDOM.nextLong() & 0x3FFFFFFFFFFFFFFFL) | 0x8000000000000000L; + return new UUID(msb, lsb); + } +} diff --git a/mod/src/main/java/de/hahmann/atmstats/mc/AtmConfig.java b/mod/src/main/java/de/hahmann/atmstats/mc/AtmConfig.java new file mode 100644 index 0000000..5ac24b0 --- /dev/null +++ b/mod/src/main/java/de/hahmann/atmstats/mc/AtmConfig.java @@ -0,0 +1,39 @@ +package de.hahmann.atmstats.mc; + +import net.neoforged.neoforge.common.ModConfigSpec; + +/** config/atmstats-server.toml */ +public final class AtmConfig { + public static final ModConfigSpec SPEC; + public static final ModConfigSpec.BooleanValue ENABLED; + public static final ModConfigSpec.ConfigValue ENDPOINT; + public static final ModConfigSpec.ConfigValue SERVER_ID; + public static final ModConfigSpec.ConfigValue TOKEN; + public static final ModConfigSpec.ConfigValue HMAC_SECRET; + public static final ModConfigSpec.IntValue STATS_SNAPSHOT_MINUTES; + public static final ModConfigSpec.IntValue QUEST_SCAN_MINUTES; + public static final ModConfigSpec.IntValue HEALTH_INTERVAL_SECONDS; + public static final ModConfigSpec.IntValue MAX_OUTBOX_MB; + + static { + ModConfigSpec.Builder b = new ModConfigSpec.Builder(); + b.comment("ATM Stats: sendet Statistiken an das Statistikportal").push("atmstats"); + ENABLED = b.comment("Erfassung aktivieren").define("enabled", true); + ENDPOINT = b.comment("Basis-URL des Portals, z. B. https://stats.example.de").define("endpoint", ""); + SERVER_ID = b.comment("Kennung dieses Servers").define("serverId", "atm10"); + TOKEN = b.comment("Ingest-Token (Backend: npm run cli -- token:create)").define("token", ""); + HMAC_SECRET = b.comment("Gemeinsames Geheimnis für die Signatur (INGEST_HMAC_SECRET im Backend)").define("hmacSecret", ""); + STATS_SNAPSHOT_MINUTES = b.comment("Intervall für Statistik-Snapshots online Spieler").defineInRange("statsSnapshotMinutes", 5, 1, 60); + QUEST_SCAN_MINUTES = b.comment("Intervall für die Prüfung der FTB-Quest-Dateien").defineInRange("questScanMinutes", 2, 1, 60); + HEALTH_INTERVAL_SECONDS = b.comment("Intervall für TPS/MSPT-Meldungen").defineInRange("healthIntervalSeconds", 60, 10, 600); + MAX_OUTBOX_MB = b.comment("Maximale Größe der Warteschlange auf der Festplatte").defineInRange("maxOutboxMb", 50, 1, 1000); + b.pop(); + SPEC = b.build(); + } + + private AtmConfig() {} + + static boolean isConfigured() { + return ENABLED.get() && !ENDPOINT.get().isBlank() && !TOKEN.get().isBlank() && !HMAC_SECRET.get().isBlank(); + } +} diff --git a/mod/src/main/java/de/hahmann/atmstats/mc/AtmStatsMod.java b/mod/src/main/java/de/hahmann/atmstats/mc/AtmStatsMod.java new file mode 100644 index 0000000..6893ac5 --- /dev/null +++ b/mod/src/main/java/de/hahmann/atmstats/mc/AtmStatsMod.java @@ -0,0 +1,23 @@ +package de.hahmann.atmstats.mc; + +import com.mojang.logging.LogUtils; +import net.neoforged.fml.ModContainer; +import net.neoforged.fml.common.Mod; +import net.neoforged.fml.config.ModConfig; +import net.neoforged.fml.loading.FMLEnvironment; +import net.neoforged.neoforge.common.NeoForge; + +/** Server-only statistics collector for the ATM10 stats portal. Registers nothing in the game registries. */ +@Mod(AtmStatsMod.MOD_ID) +public final class AtmStatsMod { + public static final String MOD_ID = "atmstats"; + + public AtmStatsMod(ModContainer container) { + container.registerConfig(ModConfig.Type.COMMON, AtmConfig.SPEC, "atmstats-server.toml"); + if (FMLEnvironment.dist.isDedicatedServer()) { + NeoForge.EVENT_BUS.register(new GameHooks()); + } else { + LogUtils.getLogger().info("atmstats: client/integrated server detected, collector disabled"); + } + } +} diff --git a/mod/src/main/java/de/hahmann/atmstats/mc/CollectorRuntime.java b/mod/src/main/java/de/hahmann/atmstats/mc/CollectorRuntime.java new file mode 100644 index 0000000..9783b8f --- /dev/null +++ b/mod/src/main/java/de/hahmann/atmstats/mc/CollectorRuntime.java @@ -0,0 +1,75 @@ +package de.hahmann.atmstats.mc; + +import com.mojang.logging.LogUtils; +import de.hahmann.atmstats.core.Backoff; +import de.hahmann.atmstats.core.Events; +import de.hahmann.atmstats.core.HttpIngestClient; +import de.hahmann.atmstats.core.LogSink; +import de.hahmann.atmstats.core.OutboxJournal; +import de.hahmann.atmstats.core.Shipper; +import java.io.IOException; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.time.Duration; +import java.util.Queue; +import java.util.concurrent.ConcurrentLinkedQueue; +import net.minecraft.server.MinecraftServer; +import net.minecraft.world.level.storage.LevelResource; +import net.neoforged.fml.loading.FMLPaths; +import org.slf4j.Logger; + +/** Wiring of one server run: queue → worker thread (stats conversion, file scan, journal, HTTPS). */ +final class CollectorRuntime { + private static final Logger LOG = LogUtils.getLogger(); + static final LogSink LOG_SINK = new LogSink() { + public void info(String msg) { LOG.info(msg); } + public void warn(String msg) { LOG.warn(msg); } + }; + + private final Queue queue = new ConcurrentLinkedQueue<>(); + private final StatsCollector stats; + private final HealthMonitor health; + private final WorkerThread worker; + + private CollectorRuntime(MinecraftServer server) throws IOException { + Path serverDir = server.getServerDirectory().toAbsolutePath().normalize(); + Path worldDir = server.getWorldPath(LevelResource.ROOT).toAbsolutePath().normalize(); + byte[] secret = AtmConfig.HMAC_SECRET.get().getBytes(StandardCharsets.UTF_8); + + OutboxJournal journal = new OutboxJournal(serverDir.resolve("atmstats").resolve("outbox"), + AtmConfig.MAX_OUTBOX_MB.get() * 1024L * 1024L, Duration.ofDays(7)); + HttpIngestClient client = new HttpIngestClient(URI.create(AtmConfig.ENDPOINT.get().replaceAll("/+$", "")), AtmConfig.TOKEN.get(), + AtmConfig.SERVER_ID.get(), secret, Duration.ofSeconds(10)); + Shipper shipper = new Shipper(queue, journal, client, secret, new Backoff(1000, 300_000), System::currentTimeMillis, LOG_SINK); + + stats = new StatsCollector(AtmConfig.STATS_SNAPSHOT_MINUTES.get() * 60_000L, queue::add); + health = new HealthMonitor(AtmConfig.HEALTH_INTERVAL_SECONDS.get() * 1000L, queue::add); + FileWatcher files = new FileWatcher(worldDir, FMLPaths.CONFIGDIR.get(), queue::add, LOG_SINK); + worker = new WorkerThread(shipper, client, stats, files, AtmConfig.QUEST_SCAN_MINUTES.get() * 60_000L, LOG_SINK); + } + + static CollectorRuntime start(MinecraftServer server) throws IOException { + CollectorRuntime rt = new CollectorRuntime(server); + rt.worker.start(); + LOG.info("atmstats: collector started, sending to {}", AtmConfig.ENDPOINT.get()); + return rt; + } + + void enqueue(Events.AtmEvent event) { + queue.add(event); + } + + StatsCollector stats() { + return stats; + } + + HealthMonitor health() { + return health; + } + + /** Stops the worker; everything still queued is written to the on-disk outbox (max ~3 s). */ + void stop() { + worker.shutdown(Duration.ofSeconds(3)); + } +} diff --git a/mod/src/main/java/de/hahmann/atmstats/mc/FileWatcher.java b/mod/src/main/java/de/hahmann/atmstats/mc/FileWatcher.java new file mode 100644 index 0000000..dbb4ea6 --- /dev/null +++ b/mod/src/main/java/de/hahmann/atmstats/mc/FileWatcher.java @@ -0,0 +1,84 @@ +package de.hahmann.atmstats.mc; + +import de.hahmann.atmstats.core.Events; +import de.hahmann.atmstats.core.FileChangeTracker; +import de.hahmann.atmstats.core.LogSink; +import de.hahmann.atmstats.core.Signer; +import de.hahmann.atmstats.core.SyncPaths; +import de.hahmann.atmstats.core.UuidV7; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.BasicFileAttributes; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; +import java.util.stream.Stream; + +/** + * Forwards changed FTB Quests/Teams files (selected by SyncPaths) verbatim as file_sync events (parsed by the backend). Worker thread only. + * Paths are reported as "world/…" and "config/…" independent of the actual level name. + */ +final class FileWatcher { + static final long MAX_FILE_BYTES = 1_500_000; + + private record Root(Path dir, String prefix, boolean recursive) {} + + private final List roots; + private final FileChangeTracker tracker = new FileChangeTracker(); + private final Consumer sink; + private final LogSink log; + + FileWatcher(Path worldDir, Path configDir, Consumer sink, LogSink log) { + Path quests = configDir.resolve("ftbquests").resolve("quests"); + // Only the directories that can contain wanted files are walked (lang/ alone holds ~35 MB). + this.roots = List.of( + new Root(worldDir.resolve("ftbquests"), "world/ftbquests", false), + new Root(worldDir.resolve("ftbteams"), "world/ftbteams", true), + new Root(quests.resolve("chapters"), "config/ftbquests/quests/chapters", false), + new Root(quests.resolve("lang").resolve("en_us"), "config/ftbquests/quests/lang/en_us", true)); + this.sink = sink; + this.log = log; + } + + void scan() { + List seen = new ArrayList<>(); + for (Root root : roots) { + if (!Files.isDirectory(root.dir())) continue; + try (Stream files = root.recursive() ? Files.walk(root.dir()) : Files.list(root.dir())) { + for (Path file : (Iterable) files.filter(Files::isRegularFile)::iterator) { + String rel = root.prefix() + "/" + root.dir().relativize(file).toString().replace('\\', '/'); + if (!SyncPaths.isWanted(rel)) continue; + seen.add(rel); + check(file, rel); + } + } catch (IOException e) { + log.warn("atmstats: cannot scan " + root.dir() + ": " + e.getMessage()); + } + } + tracker.retainOnly(seen); + } + + private void check(Path file, String rel) { + try { + BasicFileAttributes attrs = Files.readAttributes(file, BasicFileAttributes.class); + long size = attrs.size(); + long mtime = attrs.lastModifiedTime().toMillis(); + if (!tracker.needsRead(rel, size, mtime)) return; + if (size > MAX_FILE_BYTES) { + log.warn("atmstats: skipping " + rel + " (" + size + " bytes, limit " + MAX_FILE_BYTES + ")"); + tracker.commit(rel, size, mtime, "skipped"); + return; + } + byte[] bytes = Files.readAllBytes(file); + String sha = Signer.sha256Hex(bytes); + if (tracker.commit(rel, size, mtime, sha)) { + sink.accept(new Events.FileSync(UuidV7.next(), mtime, rel, sha, new String(bytes, StandardCharsets.UTF_8))); + } + } catch (IOException e) { + // FTB may be writing the file right now; try again on the next scan. + log.warn("atmstats: cannot read " + rel + ": " + e.getMessage()); + } + } +} diff --git a/mod/src/main/java/de/hahmann/atmstats/mc/GameHooks.java b/mod/src/main/java/de/hahmann/atmstats/mc/GameHooks.java new file mode 100644 index 0000000..e781fe6 --- /dev/null +++ b/mod/src/main/java/de/hahmann/atmstats/mc/GameHooks.java @@ -0,0 +1,147 @@ +package de.hahmann.atmstats.mc; + +import com.mojang.logging.LogUtils; +import de.hahmann.atmstats.core.Events; +import de.hahmann.atmstats.core.UuidV7; +import net.minecraft.advancements.AdvancementHolder; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.resources.ResourceKey; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.damagesource.DamageSource; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.LivingEntity; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.Level; +import net.neoforged.bus.api.EventPriority; +import net.neoforged.bus.api.SubscribeEvent; +import net.neoforged.neoforge.common.Tags; +import net.neoforged.neoforge.common.util.FakePlayer; +import net.neoforged.neoforge.event.entity.living.LivingDeathEvent; +import net.neoforged.neoforge.event.entity.player.AdvancementEvent; +import net.neoforged.neoforge.event.entity.player.PlayerEvent; +import net.neoforged.neoforge.event.server.ServerStartedEvent; +import net.neoforged.neoforge.event.server.ServerStoppingEvent; +import net.neoforged.neoforge.event.tick.ServerTickEvent; +import org.slf4j.Logger; + +/** + * Game event handlers. Everything here runs on the server thread and only creates small immutable event + * records and puts them into a lock-free queue; I/O and serialization happen on the worker thread. + */ +public final class GameHooks { + private static final Logger LOG = LogUtils.getLogger(); + private CollectorRuntime runtime; + + @SubscribeEvent + public void onServerStarted(ServerStartedEvent event) { + if (!AtmConfig.isConfigured()) { + LOG.info("atmstats: not configured (endpoint/token/hmacSecret in config/atmstats-server.toml) – collector passive"); + return; + } + try { + runtime = CollectorRuntime.start(event.getServer()); + runtime.enqueue(ServerInfoCollector.serverStart(event.getServer())); + } catch (Exception e) { + LOG.error("atmstats: failed to start collector", e); + runtime = null; + } + } + + // Lowest priority: other mods' shutdown work runs first; we only flush. + @SubscribeEvent(priority = EventPriority.LOWEST) + public void onServerStopping(ServerStoppingEvent event) { + if (runtime == null) return; + long now = System.currentTimeMillis(); + for (ServerPlayer p : event.getServer().getPlayerList().getPlayers()) { + runtime.stats().captureNow(p, now); + runtime.enqueue(new Events.PlayerLeave(UuidV7.next(), now, p.getStringUUID(), name(p))); + } + runtime.enqueue(new Events.ServerStop(UuidV7.next(), now)); + runtime.stop(); + runtime = null; + } + + @SubscribeEvent + public void onLogin(PlayerEvent.PlayerLoggedInEvent event) { + if (runtime != null && event.getEntity() instanceof ServerPlayer p && !(p instanceof FakePlayer)) { + runtime.enqueue(new Events.PlayerJoin(UuidV7.next(), System.currentTimeMillis(), p.getStringUUID(), name(p))); + } + } + + @SubscribeEvent + public void onLogout(PlayerEvent.PlayerLoggedOutEvent event) { + if (runtime != null && event.getEntity() instanceof ServerPlayer p && !(p instanceof FakePlayer)) { + long now = System.currentTimeMillis(); + runtime.stats().captureNow(p, now); + runtime.enqueue(new Events.PlayerLeave(UuidV7.next(), now, p.getStringUUID(), name(p))); + } + } + + @SubscribeEvent + public void onDimensionChange(PlayerEvent.PlayerChangedDimensionEvent event) { + if (runtime != null && event.getEntity() instanceof ServerPlayer p && !(p instanceof FakePlayer)) { + runtime.enqueue(new Events.DimensionChange(UuidV7.next(), System.currentTimeMillis(), p.getStringUUID(), name(p), + dim(event.getFrom()), dim(event.getTo()))); + } + } + + @SubscribeEvent + public void onAdvancement(AdvancementEvent.AdvancementEarnEvent event) { + if (runtime == null || !(event.getEntity() instanceof ServerPlayer p) || p instanceof FakePlayer) return; + AdvancementHolder holder = event.getAdvancement(); + // Only advancements with a display (excludes recipe unlocks and hidden technical ones). + holder.value().display().ifPresent(display -> runtime.enqueue(new Events.Advancement(UuidV7.next(), System.currentTimeMillis(), + p.getStringUUID(), name(p), holder.id().toString(), display.getTitle().getString()))); + } + + // Lowest priority and only when not canceled: totem/revival mods may cancel the death. + @SubscribeEvent(priority = EventPriority.LOWEST) + public void onDeath(LivingDeathEvent event) { + if (runtime == null || event.isCanceled()) return; + LivingEntity victim = event.getEntity(); + DamageSource source = event.getSource(); + long now = System.currentTimeMillis(); + Entity attacker = source.getEntity(); + + if (victim instanceof ServerPlayer p && !(p instanceof FakePlayer)) { + String killerType = attacker != null ? BuiltInRegistries.ENTITY_TYPE.getKey(attacker.getType()).toString() : null; + String killerName = attacker instanceof Player kp ? kp.getGameProfile().getName() + : attacker != null && attacker.hasCustomName() ? attacker.getCustomName().getString() : null; + String cause = source.typeHolder().unwrapKey().map(k -> k.location().toString()).orElse(source.getMsgId()); + runtime.enqueue(new Events.PlayerDeath(UuidV7.next(), now, p.getStringUUID(), name(p), dim(p.level().dimension()), cause, + killerType, killerName)); + } + + // Individual kills only for bosses and players; regular mob kills come from the stats counters. + if (attacker instanceof ServerPlayer killer && !(killer instanceof FakePlayer) && attacker != victim) { + boolean isBoss = victim.getType().is(Tags.EntityTypes.BOSSES); + if (isBoss || victim instanceof Player) { + ItemStack weapon = killer.getMainHandItem(); + runtime.enqueue(new Events.EntityKill(UuidV7.next(), now, killer.getStringUUID(), name(killer), dim(killer.level().dimension()), + BuiltInRegistries.ENTITY_TYPE.getKey(victim.getType()).toString(), isBoss, + weapon.isEmpty() ? null : BuiltInRegistries.ITEM.getKey(weapon.getItem()).toString())); + } + } + } + + @SubscribeEvent + public void onTickPre(ServerTickEvent.Pre event) { + if (runtime != null) runtime.health().tickStart(); + } + + @SubscribeEvent + public void onTickPost(ServerTickEvent.Post event) { + if (runtime == null) return; + runtime.health().tickEnd(event.getServer()); + runtime.stats().tick(event.getServer()); + } + + static String name(Player p) { + return p.getGameProfile().getName(); + } + + static String dim(ResourceKey key) { + return key.location().toString(); + } +} diff --git a/mod/src/main/java/de/hahmann/atmstats/mc/HealthMonitor.java b/mod/src/main/java/de/hahmann/atmstats/mc/HealthMonitor.java new file mode 100644 index 0000000..b630590 --- /dev/null +++ b/mod/src/main/java/de/hahmann/atmstats/mc/HealthMonitor.java @@ -0,0 +1,47 @@ +package de.hahmann.atmstats.mc; + +import de.hahmann.atmstats.core.Events; +import de.hahmann.atmstats.core.HealthWindow; +import de.hahmann.atmstats.core.UuidV7; +import java.util.function.Consumer; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerLevel; + +/** Measures tick durations itself (no dependency on spark) and reports one health event per interval. */ +final class HealthMonitor { + private final HealthWindow window = new HealthWindow(2400); + private final long intervalMs; + private final Consumer sink; + private long tickStartNanos; + private long windowStartMs = System.currentTimeMillis(); + + HealthMonitor(long intervalMs, Consumer sink) { + this.intervalMs = intervalMs; + this.sink = sink; + } + + void tickStart() { + tickStartNanos = System.nanoTime(); + } + + void tickEnd(MinecraftServer server) { + if (tickStartNanos == 0) return; + window.record(System.nanoTime() - tickStartNanos); + long now = System.currentTimeMillis(); + if (now - windowStartMs < intervalMs) return; + + HealthWindow.Stats s = window.drain(); + int chunks = 0; + for (ServerLevel level : server.getAllLevels()) chunks += level.getChunkSource().getLoadedChunksCount(); + java.lang.Runtime rt = java.lang.Runtime.getRuntime(); + double usedMb = (rt.totalMemory() - rt.freeMemory()) / 1048576.0; + double maxMb = rt.maxMemory() / 1048576.0; + sink.accept(new Events.Health(UuidV7.next(), now, windowStartMs, now, round(s.msptAvg()), round(s.msptP95()), round(s.msptMax()), + round(s.tps()), server.getPlayerList().getPlayerCount(), chunks, Math.round(usedMb), Math.round(maxMb))); + windowStartMs = now; + } + + private static double round(double v) { + return Math.round(v * 100) / 100.0; + } +} diff --git a/mod/src/main/java/de/hahmann/atmstats/mc/ServerInfoCollector.java b/mod/src/main/java/de/hahmann/atmstats/mc/ServerInfoCollector.java new file mode 100644 index 0000000..ef52615 --- /dev/null +++ b/mod/src/main/java/de/hahmann/atmstats/mc/ServerInfoCollector.java @@ -0,0 +1,25 @@ +package de.hahmann.atmstats.mc; + +import de.hahmann.atmstats.core.Events; +import de.hahmann.atmstats.core.UuidV7; +import java.util.Comparator; +import java.util.List; +import net.minecraft.SharedConstants; +import net.minecraft.server.MinecraftServer; +import net.neoforged.fml.ModList; + +/** Exact versions and the installed mod list, read from the running server instead of guessed. */ +final class ServerInfoCollector { + private ServerInfoCollector() {} + + static Events.ServerStart serverStart(MinecraftServer server) { + ModList mods = ModList.get(); + String neoforge = mods.getModContainerById("neoforge").map(c -> c.getModInfo().getVersion().toString()).orElse("unknown"); + List list = mods.getMods().stream() + .map(m -> new Events.ModInfo(m.getModId(), m.getVersion().toString(), m.getDisplayName())) + .sorted(Comparator.comparing(Events.ModInfo::id)) + .toList(); + return new Events.ServerStart(UuidV7.next(), System.currentTimeMillis(), SharedConstants.getCurrentVersion().getName(), neoforge, + System.getProperty("java.version"), list); + } +} diff --git a/mod/src/main/java/de/hahmann/atmstats/mc/StatsCollector.java b/mod/src/main/java/de/hahmann/atmstats/mc/StatsCollector.java new file mode 100644 index 0000000..b279efb --- /dev/null +++ b/mod/src/main/java/de/hahmann/atmstats/mc/StatsCollector.java @@ -0,0 +1,85 @@ +package de.hahmann.atmstats.mc; + +import de.hahmann.atmstats.core.Events; +import de.hahmann.atmstats.core.StatsDiff; +import de.hahmann.atmstats.core.UuidV7; +import it.unimi.dsi.fastutil.objects.Object2IntMap; +import it.unimi.dsi.fastutil.objects.Object2IntOpenHashMap; +import java.util.ArrayDeque; +import java.util.Deque; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Queue; +import java.util.UUID; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.function.Consumer; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.stats.Stat; +import net.neoforged.neoforge.common.util.FakePlayer; + +/** + * Periodically copies the live counters of online players. The server thread only copies the counter map + * (spread over ticks, at most {@value #PLAYERS_PER_TICK} players per tick); key conversion and diffing run on the + * worker thread via {@link #drain()}. + */ +final class StatsCollector { + static final int PLAYERS_PER_TICK = 2; + + private record Raw(String uuid, String name, long ts, Object2IntMap> counters) {} + + private final long intervalMs; + private final Queue raw = new ConcurrentLinkedQueue<>(); + private final Deque pending = new ArrayDeque<>(); + private final StatsDiff diff = new StatsDiff(); // worker thread only + private final Consumer sink; + private long nextRunMs; + + StatsCollector(long intervalMs, Consumer sink) { + this.intervalMs = intervalMs; + this.sink = sink; + this.nextRunMs = System.currentTimeMillis() + 30_000; // first snapshot shortly after start + } + + /** Server thread. */ + void tick(MinecraftServer server) { + long now = System.currentTimeMillis(); + if (pending.isEmpty() && now >= nextRunMs) { + nextRunMs = now + intervalMs; + for (ServerPlayer p : server.getPlayerList().getPlayers()) if (!(p instanceof FakePlayer)) pending.add(p.getUUID()); + } + for (int i = 0; i < PLAYERS_PER_TICK && !pending.isEmpty(); i++) { + ServerPlayer p = server.getPlayerList().getPlayer(pending.poll()); + if (p != null) captureNow(p, now); + } + } + + /** Server thread: copies the player's counters (a few hundred to a few thousand entries). */ + void captureNow(ServerPlayer p, long now) { + raw.add(new Raw(p.getStringUUID(), GameHooks.name(p), now, new Object2IntOpenHashMap<>(p.getStats().stats))); + } + + /** Worker thread: converts captured counters to absolute, changed-only snapshots. */ + void drain() { + Raw r; + while ((r = raw.poll()) != null) { + Map> stats = new LinkedHashMap<>(); + for (Object2IntMap.Entry> e : r.counters().object2IntEntrySet()) { + ResourceLocation category = BuiltInRegistries.STAT_TYPE.getKey(e.getKey().getType()); + ResourceLocation key = keyOf(e.getKey()); + if (category == null || key == null || e.getIntValue() < 0) continue; + stats.computeIfAbsent(category.toString(), c -> new LinkedHashMap<>()).put(key.toString(), (long) e.getIntValue()); + } + StatsDiff.Result result = diff.apply(r.uuid(), stats); + if (result.full() || !result.changed().isEmpty()) { + sink.accept(new Events.StatsSnapshot(UuidV7.next(), r.ts(), r.uuid(), r.name(), result.full(), result.changed())); + } + } + } + + private static ResourceLocation keyOf(Stat stat) { + return stat.getType().getRegistry().getKey(stat.getValue()); + } +} diff --git a/mod/src/main/java/de/hahmann/atmstats/mc/WorkerThread.java b/mod/src/main/java/de/hahmann/atmstats/mc/WorkerThread.java new file mode 100644 index 0000000..fa72367 --- /dev/null +++ b/mod/src/main/java/de/hahmann/atmstats/mc/WorkerThread.java @@ -0,0 +1,92 @@ +package de.hahmann.atmstats.mc; + +import de.hahmann.atmstats.core.HttpIngestClient; +import de.hahmann.atmstats.core.LogSink; +import de.hahmann.atmstats.core.Shipper; +import java.time.Duration; + +/** The mod's single background thread: all serialization, file and network I/O happens here. */ +final class WorkerThread extends Thread { + private static final long IDLE_TICK_MS = 1000; + private final Shipper shipper; + private final HttpIngestClient client; + private final StatsCollector stats; + private final FileWatcher files; + private final long fileScanMs; + private final LogSink log; + private volatile boolean running = true; + + WorkerThread(Shipper shipper, HttpIngestClient client, StatsCollector stats, FileWatcher files, long fileScanMs, LogSink log) { + super("atmstats-worker"); + setDaemon(true); + setPriority(Thread.MIN_PRIORITY); + this.shipper = shipper; + this.client = client; + this.stats = stats; + this.files = files; + this.fileScanMs = fileScanMs; + this.log = log; + } + + @Override + public void run() { + // Self-test answers the question whether the host allows outbound HTTPS. + String ping = client.ping(System.currentTimeMillis()); + if ("reachable".equals(ping)) log.info("atmstats: backend reachable"); + else log.warn("atmstats: backend NOT reachable: " + ping + " – events are kept in atmstats/outbox and retried"); + + long nextScan = 0; + long nextSend = 0; + while (running) { + try { + stats.drain(); + long now = System.currentTimeMillis(); + if (now >= nextScan) { + files.scan(); + nextScan = now + fileScanMs; + } + if (now >= nextSend) { + long delay = shipper.runOnce(); // also persists the queue + nextSend = System.currentTimeMillis() + delay; + if (delay == 0) continue; // backlog: send the next segment right away + } else { + // During backoff, still move new events to disk every second so a crash cannot lose them. + shipper.flushToJournal(); + } + Thread.sleep(IDLE_TICK_MS); + } catch (InterruptedException e) { + // shutdown requested + } catch (Exception e) { + log.warn("atmstats: worker error: " + e); + sleepQuietly(5000); + } + } + // Clear a pending interrupt: interruptible file channels (fsync on rotation) would otherwise abort the flush. + Thread.interrupted(); + try { + stats.drain(); + shipper.flushToJournal(); + } catch (Exception e) { + log.warn("atmstats: could not persist queued events on shutdown: " + e); + } + } + + void shutdown(Duration timeout) { + running = false; + interrupt(); + try { + join(timeout.toMillis()); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + if (isAlive()) log.warn("atmstats: worker did not stop in time; unsent events stay in memory and are lost"); + } + + private static void sleepQuietly(long ms) { + try { + Thread.sleep(ms); + } catch (InterruptedException ignored) { + // shutdown + } + } +} diff --git a/mod/src/main/resources/META-INF/accesstransformer.cfg b/mod/src/main/resources/META-INF/accesstransformer.cfg new file mode 100644 index 0000000..bf961e0 --- /dev/null +++ b/mod/src/main/resources/META-INF/accesstransformer.cfg @@ -0,0 +1,2 @@ +# Zugriff auf die Zählertabelle eines Spielers (Kopie alle 5 Minuten statt Iteration über alle Registry-Einträge) +public net.minecraft.stats.StatsCounter stats diff --git a/mod/src/main/templates/META-INF/neoforge.mods.toml b/mod/src/main/templates/META-INF/neoforge.mods.toml new file mode 100644 index 0000000..042e094 --- /dev/null +++ b/mod/src/main/templates/META-INF/neoforge.mods.toml @@ -0,0 +1,29 @@ +modLoader="javafml" +loaderVersion="${loader_version_range}" +license="${mod_license}" + +[[mods]] +modId="${mod_id}" +version="${mod_version}" +displayName="${mod_name}" +authors="Lucas Hahmann" +description='''Serverseitige Statistik-Erfassung für das ATM10-Statistikportal. Clients benötigen diese Mod nicht.''' +# Rein serverseitig: Clients ohne die Mod dürfen verbinden. +displayTest="IGNORE_ALL_VERSION" + +[[dependencies.${mod_id}]] +modId="neoforge" +type="required" +versionRange="${neo_version_range}" +ordering="NONE" +side="SERVER" + +[[dependencies.${mod_id}]] +modId="minecraft" +type="required" +versionRange="${minecraft_version_range}" +ordering="NONE" +side="SERVER" + +[[accessTransformers]] +file="META-INF/accesstransformer.cfg" diff --git a/mod/src/test/java/de/hahmann/atmstats/core/BackoffTest.java b/mod/src/test/java/de/hahmann/atmstats/core/BackoffTest.java new file mode 100644 index 0000000..d97816b --- /dev/null +++ b/mod/src/test/java/de/hahmann/atmstats/core/BackoffTest.java @@ -0,0 +1,26 @@ +package de.hahmann.atmstats.core; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Random; +import org.junit.jupiter.api.Test; + +class BackoffTest { + private final Backoff backoff = new Backoff(1000, 300_000); + + @Test + void capsAtFiveMinutes() { + Random rnd = new Random(1); + for (int attempt : new int[] {12, 30, 63, 1000}) { + long d = backoff.delayMs(attempt, rnd); + assertTrue(d <= 300_000 && d >= 150_000, "attempt " + attempt + " -> " + d); + } + } + + @Test + void growsExponentially() { + Random rnd = new Random(2); + assertTrue(backoff.delayMs(0, rnd) >= 500 && backoff.delayMs(0, rnd) <= 1000); + assertTrue(backoff.delayMs(3, rnd) >= 4000 && backoff.delayMs(3, rnd) <= 8000); + } +} diff --git a/mod/src/test/java/de/hahmann/atmstats/core/EventJsonTest.java b/mod/src/test/java/de/hahmann/atmstats/core/EventJsonTest.java new file mode 100644 index 0000000..c3e253a --- /dev/null +++ b/mod/src/test/java/de/hahmann/atmstats/core/EventJsonTest.java @@ -0,0 +1,57 @@ +package de.hahmann.atmstats.core; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import com.google.gson.JsonElement; +import com.google.gson.JsonParser; +import de.hahmann.atmstats.core.Events.*; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import org.junit.jupiter.api.Test; + +/** The Java serialization must produce exactly the JSON of shared/fixtures/protocol (validated by the backend). */ +class EventJsonTest { + private static final String P = "308a693c-2bd8-4d6b-8790-670aef5be601"; + private static final String NAME = "CDOGamer"; + + private static UUID id(String suffix) { + return UUID.fromString("0199d6a0-0000-7000-8000-0000000000" + suffix); + } + + private static JsonElement fixture(String type) throws IOException { + Path dir = Path.of(System.getProperty("atm.fixtures")); + return JsonParser.parseString(Files.readString(dir.resolve(type + ".json"), StandardCharsets.UTF_8)); + } + + private static void check(AtmEvent e) throws IOException { + assertEquals(fixture(e.type()), JsonParser.parseString(EventJson.toJson(e)), e.type()); + } + + @Test + void matchesSharedFixtures() throws IOException { + check(new ServerStart(id("01"), 1760259000000L, "1.21.1", "21.1.219", "21.0.5", + List.of(new ModInfo("mekanism", "10.7.19", "Mekanism"), new ModInfo("ftbquests", "2101.1.36", "FTB Quests")))); + check(new ServerStop(id("02"), 1760300000000L)); + check(new PlayerJoin(id("03"), 1760259783006L, P, NAME)); + check(new PlayerLeave(id("04"), 1760270000000L, P, NAME)); + check(new PlayerDeath(id("05"), 1760261000000L, P, NAME, "minecraft:the_nether", "minecraft:mob_attack", + "minecraft:wither_skeleton", null)); + check(new EntityKill(id("06"), 1760262000000L, P, NAME, "minecraft:the_end", "minecraft:ender_dragon", true, + "mekanismtools:refined_obsidian_sword")); + check(new Advancement(id("07"), 1760260131149L, P, NAME, "mysticalagriculture:infusion", "Infusing Efficiency")); + check(new DimensionChange(id("08"), 1760271736798L, P, NAME, "minecraft:overworld", "minecraft:the_nether")); + Map> stats = new LinkedHashMap<>(); + stats.put("minecraft:custom", new LinkedHashMap<>(Map.of("minecraft:play_time", 1175736L, "minecraft:deaths", 8L))); + stats.put("minecraft:mined", Map.of("mekanism:osmium_ore", 12L)); + check(new StatsSnapshot(id("09"), 1760263000000L, P, NAME, false, stats)); + check(new FileSync(id("0a"), 1760264000000L, "world/ftbquests/308a693c-2bd8-4d6b-8790-670aef5be601.snbt", + "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08", "{\n\tversion: 1\n}\n")); + check(new Health(id("0b"), 1760265060000L, 1760265000000L, 1760265060000L, 38.5, 61.2, 210.0, 20.0, 3, 1840, 2200, 8192)); + } +} diff --git a/mod/src/test/java/de/hahmann/atmstats/core/FileChangeTrackerTest.java b/mod/src/test/java/de/hahmann/atmstats/core/FileChangeTrackerTest.java new file mode 100644 index 0000000..dc5845d --- /dev/null +++ b/mod/src/test/java/de/hahmann/atmstats/core/FileChangeTrackerTest.java @@ -0,0 +1,42 @@ +package de.hahmann.atmstats.core; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import org.junit.jupiter.api.Test; + +class FileChangeTrackerTest { + @Test + void detectsNewChangedAndUnchangedFiles() { + FileChangeTracker t = new FileChangeTracker(); + + // new file: must be read and hashed, and is reported as changed + assertTrue(t.needsRead("a.snbt", 10, 100)); + assertTrue(t.commit("a.snbt", 10, 100, "h1")); + + // same size + mtime: not read again + assertFalse(t.needsRead("a.snbt", 10, 100)); + + // touched (new mtime) but identical content: read, but not reported + assertTrue(t.needsRead("a.snbt", 10, 200)); + assertFalse(t.commit("a.snbt", 10, 200, "h1")); + assertFalse(t.needsRead("a.snbt", 10, 200)); + + // real change + assertTrue(t.needsRead("a.snbt", 12, 300)); + assertTrue(t.commit("a.snbt", 12, 300, "h2")); + } + + @Test + void forgetsDeletedFiles() { + FileChangeTracker t = new FileChangeTracker(); + t.commit("a.snbt", 1, 1, "h"); + t.commit("b.snbt", 1, 1, "h"); + t.retainOnly(List.of("b.snbt")); + assertTrue(t.needsRead("a.snbt", 1, 1)); + assertFalse(t.needsRead("b.snbt", 1, 1)); + assertEquals(1, t.size()); + } +} diff --git a/mod/src/test/java/de/hahmann/atmstats/core/HealthWindowTest.java b/mod/src/test/java/de/hahmann/atmstats/core/HealthWindowTest.java new file mode 100644 index 0000000..5503832 --- /dev/null +++ b/mod/src/test/java/de/hahmann/atmstats/core/HealthWindowTest.java @@ -0,0 +1,36 @@ +package de.hahmann.atmstats.core; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +class HealthWindowTest { + @Test + void aggregatesTickDurations() { + HealthWindow w = new HealthWindow(1000); + for (int i = 1; i <= 100; i++) w.record(i * 1_000_000L); // 1..100 ms + HealthWindow.Stats s = w.drain(); + assertEquals(50.5, s.msptAvg(), 1e-9); + assertEquals(95.0, s.msptP95(), 1e-9); + assertEquals(100.0, s.msptMax(), 1e-9); + assertEquals(1000.0 / 50.5, s.tps(), 1e-9); + assertEquals(0, w.drain().ticks()); + } + + @Test + void tpsIsCappedAtTwenty() { + HealthWindow w = new HealthWindow(1000); + for (int i = 0; i < 10; i++) w.record(2_000_000L); + assertEquals(20.0, w.drain().tps(), 1e-9); + } + + @Test + void keepsAtMostCapacitySamples() { + HealthWindow w = new HealthWindow(10); + for (int i = 0; i < 50; i++) w.record(1_000_000L); + HealthWindow.Stats s = w.drain(); + assertEquals(50, s.ticks()); + assertTrue(s.msptAvg() > 0); + } +} diff --git a/mod/src/test/java/de/hahmann/atmstats/core/OutboxJournalTest.java b/mod/src/test/java/de/hahmann/atmstats/core/OutboxJournalTest.java new file mode 100644 index 0000000..85445bf --- /dev/null +++ b/mod/src/test/java/de/hahmann/atmstats/core/OutboxJournalTest.java @@ -0,0 +1,113 @@ +package de.hahmann.atmstats.core; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.stream.Stream; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class OutboxJournalTest { + @TempDir + Path dir; + + private static final Duration WEEK = Duration.ofDays(7); + + private static String event(int i) { + return "{\"id\":\"e" + i + "\",\"type\":\"player_join\"}"; + } + + private static String snapshot(int i) { + return "{\"id\":\"s" + i + "\",\"type\":\"stats_snapshot\",\"pad\":\"" + "x".repeat(200) + "\"}"; + } + + @Test + void survivesRestart() throws IOException { + OutboxJournal j1 = new OutboxJournal(dir, 50_000_000, WEEK); + j1.append(List.of(event(1), event(2))); + OutboxJournal j2 = new OutboxJournal(dir, 50_000_000, WEEK); + Optional seg = j2.nextSendable(); + assertTrue(seg.isPresent()); + assertEquals(List.of(event(1), event(2)), seg.get().lines()); + } + + @Test + void dropsTruncatedLastLine() throws IOException { + Files.writeString(dir.resolve("current.jsonl"), event(1) + "\n{\"id\":\"trunc", StandardCharsets.UTF_8); + OutboxJournal j = new OutboxJournal(dir, 50_000_000, WEEK); + j.append(List.of(event(2))); + assertEquals(List.of(event(1), event(2)), j.nextSendable().orElseThrow().lines()); + } + + @Test + void ackDeletesSegment() throws IOException { + OutboxJournal j = new OutboxJournal(dir, 50_000_000, WEEK); + j.append(List.of(event(1))); + OutboxJournal.Segment s = j.nextSendable().orElseThrow(); + j.ack(s); + assertFalse(j.nextSendable().isPresent()); + assertEquals(0, j.totalBytes()); + } + + @Test + void rejectMovesSegmentAside() throws IOException { + OutboxJournal j = new OutboxJournal(dir, 50_000_000, WEEK); + j.append(List.of(event(1))); + j.reject(j.nextSendable().orElseThrow(), "400 bad_batch"); + assertFalse(j.nextSendable().isPresent()); + try (Stream files = Files.list(dir.resolve("rejected"))) { + assertEquals(2, files.count()); // segment + reason + } + } + + @Test + void rotatesAt500Lines() throws IOException { + OutboxJournal j = new OutboxJournal(dir, 50_000_000, WEEK); + List lines = new ArrayList<>(); + for (int i = 0; i < 501; i++) lines.add(event(i)); + j.append(lines); + OutboxJournal.Segment first = j.nextSendable().orElseThrow(); + assertEquals(500, first.lines().size()); + j.ack(first); + assertEquals(List.of(event(500)), j.nextSendable().orElseThrow().lines()); + } + + @Test + void sendsOldestSegmentFirst() throws IOException { + OutboxJournal j = new OutboxJournal(dir, 50_000_000, WEEK); + j.append(List.of(event(1))); + OutboxJournal.Segment a = j.nextSendable().orElseThrow(); + j.append(List.of(event(2))); + assertEquals(a.file(), j.nextSendable().orElseThrow().file()); + } + + @Test + void evictsSnapshotsBeforeEvents() throws IOException { + // ~250 bytes per snapshot line; limit allows roughly 20 of them + OutboxJournal j = new OutboxJournal(dir, 5_000, WEEK); + List mixed = new ArrayList<>(); + for (int i = 0; i < 40; i++) { + mixed.add(snapshot(i)); + if (i % 10 == 0) mixed.add(event(i)); + } + int dropped = j.append(mixed); + assertTrue(dropped > 0); + assertTrue(j.totalBytes() <= 5_000); + List remaining = new ArrayList<>(); + Optional s; + while ((s = j.nextSendable()).isPresent()) { + remaining.addAll(s.get().lines()); + j.ack(s.get()); + } + for (int i = 0; i < 40; i += 10) assertTrue(remaining.contains(event(i)), "event " + i + " kept"); + } +} diff --git a/mod/src/test/java/de/hahmann/atmstats/core/ShipperTest.java b/mod/src/test/java/de/hahmann/atmstats/core/ShipperTest.java new file mode 100644 index 0000000..14f9ac8 --- /dev/null +++ b/mod/src/test/java/de/hahmann/atmstats/core/ShipperTest.java @@ -0,0 +1,197 @@ +package de.hahmann.atmstats.core; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.google.gson.JsonParser; +import de.hahmann.atmstats.core.Events.PlayerJoin; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Deque; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Queue; +import java.util.Set; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.zip.GZIPInputStream; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class ShipperTest { + @TempDir + Path dir; + + private final Queue queue = new ConcurrentLinkedQueue<>(); + private final List logs = new ArrayList<>(); + private final FakeClient client = new FakeClient(); + private OutboxJournal journal; + private Shipper shipper; + + /** Answers with queued responses; by default accepts every event it receives. */ + static final class FakeClient implements IngestClient { + final Deque script = new ArrayDeque<>(); + final List bodies = new ArrayList<>(); + final List signatures = new ArrayList<>(); + + @Override + public Response send(byte[] gzBody, long ts, String signature) throws IOException { + bodies.add(gzBody); + signatures.add(signature); + Object next = script.isEmpty() ? 200 : script.poll(); + if (next instanceof IOException e) throw e; + if (next instanceof Response r) return r; + int status = (Integer) next; + if (status == 200) return new Response(200, idsOf(gzBody), Set.of(), Map.of(), null); + return new Response(status, Set.of(), Set.of(), Map.of(), status == 401 ? "timestamp_out_of_range" : "error"); + } + + static Set idsOf(byte[] gz) throws IOException { + String json = new String(new GZIPInputStream(new ByteArrayInputStream(gz)).readAllBytes(), StandardCharsets.UTF_8); + Set ids = new HashSet<>(); + JsonParser.parseString(json).getAsJsonObject().getAsJsonArray("events") + .forEach(e -> ids.add(e.getAsJsonObject().get("id").getAsString())); + return ids; + } + } + + @BeforeEach + void setUp() throws IOException { + journal = new OutboxJournal(dir, 50_000_000, Duration.ofDays(7)); + shipper = new Shipper(queue, journal, client, "secret".getBytes(StandardCharsets.UTF_8), new Backoff(1000, 300_000), + () -> 1_760_000_000_000L, new LogSink() { + public void info(String msg) { logs.add("INFO " + msg); } + public void warn(String msg) { logs.add("WARN " + msg); } + }); + } + + private void enqueue(int n) { + for (int i = 0; i < n; i++) queue.add(new PlayerJoin(UuidV7.next(), 1L, "308a693c-2bd8-4d6b-8790-670aef5be601", "CDOGamer")); + } + + @Test + void acksOnSuccess() throws IOException { + enqueue(3); + long delay = shipper.runOnce(); + assertEquals(0, delay); + assertEquals(1, client.bodies.size()); + assertEquals(3, FakeClient.idsOf(client.bodies.get(0)).size()); + assertEquals(Signer.sign("secret".getBytes(StandardCharsets.UTF_8), 1_760_000_000_000L, client.bodies.get(0)), client.signatures.get(0)); + assertFalse(journal.nextSendable().isPresent()); + } + + @Test + void retriesWithBackoffOn503() throws IOException { + enqueue(2); + client.script.add(503); + long first = shipper.runOnce(); + assertTrue(first >= 500, "backoff delay " + first); + assertTrue(journal.nextSendable().isPresent(), "segment kept"); + long second = shipper.runOnce(); + assertEquals(0, second); + assertEquals(2, client.bodies.size()); + assertFalse(journal.nextSendable().isPresent()); + } + + @Test + void rejectsOn400() throws IOException { + enqueue(1); + client.script.add(400); + shipper.runOnce(); + assertFalse(journal.nextSendable().isPresent()); + assertTrue(Files.exists(dir.resolve("rejected"))); + assertTrue(logs.stream().anyMatch(l -> l.startsWith("WARN") && l.contains("rejected"))); + } + + @Test + void logsClockSkewOn401() throws IOException { + enqueue(1); + client.script.add(401); + long delay = shipper.runOnce(); + assertTrue(delay > 0); + assertTrue(journal.nextSendable().isPresent()); + assertTrue(logs.stream().anyMatch(l -> l.contains("clock skew")), logs.toString()); + } + + @Test + void keepsEventsWhenClientThrows() throws IOException { + enqueue(2); + client.script.add(new IOException("connection refused")); + long delay = shipper.runOnce(); + assertTrue(delay > 0); + assertEquals(2, journal.nextSendable().orElseThrow().lines().size()); + // exceptions without message (e.g. ConnectException) must still be identifiable in the log + assertTrue(logs.stream().anyMatch(l -> l.contains("IOException: connection refused")), logs.toString()); + } + + @Test + void flushToJournalPersistsQueuedEvents() throws IOException { + enqueue(4); + shipper.flushToJournal(); + assertTrue(queue.isEmpty()); + assertEquals(4, new OutboxJournal(dir, 50_000_000, Duration.ofDays(7)).nextSendable().orElseThrow().lines().size()); + } + + @Test + void keepsSegmentWhen200DoesNotConfirmAllIds() throws IOException { + enqueue(2); + // e.g. a proxy error page answered with 200: nothing confirmed + client.script.add(new IngestClient.Response(200, Set.of(), Set.of(), Map.of(), null)); + long delay = shipper.runOnce(); + assertTrue(delay > 0); + assertEquals(2, journal.nextSendable().orElseThrow().lines().size()); + assertTrue(logs.stream().anyMatch(l -> l.contains("did not confirm")), logs.toString()); + } + + @Test + void individuallyRejectedEventsAreKeptInRejectedFolder() throws IOException { + enqueue(2); + flushAndCaptureIds(); + String bad = firstIds.get(0); + Set good = new HashSet<>(firstIds); + good.remove(bad); + client.script.add(new IngestClient.Response(200, good, Set.of(), Map.of(bad, "uuid: Invalid uuid"), null)); + assertEquals(0, shipper.runOnce()); + assertFalse(journal.nextSendable().isPresent()); + try (var files = Files.list(dir.resolve("rejected"))) { + String content = files.filter(p -> p.toString().endsWith(".jsonl")).map(p -> { + try { return Files.readString(p); } catch (IOException e) { throw new RuntimeException(e); } + }).reduce("", String::concat); + assertTrue(content.contains(bad), content); + assertFalse(content.contains(new ArrayList<>(good).get(0))); + } + } + + @Test + void eventsStayQueuedWhenJournalCannotBeWritten() throws IOException { + enqueue(3); + // make current.jsonl unwritable by putting a directory in its place + Files.createDirectories(dir.resolve("current.jsonl")); + try { + shipper.flushToJournal(); + } catch (IOException expected) { + // reported to the caller + } + assertEquals(3, queue.size()); + } + + private final List firstIds = new ArrayList<>(); + + private void flushAndCaptureIds() { + for (Events.AtmEvent e : queue) firstIds.add(e.id().toString()); + } + + @Test + void idleWhenNothingToSend() throws IOException { + assertEquals(Shipper.IDLE_MS, shipper.runOnce()); + assertTrue(client.bodies.isEmpty()); + } +} diff --git a/mod/src/test/java/de/hahmann/atmstats/core/SignerTest.java b/mod/src/test/java/de/hahmann/atmstats/core/SignerTest.java new file mode 100644 index 0000000..e198e59 --- /dev/null +++ b/mod/src/test/java/de/hahmann/atmstats/core/SignerTest.java @@ -0,0 +1,15 @@ +package de.hahmann.atmstats.core; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.nio.charset.StandardCharsets; +import org.junit.jupiter.api.Test; + +class SignerTest { + @Test + void matchesBackendVector() { + // Same vector as backend/test/ingest.test.ts "signature vector (shared with the mod)" + String sig = Signer.sign("test-secret".getBytes(StandardCharsets.UTF_8), 1760000000000L, "hello".getBytes(StandardCharsets.UTF_8)); + assertEquals("c9f85674a0d206a6d8e53290fb21af4933f8c8451fb8b2aeefcdf3d2314ef043", sig); + } +} diff --git a/mod/src/test/java/de/hahmann/atmstats/core/StatsDiffTest.java b/mod/src/test/java/de/hahmann/atmstats/core/StatsDiffTest.java new file mode 100644 index 0000000..c6f5348 --- /dev/null +++ b/mod/src/test/java/de/hahmann/atmstats/core/StatsDiffTest.java @@ -0,0 +1,36 @@ +package de.hahmann.atmstats.core; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Map; +import org.junit.jupiter.api.Test; + +class StatsDiffTest { + private static Map> stats(long jumps, long deaths) { + return Map.of("minecraft:custom", Map.of("minecraft:jump", jumps, "minecraft:deaths", deaths)); + } + + @Test + void firstSnapshotIsFullThenOnlyChanges() { + StatsDiff diff = new StatsDiff(); + StatsDiff.Result first = diff.apply("p", stats(1, 0)); + assertTrue(first.full()); + assertEquals(stats(1, 0), first.changed()); + + StatsDiff.Result second = diff.apply("p", stats(5, 0)); + assertFalse(second.full()); + assertEquals(Map.of("minecraft:custom", Map.of("minecraft:jump", 5L)), second.changed()); + + assertTrue(diff.apply("p", stats(5, 0)).changed().isEmpty()); + } + + @Test + void forgetMakesNextSnapshotFullAgain() { + StatsDiff diff = new StatsDiff(); + diff.apply("p", stats(1, 0)); + diff.forget("p"); + assertTrue(diff.apply("p", stats(1, 0)).full()); + } +} diff --git a/mod/src/test/java/de/hahmann/atmstats/core/SyncPathsTest.java b/mod/src/test/java/de/hahmann/atmstats/core/SyncPathsTest.java new file mode 100644 index 0000000..5744a75 --- /dev/null +++ b/mod/src/test/java/de/hahmann/atmstats/core/SyncPathsTest.java @@ -0,0 +1,36 @@ +package de.hahmann.atmstats.core; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import org.junit.jupiter.api.Test; + +/** Same rules as backend isQuestConfigFile + team/progress paths (verified against the ATM10 server layout). */ +class SyncPathsTest { + @Test + void sendsProgressTeamsChaptersAndEnglishTexts() { + for (String p : List.of( + "world/ftbquests/308a693c-2bd8-4d6b-8790-670aef5be601.snbt", + "world/ftbteams/player/308a693c-2bd8-4d6b-8790-670aef5be601.snbt", + "world/ftbteams/party/5e0c1a2b-0000-4000-8000-00000000abcd.snbt", + "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")) { + assertTrue(SyncPaths.isWanted(p), p); + } + } + + @Test + void skipsOtherLanguagesRewardTablesAndCombinedLangFile() { + for (String p : List.of( + "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", + "world/ftbteams/ftbteams.snbt", + "world/ftbteams/server/x.snbt")) { + assertFalse(SyncPaths.isWanted(p), p); + } + } +} diff --git a/mod/src/test/java/de/hahmann/atmstats/core/UuidV7Test.java b/mod/src/test/java/de/hahmann/atmstats/core/UuidV7Test.java new file mode 100644 index 0000000..d2e108c --- /dev/null +++ b/mod/src/test/java/de/hahmann/atmstats/core/UuidV7Test.java @@ -0,0 +1,33 @@ +package de.hahmann.atmstats.core; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.Test; + +class UuidV7Test { + @Test + void isVersion7AndMonotonic() { + List ids = new ArrayList<>(); + for (int i = 0; i < 1000; i++) { + UUID id = UuidV7.next(); + assertEquals(7, id.version()); + assertEquals(2, id.variant()); + ids.add(id.toString()); + } + List sorted = new ArrayList<>(ids); + sorted.sort(String::compareTo); + assertEquals(ids, sorted); + assertEquals(1000, ids.stream().distinct().count()); + } + + @Test + void encodesCurrentTime() { + long before = System.currentTimeMillis(); + UUID id = UuidV7.next(); + long ts = id.getMostSignificantBits() >>> 16; + assertEquals(true, ts >= before && ts <= System.currentTimeMillis() + 1); + } +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..e2e2e53 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,11789 @@ +{ + "name": "atm10-stats", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "atm10-stats", + "workspaces": [ + "shared", + "backend", + "web", + "tools/simulator" + ], + "engines": { + "node": ">=22" + } + }, + "backend": { + "name": "@atm/backend", + "version": "1.0.0", + "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" + }, + "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" + } + }, + "node_modules/@adobe/css-tools": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@alloc/quick-lru": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.3.0.tgz", + "integrity": "sha512-U4+70Pc5ZS9osnCBCE5Jha/ciHM+Yp+CNMNC/7HvYbNRk1Ldd+f7qO65W5qfhu/TCv+/ozljlXXe9Nj8419DMA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/@atm/backend": { + "resolved": "backend", + "link": true + }, + "node_modules/@atm/shared": { + "resolved": "shared", + "link": true + }, + "node_modules/@atm/simulator": { + "resolved": "tools/simulator", + "link": true + }, + "node_modules/@atm/web": { + "resolved": "web", + "link": true + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/code-frame/node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.10", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.10.tgz", + "integrity": "sha512-EMCx3AGZ3WfAl6ht7AyQoPm2Lh+BGYEMdc4Q6Hf7uSCDOZFuLq44qNwL+iaim8VlyYhRAo4X43+weadK8ZB4Yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz", + "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.10", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.10.tgz", + "integrity": "sha512-NpugTzbKgGrNe45Ui32cN8vCFFMxVtP/9rtfwv0nP5FRDAjvYBxJcLVpYA9mr1jxP6cnIT7x9LUzuDTbWYYNRA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.10", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.10.tgz", + "integrity": "sha512-B3lYAs2nkKskbgQ1nfy8Ma9ao9Q1rntTAQHrzzyDuj+TSO+X3le6PfWwqDvodsb+OumE10eMFfuMCTQSQ5iuIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.9", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@epic-web/invariant": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz", + "integrity": "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==", + "license": "MIT" + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.7.tgz", + "integrity": "sha512-F42g89Qd5oAWtp0k0nnSrjziAKza7w8SVT4mStc18LZMaRb4J1HQAHLCalEtDCxrTuksx7NU9qsmeLwpOfPqWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.3.2", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/js": { + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.5.tgz", + "integrity": "sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@fastify/ajv-compiler": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.7.tgz", + "integrity": "sha512-VP0yIfwlc1ohj733keYDqyHm1VWeP9VQxYomunhr90jkC4aNNibbkrRiaNgPgqj1ORxUenILR4qzA/ppYBNAtQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "dependencies": { + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^4.0.0" + } + }, + "node_modules/@fastify/cookie": { + "version": "11.1.3", + "resolved": "https://registry.npmjs.org/@fastify/cookie/-/cookie-11.1.3.tgz", + "integrity": "sha512-RBhlMNsmSG6C5KGiTkhSXBF75xIrUMSytDDBcpnVewhJ8XVYnJTu+SV6FYKXClIRsBuRjOfKbBSULvnPD3X/mg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "dependencies": { + "cookie": "^2.0.0", + "fastify-plugin": "^6.0.0" + } + }, + "node_modules/@fastify/deepmerge": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@fastify/deepmerge/-/deepmerge-3.2.2.tgz", + "integrity": "sha512-155eqA/WMQdN6gUD4RrhS2K0Pcz2Ou9pQ86L0sipOpP9ONbTA6oYw/hW4VdBjR4egcDHqmWUK9x80oUX7S1mDQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ] + }, + "node_modules/@fastify/error": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.1.tgz", + "integrity": "sha512-MYBumuIwP+5MIpXSyKm6/o1Eigcoij186lWUssX3LH5U8KWqU0ktZhzC4CErIX9EJeQeRGI+zJZQWakBlFbUcg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ] + }, + "node_modules/@fastify/fast-json-stringify-compiler": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-5.1.1.tgz", + "integrity": "sha512-pyxRKk7UpUnnrztP9pF4+ZYxusEQ4LifTyzRdiXmyql1QezNHxO02D25MVU2o4kMeAMYWAjG6RKRlojgkbiDAw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "dependencies": { + "fast-json-stringify": "^7.0.0" + } + }, + "node_modules/@fastify/forwarded": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@fastify/forwarded/-/forwarded-3.1.1.tgz", + "integrity": "sha512-XfDtW2Icvhqi6DP1YHFK4q/E8WpNd3+raBdzAyVZxmTInbFTtnEEKvEAMMOVdFuRyv+ynYipaA9D8FvjPqACkw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ] + }, + "node_modules/@fastify/helmet": { + "version": "13.1.2", + "resolved": "https://registry.npmjs.org/@fastify/helmet/-/helmet-13.1.2.tgz", + "integrity": "sha512-fXTultrEFW9bczr3jcffZD7NLlsTYa8eCCzVFsJvLIVO7SUiY/VWp18qCJbf2N9AXIEzIC11NYlNB3BlzBEZeg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "dependencies": { + "fastify-plugin": "^6.0.0", + "helmet": "^8.0.0" + } + }, + "node_modules/@fastify/merge-json-schemas": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.2.2.tgz", + "integrity": "sha512-d/gx9sRbUCVgGRGzma46wLcwiY8noTqO2Nmjg/vbTARxHRE5bdh5PIL8s97Lt1X7ScRWRW52eVYxwIoPwIdVUA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@fastify/proxy-addr": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@fastify/proxy-addr/-/proxy-addr-5.1.2.tgz", + "integrity": "sha512-uqB4QsgVoro0XDC+DIftIMGXHdBPn+lK6Ek3xXAIxPMCJfqxnbtyEoMCm2KPUVOuIosd7Cyw9HawEqMFM/UA+g==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "dependencies": { + "@fastify/forwarded": "^3.0.0", + "ipaddr.js": "^2.1.0" + } + }, + "node_modules/@fastify/rate-limit": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/@fastify/rate-limit/-/rate-limit-10.3.0.tgz", + "integrity": "sha512-eIGkG9XKQs0nyynatApA3EVrojHOuq4l6fhB4eeCk4PIOeadvOJz9/4w3vGI44Go17uaXOWEcPkaD8kuKm7g6Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "dependencies": { + "@lukeed/ms": "^2.0.2", + "fastify-plugin": "^5.0.0", + "toad-cache": "^3.7.0" + } + }, + "node_modules/@fastify/rate-limit/node_modules/fastify-plugin": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-5.1.0.tgz", + "integrity": "sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ] + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.5.tgz", + "integrity": "sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.3.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.5.tgz", + "integrity": "sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.3.4" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.5.tgz", + "integrity": "sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==", + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.4.tgz", + "integrity": "sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.4.tgz", + "integrity": "sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.4.tgz", + "integrity": "sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.4.tgz", + "integrity": "sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.4.tgz", + "integrity": "sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.4.tgz", + "integrity": "sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.4.tgz", + "integrity": "sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.4.tgz", + "integrity": "sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.4.tgz", + "integrity": "sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.4.tgz", + "integrity": "sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.5.tgz", + "integrity": "sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.5.tgz", + "integrity": "sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.5.tgz", + "integrity": "sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.5.tgz", + "integrity": "sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.5.tgz", + "integrity": "sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.5.tgz", + "integrity": "sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.3.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.5.tgz", + "integrity": "sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.3.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.5.tgz", + "integrity": "sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.3.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.5.tgz", + "integrity": "sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==", + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.11.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.5.tgz", + "integrity": "sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/sharp-wasm32": "0.35.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.5.tgz", + "integrity": "sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.5.tgz", + "integrity": "sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.5.tgz", + "integrity": "sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@lukeed/ms": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@lukeed/ms/-/ms-2.0.2.tgz", + "integrity": "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==", + "engines": { + "node": ">=8" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.5.tgz", + "integrity": "sha512-HshSvXzmBxNzqejd3k/KemgQ3hdREUYFRRrgxOZ+gPljDIsO2SDZrVZMd4Z/HsxudQbE62OVyVFE8z9BCurfjA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=23.5.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", + "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" + } + }, + "node_modules/@next/env": { + "version": "15.5.27", + "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.27.tgz", + "integrity": "sha512-WC5hvVqiuFOcRyz6gKPeJkiVvSMf3scNeAV49CdWtVqxmUlzh8UKR6YHt9yiNkh8LngtzqSlVz07/Ma3ZAzchA==", + "license": "MIT" + }, + "node_modules/@next/eslint-plugin-next": { + "version": "15.5.27", + "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-15.5.27.tgz", + "integrity": "sha512-a94xlzvOLaULh4Fzcvj9B2hwIOtPHJW75gghkXiJ8s1Jk0eo00UknrDVhx04Y+8ARu9ds13vy/jHg5u/PdVGqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-glob": "3.3.1" + } + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "15.5.27", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.27.tgz", + "integrity": "sha512-fBA9QurmK4+uj4wNhmRTuO5zMVRaBHCvp6H93zTG/5i6Yi6TewbvbesXB8YQNj8T99Cwrow87hHiFV6wL/hyMA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "15.5.27", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.27.tgz", + "integrity": "sha512-FGZwJwry8qZNHxD/nfwrCwCHHrqx6epqItkkxbYcBQEQ2uw5S43bY83fQB5zbAsZE7TELMjoYkbhsQ4oKM7wwg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "15.5.27", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.27.tgz", + "integrity": "sha512-GQulAaJYNF1RYuatlsw9/uzbKz4if2V7Ic9nVO+P0ljYPm4yBRTFIl6g5YkKTw40lMLbpy9edLoH44g3jSHMYA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "15.5.27", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.27.tgz", + "integrity": "sha512-URFFsUmu8Pi0/1qdnUI7tx7keZw1Mt5zHfrIX1RfDG9Nih8DYxK54WZsDJUX2ZTlii1jgotL7qcbwKnSAES6xQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "15.5.27", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.27.tgz", + "integrity": "sha512-aJp74z7uvU5jQhyH7lyk+4qSRcTdKbKT9NQE3jiO9kWYkoHZjKi0zzHDbCoZ+v2sJDEiJ/z58R0UlWMGWNF3HA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "15.5.27", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.27.tgz", + "integrity": "sha512-ILl2bQ8MbpvyMpY8AXljb3BXs0MJSC8HXZ6QV5AWpVS7ZELqNPtrHvhUk+SNarvjvICXX8bCS287TCi+gUFVYg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "15.5.27", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.27.tgz", + "integrity": "sha512-1MB9Rh5y8TOPN2x7naNTJNkCZQav9c70AI7EBYXxsEewwOffC4DjjwBwKf20NBvcA+u+5jDv7nVmHxunlP2d7Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "15.5.27", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.27.tgz", + "integrity": "sha512-XtYgpGOswgnyBWXzOEG6q9RhvnSw0CEZ0MbWqCsl5PISgt9EzvJvNW5mU5n1PsphX/lnamzxjgQXH2GUJL8nqg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nolyfill/is-core-module": { + "version": "1.0.39", + "resolved": "https://registry.npmjs.org/@nolyfill/is-core-module/-/is-core-module-1.0.39.tgz", + "integrity": "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.4.0" + } + }, + "node_modules/@phc/format": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@phc/format/-/format-1.0.0.tgz", + "integrity": "sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==" + }, + "node_modules/@playwright/test": { + "version": "1.64.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.64.0.tgz", + "integrity": "sha512-oTN9FNYuN6bqQMwiho6jJKLCG2/QOYCixGXuCjRmzOq23eODeukuY2XiAVoVpAECbuezMt1MHQjAiQxK8F+Y+g==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.64.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.64.5.tgz", + "integrity": "sha512-MrfgRQSpD5hkkRg6XI16B6EDLb+6+xp8xje6bpdSwkYw+qxrBKbUVrRAQu9h82UT+QumSIVQ8HXsXjPd2dlhqw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.64.5.tgz", + "integrity": "sha512-+uu2p/R9Tzn8GpoQyX1pAo5dmKT1DhXevfaCzqzmPFhnLjHLWw7uWkaVGlxmhwYoRiYYhsGfRcD5cTBpL7rgSw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.64.5.tgz", + "integrity": "sha512-17dys+YEmzwzzFVHQAVDONg5FALXY9kd99HFEpfk4gJVHYxoh5bGea3D4t1fXXnwkTcOe25wPyd6nWS0I8IdLQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.64.5.tgz", + "integrity": "sha512-wg0zQEKkEvFZf1UaaX1UELOoPs//OSKUG7D+yITuqOsAvza7LvHTv5Wu4nmMfzNG7EYI2jp2PJC7Id6OETmsYQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.64.5.tgz", + "integrity": "sha512-CoJmWcpEMuwin+TZ/ev/H9uDyF/uxUKQSWXdmIPn/UBx/uGV9+4ebMp0FwnlhWR9PE1AdzYsKf3bZevhif38+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.64.5.tgz", + "integrity": "sha512-PxZnLAaTUAL5xG0W2BXLeYPKrK2b3LIASV2/e/Kf7PZvSLI1iAQIm9B9aJD+YyFIT7hc6qaMV3+OOO35R28LfA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.64.5.tgz", + "integrity": "sha512-uPo3IvnONjhbFXwWgroBeFwOicuhT7ZwQrnzkzmOtBJzeQbkUJUE1SJDfDI31ijQ5+ZUIA6sPrdUMxTSnxUWAg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.64.5.tgz", + "integrity": "sha512-ywI8Jyuc4LyHYTjC0mw4yA1ulvZBkHusdpWnoiEBsmBuHvSnt90btTBGCZ+s/2y+YNNfVAEKy33nPo5UI9zTmg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.64.5.tgz", + "integrity": "sha512-tmuXJoQ/3ovSr637aykZMdQteU/CB2APAz2CrvsbibuoHZi1BS1Db13cVViBdEARGwXCLn1DSJ8wsjoThLJPGA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.64.5.tgz", + "integrity": "sha512-5FMjSYNcUOHx7oW8Qu9KB6jGPpvF/8sG7+BDHwU/Ihh1/6FMJ9ySmsFG4tJ1qbNRuP8OLzCBDso4sF0mQaLuFQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.64.5.tgz", + "integrity": "sha512-K8ZJ+neLDbUkIrL8dqF/5LtuVCrKwo27kREgOlxjBEjlOXwHvDnHLU8Z/UKv/B49qi7e8hsSDOyoJwhwqhvX4w==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.64.5.tgz", + "integrity": "sha512-NlVxsq7PFPv0DBKGuNWRDKixQYQCjM53zl04mxEcKMBmJbCBIUm1JdPfIWDRHyOOFzzYPmLQzfeEwh4716ayQg==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.64.5.tgz", + "integrity": "sha512-ARhRHJYs5eCFet5azfrHD97vOkD8OzNW2RSMlvo5CcBqXaTOLfp0OHDtUQh7/K5AL5fUYoFp2fgC9KDg4vVYMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.64.5.tgz", + "integrity": "sha512-atSgreM5C7jpxd9aEXN+6vJ5wTd05iYw+S3FiQpw7VsoEl8B90llbq35ARON+GOHj5QlVQEq2vOKWCHTob/BSQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.64.5.tgz", + "integrity": "sha512-vmlmwBFGnjVWxSRoVabNkyYuscSmg52Pd0fM8jEbNT3Jmka4A6HcB84x4uAmuumznWfBVaxKOqU6jbEgomWKCA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.64.5.tgz", + "integrity": "sha512-hhJrP6S9WkbsbcHgUkvCBS5Cn8IdsG8XjlLZyxjnv9+NdoYfbbZE4TWJYr8apdaj1M5t5Y1id2StnEslxpNFyA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.64.5.tgz", + "integrity": "sha512-NNVCyN9pGrwFmQa4bf+Cnf5aVH3g6ZpnlOg1Cs/H9swIHT9bRixijfzUqyEIWm1WtiAuR1LJ+rJH6zpBm8kgQA==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.64.5.tgz", + "integrity": "sha512-dR9nd6OKnvSS60Ifz/L7Mj5zzYyoqyRn2NwTuaOONKaa3xsVGXyp8YusMbvFyEOR94DxnwgcbgsFqkHd29ZSzQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.64.5.tgz", + "integrity": "sha512-1tzsMjaqhsh3bAO6SJiJqh8hIr9sSPRd+i2vPPxJHod8/XYnf9TEnK0z/u3r1z1AkI347X0KbRSg9dWcxeGahw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.64.5.tgz", + "integrity": "sha512-01mWMQblKoXXsmk28KXtpbA96w7g/aOWUoV3AC16fdHdqkykGalqyyM+5u8M7uP5/MEMiiPv69Hv7iv/hEH/dA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.64.5.tgz", + "integrity": "sha512-trdnMPb/DNm4V/CgsaoGgr90HfQQ071wfK0KoIs4YpLvjBgVQGXhGabg2Z6nK4EcYNilrb9ZZPcsP6+zpJIdxQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.64.5.tgz", + "integrity": "sha512-x2LxZlfdfsw99T6/qb9EqE/Qi84JWzibjyOJyhJsgFJy6CafaTiqIXCCnslCGrV5XWNh7fwrS3FbRENcuKnooA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.64.5.tgz", + "integrity": "sha512-/Qd/9aDQIo0+jmItgBmPIs8195YVbDxRRApn47GyufgbutCDOh7eP5vSKKlI4QNFeaI+wKQAkSMsPnF3EuMpaA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.64.5.tgz", + "integrity": "sha512-ngUJ/XvOWrqbd2IMsfXfh2xXFLTP3sY7sCKvxbGvYdBZ1KmXFd3ewDZ9n3/GNzAUgzRuhLT/UvJRivbNoAQRBw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.64.5.tgz", + "integrity": "sha512-IhV6ew/DuzUWwHaYmlh7MrPCV3lKFUq/amTEwysi4r/mwNPAEfAMh5dYWeTSPTeMy2pFHGMEWYtPkHzfX3tG5A==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rtsao/scc": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", + "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rushstack/eslint-patch": { + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.16.1.tgz", + "integrity": "sha512-TvZbIpeKqGQQ7X0zSCvPH9riMSFQFSggnfBjFZ1mEoILW+UuXCKwOoPcgjMwiUtRqFZ8jWhPJc4um14vC6I4ag==", + "dev": true, + "license": "MIT" + }, + "node_modules/@swc/helpers": { + "version": "0.5.15", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", + "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", + "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.24.1", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz", + "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-x64": "4.3.3", + "@tailwindcss/oxide-freebsd-x64": "4.3.3", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-x64-musl": "4.3.3", + "@tailwindcss/oxide-wasm32-wasi": "4.3.3", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", + "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", + "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", + "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", + "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", + "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", + "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", + "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", + "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", + "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", + "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", + "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", + "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/postcss": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.3.3.tgz", + "integrity": "sha512-JTSZZGQi1AyKirbLN3azmjVzef92tcX7h+iSqPdaeStyFpGpDlKvvpxeOE8njhbUanbRwr3z8DyzhICWnMtQeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "@tailwindcss/node": "4.3.3", + "@tailwindcss/oxide": "4.3.3", + "postcss": "^8.5.16", + "tailwindcss": "4.3.3" + } + }, + "node_modules/@testing-library/dom": { + "version": "10.4.2", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.2.tgz", + "integrity": "sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/dom/node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.3", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.3.tgz", + "integrity": "sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.4", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.4.tgz", + "integrity": "sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-kVd74ta9eof3eJOvbNd1vGKS/XERRyQbT26Og63hIsvDO84cjD5gEOhsXf26w3FSoNlPVz84DOFcKv/oou+fMw==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json5": { + "version": "0.0.29", + "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", + "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "dev": true, + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/pg": { + "version": "8.23.1", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.23.1.tgz", + "integrity": "sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A==", + "dev": true, + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, + "node_modules/@types/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", + "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.3.0" + } + }, + "node_modules/@types/ssh2": { + "version": "1.15.6", + "resolved": "https://registry.npmjs.org/@types/ssh2/-/ssh2-1.15.6.tgz", + "integrity": "sha512-oGdxhBqcRTwSTKFm+9EiKzkNVYRLEFkcW44lhguvBalGJbWfGnDt/ezwSUZc+SF9m9bMc3VyklNAtp7zICjS5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "^18.11.18" + } + }, + "node_modules/@types/ssh2-sftp-client": { + "version": "9.0.6", + "resolved": "https://registry.npmjs.org/@types/ssh2-sftp-client/-/ssh2-sftp-client-9.0.6.tgz", + "integrity": "sha512-4+KvXO/V77y9VjI2op2T8+RCGI/GXQAwR0q5Qkj/EJ5YSeyKszqZP6F8i3H3txYoBqjc7sgorqyvBP3+w1EHyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ssh2": "^1.0.0" + } + }, + "node_modules/@types/ssh2/node_modules/@types/node": { + "version": "18.19.130", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", + "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@types/ssh2/node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.71.2.tgz", + "integrity": "sha512-K72/pvT7qpm8q3k8G5u93NCSN1RcLxX5h7Gw0bQpbMXviBHeY6Nx1WEwmk9wP+eKTSQ8ecxvcgt/ZX/rB938xQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.71.2", + "@typescript-eslint/type-utils": "8.71.2", + "@typescript-eslint/utils": "8.71.2", + "@typescript-eslint/visitor-keys": "8.71.2", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.71.2", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.12", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.12.tgz", + "integrity": "sha512-/8UvqAPU9DGTI9k4mxtf49U37Isfwr8Uts96+SBHIkFxPJnHS0Ew4f00sM4Scd8V8EjM0jUNtVDdv1kPdt35lg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.71.2.tgz", + "integrity": "sha512-aTtmZjp/x3gsGHkDs04/1I8kNogBJkpQaeGaN0PfiG43NzDEmW2n+OP5im9wfsvyX0yP1UZWBrAGtDBfH9jEVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.71.2", + "@typescript-eslint/types": "8.71.2", + "@typescript-eslint/typescript-estree": "8.71.2", + "@typescript-eslint/visitor-keys": "8.71.2", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.71.2.tgz", + "integrity": "sha512-Pd68GsLVh5ZXlcEccFPdhSOvtHZ/2WU0XsA8zmXOS3F158lVlcTLhfQ6qvtL1RzbMie0Kp8xtbVwAg4nejKPHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.71.2", + "@typescript-eslint/types": "^8.71.2", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.71.2.tgz", + "integrity": "sha512-YF4wgg5iUX5Obywoac27B56FhozMNFhdJSbh11H5GBAwmBtfyRhmgTQqW9tRNNmHDvsZ2CX6UGzoz+YfYn2Leg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.71.2", + "@typescript-eslint/visitor-keys": "8.71.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.71.2.tgz", + "integrity": "sha512-jz49TAWokm/7yWD9/o837nYax9DyALxIPOImdImvVzo6canBCZYzin8zJyqNR5k1j9K1DE0bVvr0XYB+99uAdg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.71.2.tgz", + "integrity": "sha512-cC8UaalCViJkb3rH/xLIvidEwoFRtMZMUqDjjJMGEKaTFwYRHYBcoczlEGHuvkHEO8svRDrgZ0yQgOArFmwang==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.71.2", + "@typescript-eslint/typescript-estree": "8.71.2", + "@typescript-eslint/utils": "8.71.2", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.71.2.tgz", + "integrity": "sha512-y5Ukl3oVp7e/VGxqIoEXoeb/Roj71PAJ4WG/N/RGj+eeDvKRLM15kGr9MijLbN0i03S52zxu31Z98E5gWChJKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.71.2.tgz", + "integrity": "sha512-DgNOEsl4pdYzwSL+N/Cl33tX1Hlw3FvId73LnKXfq7jnrF2viOEn7X+P7RBkkSWCVm92gnWd28GIC5vJuLREeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.71.2", + "@typescript-eslint/tsconfig-utils": "8.71.2", + "@typescript-eslint/types": "8.71.2", + "@typescript-eslint/visitor-keys": "8.71.2", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.71.2.tgz", + "integrity": "sha512-RyKa1G1M9rKHDhrk+HJtiF1MvLD5Ux2JW3TSkzw+02pRALdpJb+QzG1Y5em+LvCgzGwgg8H7mBRFKljNz7ImPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.71.2", + "@typescript-eslint/types": "8.71.2", + "@typescript-eslint/typescript-estree": "8.71.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.71.2.tgz", + "integrity": "sha512-zozlN0UA32bSG2zkKAgQyRYSbEISVoyU6vvkKYo2aSny74D8Jh4e+POkJ6Lk0dlezZi5x7TtD+88Xb06IB8nhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.71.2", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@unrs/resolver-binding-android-arm-eabi": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.12.2.tgz", + "integrity": "sha512-g5T90pqg1bo/7mytQx6F4iBNC0Wsh9cu+z9veDbFjc7HjpesJFWD7QMS0NGStXM075+7dJPPVvBbpZlnrdpi/w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-android-arm64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.12.2.tgz", + "integrity": "sha512-YGCRZv/9GLhwmz6mYDeTsm/92BAyR28l6c2ReweVW5pWgfsitWLY8upvfRlGdoyD8HjeTHSYJWyZGD4KJA/nFQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-arm64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.12.2.tgz", + "integrity": "sha512-u9DiNT1auQMO20A9SyTuG3wUgQWB9Z7KjAg0uFuCDR1FsAY8A0CG2S6JpHS1xwm/w1G08bjXZDcyOCjv1WAm2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-x64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.12.2.tgz", + "integrity": "sha512-f7rPLi/T1HVKZu/u6t87lroib16n8vrSzcyxI7lg4BGO9UF26KhQL44sd9eOUgrTYhvRXtWOIZT5PejdPyJfUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-freebsd-x64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.12.2.tgz", + "integrity": "sha512-BpcOjWCJub6nRZUS2zA20pmLvjtqAtGejETaIyRLiZiQf++cbrjltLA5NN/xaXfqeOBOSlMFbemIl5/S5tljmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.12.2.tgz", + "integrity": "sha512-vZTDvdSISZjJx66OzJqtsOhzifbqRjbmI1Mnu49fQDwog5GtDI4QidRiEAYbZCRj9C8YZEW+3ZjqsyS9GR4k2A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.12.2.tgz", + "integrity": "sha512-BiPI+IrIlwcW4nLLMM21+B1dFPzd55yAVgVGrdgDjNef+ch03GdxrcyaIz8X9SsQirh/kCQ7mviyWlMxdh2D7g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.12.2.tgz", + "integrity": "sha512-zJc0H99FEPoFfSrNpa91HYfxzfAJCr502oxNK1cfdC9hlaFI43RT+JFCann9JUgZmLzzntChHyn13Sgn9ljHNg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.12.2.tgz", + "integrity": "sha512-KQ3Lki6l+Pz1k/eBipN41ES+YUK30beLGb9YqcB1O542cyLCNE6GaxrfcY3T6EezmGGk84wb5XyO9loTM9tkcA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-loong64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-loong64-gnu/-/resolver-binding-linux-loong64-gnu-1.12.2.tgz", + "integrity": "sha512-3SJGEh1DborhG6pyxvhPzCT4bbSIVihsvgJc13P1bHG7KLdNDaF9T3gsTwFc7Jw/5Y5/iWOjkEx7Zy0NvCGX3Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-loong64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-loong64-musl/-/resolver-binding-linux-loong64-musl-1.12.2.tgz", + "integrity": "sha512-jiuG/Obbel7uw1PwHNFfrkiKhLAF6mnyZ6aWlOAVN9WqKm8v0OFGnciJIHu8+CMvXLQ8AD51LPzAoUfT21D5Ew==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.12.2.tgz", + "integrity": "sha512-q7xRvVpmcfeL+LlZg8Pbbo6QaTZwDU5BaGZbwfhkEsXJn3Was8xYfE0RBH266xZt0rM6B7i8xAYIvjthuUIWHg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.12.2.tgz", + "integrity": "sha512-0CVdx6lcnT3Q9inOH8tsMIOJ6ImndllMjqJHg8RLVdB7Vq4SfkEXl9mCSsVNuNA4MCYycRicCUxPCabVHJRr6A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.12.2.tgz", + "integrity": "sha512-iOwlRo9vnp6R6ohHQS11n0NnfdXx/omhkocmIfaPRpQhKZ+3BDMkkdRVh53qjkFkpPddf+FETA28NwGN7l5l+w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.12.2.tgz", + "integrity": "sha512-HYJtLfXq94q8iZNFT1lknx258wlkkWhZeUXJRqzKBBUJ00CvZ+N33zgbCqimLjsyw5Va6uUxhVa12mI+kaveEw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-gnu": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.12.2.tgz", + "integrity": "sha512-mPsUhunKKDih5O96Y6enDQyHc1SqBPlY1E/SfMWDM3EdJ95Z9CArPeCVwCCqbP45ljvivdEk8Fxn+SIb1rDAJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-musl": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.12.2.tgz", + "integrity": "sha512-azrt6+5ydLd8Vt210AAFis/lZevSfPw93EJRIJG+xPu4WCJ8K0kppCTpMyLPcKT7H15M4Jnt2tMp5bOvCkRC6A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-openharmony-arm64": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-openharmony-arm64/-/resolver-binding-openharmony-arm64-1.12.2.tgz", + "integrity": "sha512-YZ9hP4O0X9PQb8eO980qmLNGH4zT3I9+SZTdt0Pr0YyuGQhYKoOZkV02VzrzyOZJ5xIJ3UFIenKkUkGg8GjgWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.12.2.tgz", + "integrity": "sha512-tYFDIkMxSflfEc/h92ZWNsZlHSwgimbNHSO3PL2JWQHfCuC2q316jMyYU9TIWZsFK2bQwyK5VAdYgn8ygPj69A==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.12.2.tgz", + "integrity": "sha512-qzNyg3xL0VPQmCaUh+N5jSitce6k+uCBfMDesWRnlULOZaqUkaJ0ybdT+UqlAWJoQjuqfIU/0Ptx9bteN4D82g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.12.2.tgz", + "integrity": "sha512-WD9sY00OfpHVGfsnHZoA8jVT+esS/Bg8z8jzxp5BnDCjjwsuKsPQrzswwpFy4J1AUJbXPRfkpcX0mXrzeXW79g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-x64-msvc": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.12.2.tgz", + "integrity": "sha512-nAB74NfSNKknqQ1RrYj6uz8FcXEomu/MATJZxh/x+BArzN2U3JbOYC0APYzUIGhVY3m5hRxA8VPNdPBoG8txlA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/@vitest/expect": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz", + "integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==", + "dev": true, + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz", + "integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==", + "dev": true, + "dependencies": { + "@vitest/spy": "3.2.7", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz", + "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==", + "dev": true, + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz", + "integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==", + "dev": true, + "dependencies": { + "@vitest/utils": "3.2.7", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz", + "integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==", + "dev": true, + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz", + "integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==", + "dev": true, + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz", + "integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==", + "dev": true, + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/abstract-logging": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", + "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==" + }, + "node_modules/acorn": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.19.0.tgz", + "integrity": "sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw==", + "dev": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv/node_modules/fast-uri": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", + "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ] + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true + }, + "node_modules/argon2": { + "version": "0.44.0", + "resolved": "https://registry.npmjs.org/argon2/-/argon2-0.44.0.tgz", + "integrity": "sha512-zHPGN3S55sihSQo0dBbK0A5qpi2R31z7HZDZnry3ifOyj8bZZnpZND2gpmhnRGO1V/d555RwBqIK5W4Mrmv3ig==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@phc/format": "^1.0.0", + "cross-env": "^10.0.0", + "node-addon-api": "^8.5.0", + "node-gyp-build": "^4.8.4" + }, + "engines": { + "node": ">=16.17.0" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-includes": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.2.0.tgz", + "integrity": "sha512-VXY5eFRarnXcYxwBjJzPmEhH55+rmP79/+ueDhi0F+TuqfHCItagIHqxeUZrmgrOPa31QTh9H85DjX3FfJ0FTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.2", + "es-object-atoms": "^1.1.2", + "es-shim-unscopables": "^1.1.0", + "is-string": "^1.1.1", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlast": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", + "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlastindex": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.6.tgz", + "integrity": "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-shim-unscopables": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flat": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", + "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flatmap": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", + "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.tosorted": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz", + "integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3", + "es-errors": "^1.3.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/asn1": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", + "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": "~2.1.0" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/ast-types-flow": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", + "integrity": "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/avvio": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/avvio/-/avvio-9.3.1.tgz", + "integrity": "sha512-QyTKW61/qlba+cUQBm/sT9X8lri4UdomqSDwCnrnj1IvLVDRRiXRkz4CgBRWB+/cmfC7SK5WQc8T1vSdGvaUcQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "dependencies": { + "@fastify/error": "^4.0.0", + "fastq": "^1.17.1" + } + }, + "node_modules/axe-core": { + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.14.0.tgz", + "integrity": "sha512-9WTZxEjsZ7b13TH8JPmbV2z8CHbl80/2hm3XPEG4JgNdQLK81IBRXmSxHfMAOkSqQeRxT/0dwNDz2GOm3zzpcQ==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.28", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.28.tgz", + "integrity": "sha512-br0LOmjJqr6Rt4AEkadbcNf67Zpo7EPwKsWbjnRrJIa1QYFHQBqM4C7F2WTbV8ahTEcg1Eo+Y0aWjjORInYFyA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/bcrypt-pbkdf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", + "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==", + "license": "BSD-3-Clause", + "dependencies": { + "tweetnacl": "^0.14.3" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.29.3", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.3.tgz", + "integrity": "sha512-1R4kiYKXGViqEN0CnoDrXc1StD9niAwu+j2dukWzrD4bJgsD4lDmEp0CRbc6E/vYJIfTHwPmwyaKtVSudICdPA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.26", + "caniuse-lite": "^1.0.30001813", + "electron-to-chromium": "^1.5.439", + "node-releases": "^2.0.57", + "update-browserslist-db": "^1.3.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/buildcheck": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.7.tgz", + "integrity": "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==", + "optional": true, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/bundle-require": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/bundle-require/-/bundle-require-5.1.0.tgz", + "integrity": "sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==", + "dev": true, + "dependencies": { + "load-tsconfig": "^0.2.3" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "peerDependencies": { + "esbuild": ">=0.18" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001815", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001815.tgz", + "integrity": "sha512-uDybUEyfgbyJJ93J1vikTylBX31AkdVYeVPeffMQpf8XN7hSfr0oZOn4VFOIMNvtU5+rHvC5Udg2TgtJKHm23Q==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "engines": { + "node": ">= 16" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", + "license": "MIT" + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "dev": true + }, + "node_modules/consola": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", + "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", + "dev": true, + "engines": { + "node": "^14.18.0 || >=16.10.0" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-2.0.1.tgz", + "integrity": "sha512-yuToqVvRrj6pfDXREyQAAv8SkAEk/8GS3jQRTiUMm66TVtBYmqQeoEjL2Lmq8Rpo6271vH76InTChTitEAm65w==", + "engines": { + "node": ">=22" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cpu-features": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.10.tgz", + "integrity": "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==", + "hasInstallScript": true, + "optional": true, + "dependencies": { + "buildcheck": "~0.0.6", + "nan": "^2.19.0" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/cross-env": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz", + "integrity": "sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==", + "license": "MIT", + "dependencies": { + "@epic-web/invariant": "^1.0.0", + "cross-spawn": "^7.0.6" + }, + "bin": { + "cross-env": "dist/bin/cross-env.js", + "cross-env-shell": "dist/bin/cross-env-shell.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/cssstyle/node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/damerau-levenshtein": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", + "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "devOptional": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT" + }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.452", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.452.tgz", + "integrity": "sha512-cVvk1YBSj6571VmV6hdJTqj94Y79xSu8cj6Y9t6ae4Y4431z8/7EJFvu3rVp90xhfuv9BpPKUmaLfTo4+FOGBg==", + "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/enhanced-resolve": { + "version": "5.26.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.26.0.tgz", + "integrity": "sha512-9vhedylFonb2YGogzUKX6+Ja72gOJbN1QHAqdrvqLwhdl/QWbKopzoUC9EbQNVsAns/bx/4uyqalrQAoy1IByw==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-abstract": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz", + "integrity": "sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.2.1", + "is-set": "^2.0.3", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.1", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.4", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.4", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.19" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-abstract-get": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es-abstract-get/-/es-abstract-get-1.0.0.tgz", + "integrity": "sha512-6PMWXpdhshVvFp+FoWYs1EvG1Nj0tvk0dZM+XcK0xMEM1czRVcP6ohqPWHy6qPagSpC8j4+p89WXlT+xXJs/fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.2", + "is-callable": "^1.2.7", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-iterator-helpers": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.4.0.tgz", + "integrity": "sha512-c/A0P0oxkACDc+cKWw8evLXK83oBKgn0qPOqCYT4x9uolpCIJAcYvJC9QYKNDRPsTeGyCrQ326jrvgZWdCdK5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.2", + "es-errors": "^1.3.0", + "es-set-tostringtag": "^2.1.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.3.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "iterator.prototype": "^1.1.5", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-shim-unscopables": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", + "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.4.tgz", + "integrity": "sha512-yPDz7wqpg1/mmHLmS3tcfTfbw5f1eryXvyghYBffGdERwe+mV7ZcWzTR8LR17Kvqt3qfPurjlonmnq3MKXIOXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-abstract-get": "^1.0.0", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "is-callable": "^1.2.7", + "is-date-object": "^1.1.0", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.5", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.5.tgz", + "integrity": "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.6", + "@eslint/js": "9.39.5", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-config-next": { + "version": "15.5.27", + "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-15.5.27.tgz", + "integrity": "sha512-SlpZ5Ch1bM+GNHxahIoCy5OVgbpIHzHoGQDjHo6vEhxvYMukWYVo++AEh9RWYaIxH1i38PO7ArzSdmTruswOew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@next/eslint-plugin-next": "15.5.27", + "@rushstack/eslint-patch": "^1.10.3", + "@typescript-eslint/eslint-plugin": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", + "@typescript-eslint/parser": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", + "eslint-import-resolver-node": "^0.3.6", + "eslint-import-resolver-typescript": "^3.5.2", + "eslint-plugin-import": "^2.31.0", + "eslint-plugin-jsx-a11y": "^6.10.0", + "eslint-plugin-react": "^7.37.0", + "eslint-plugin-react-hooks": "^5.0.0" + }, + "peerDependencies": { + "eslint": "^7.23.0 || ^8.0.0 || ^9.0.0", + "typescript": ">=3.3.1" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/eslint-import-resolver-node": { + "version": "0.3.10", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.10.tgz", + "integrity": "sha512-tRrKqFyCaKict5hOd244sL6EQFNycnMQnBe+j8uqGNXYzsImGbGUU4ibtoaBmv5FLwJwcFJNeg1GeVjQfbMrDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.2.7", + "is-core-module": "^2.16.1", + "resolve": "^2.0.0-next.6" + } + }, + "node_modules/eslint-import-resolver-node/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-import-resolver-typescript": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-3.10.1.tgz", + "integrity": "sha512-A1rHYb06zjMGAxdLSkN2fXPBwuSaQ0iO5M/hdyS0Ajj1VBaRp0sPD3dn1FhME3c/JluGFbwSxyCfqdSbtQLAHQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@nolyfill/is-core-module": "1.0.39", + "debug": "^4.4.0", + "get-tsconfig": "^4.10.0", + "is-bun-module": "^2.0.0", + "stable-hash": "^0.0.5", + "tinyglobby": "^0.2.13", + "unrs-resolver": "^1.6.2" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-import-resolver-typescript" + }, + "peerDependencies": { + "eslint": "*", + "eslint-plugin-import": "*", + "eslint-plugin-import-x": "*" + }, + "peerDependenciesMeta": { + "eslint-plugin-import": { + "optional": true + }, + "eslint-plugin-import-x": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils": { + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.14.0.tgz", + "integrity": "sha512-W2WCRZ9Dqntd+2u8jJcVMV2PKulc6RdLgUUoh/yQr3uB6lo/ZOeGx11sv60/8S4QFFKNslAlWhr9u0Ef7ZW6Ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.2.7" + }, + "engines": { + "node": ">=4" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-import": { + "version": "2.32.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", + "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rtsao/scc": "^1.1.0", + "array-includes": "^3.1.9", + "array.prototype.findlastindex": "^1.2.6", + "array.prototype.flat": "^1.3.3", + "array.prototype.flatmap": "^1.3.3", + "debug": "^3.2.7", + "doctrine": "^2.1.0", + "eslint-import-resolver-node": "^0.3.9", + "eslint-module-utils": "^2.12.1", + "hasown": "^2.0.2", + "is-core-module": "^2.16.1", + "is-glob": "^4.0.3", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "object.groupby": "^1.0.3", + "object.values": "^1.2.1", + "semver": "^6.3.1", + "string.prototype.trimend": "^1.0.9", + "tsconfig-paths": "^3.15.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" + } + }, + "node_modules/eslint-plugin-import/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-import/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/eslint-plugin-jsx-a11y": { + "version": "6.10.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.10.2.tgz", + "integrity": "sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "aria-query": "^5.3.2", + "array-includes": "^3.1.8", + "array.prototype.flatmap": "^1.3.2", + "ast-types-flow": "^0.0.8", + "axe-core": "^4.10.0", + "axobject-query": "^4.1.0", + "damerau-levenshtein": "^1.0.8", + "emoji-regex": "^9.2.2", + "hasown": "^2.0.2", + "jsx-ast-utils": "^3.3.5", + "language-tags": "^1.0.9", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "safe-regex-test": "^1.0.3", + "string.prototype.includes": "^2.0.1" + }, + "engines": { + "node": ">=4.0" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9" + } + }, + "node_modules/eslint-plugin-react": { + "version": "7.37.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", + "integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.8", + "array.prototype.findlast": "^1.2.5", + "array.prototype.flatmap": "^1.3.3", + "array.prototype.tosorted": "^1.1.4", + "doctrine": "^2.1.0", + "es-iterator-helpers": "^1.2.1", + "estraverse": "^5.3.0", + "hasown": "^2.0.2", + "jsx-ast-utils": "^2.4.1 || ^3.0.0", + "minimatch": "^3.1.2", + "object.entries": "^1.1.9", + "object.fromentries": "^2.0.8", + "object.values": "^1.2.1", + "prop-types": "^15.8.1", + "resolve": "^2.0.0-next.5", + "semver": "^6.3.1", + "string.prototype.matchall": "^4.0.12", + "string.prototype.repeat": "^1.0.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz", + "integrity": "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/eslint/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "license": "MIT" + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-decode-uri-component": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", + "integrity": "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" + }, + "node_modules/fast-equals": { + "version": "5.4.4", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.4.tgz", + "integrity": "sha512-d9/I4rfMfUlO9HOkQqLJgfyRM4siqk1SF3Gjxm4OAMZzV+kCKHe1t/bHxn3xtCPHs3HCQFcvqzkTuanzqECQxw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/fast-glob": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz", + "integrity": "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.4" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stringify": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-7.1.0.tgz", + "integrity": "sha512-wAhAQ+es0aH+x5OofdCHlZZFdqwtiM6lpEosaRIwfg271WYeEQ/tPcXdu5vpzlqZpbJ4WNqaW4QR5UjfG4Dc2w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "dependencies": { + "@fastify/merge-json-schemas": "^0.2.0", + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^4.0.0", + "json-schema-ref-resolver": "^3.0.0", + "rfdc": "^1.2.0" + } + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-querystring": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz", + "integrity": "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==", + "dependencies": { + "fast-decode-uri-component": "^1.0.1" + } + }, + "node_modules/fast-uri": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-4.2.2.tgz", + "integrity": "sha512-EwVpUMzLVNOmcWW0zi5G3JJwkopB6XXjnVvNBGfqrMMsVew2hkQWl5e/pe/CfYUeS6jt4cIO2MnamEW7v/7wPw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ] + }, + "node_modules/fastify": { + "version": "5.12.5", + "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.12.5.tgz", + "integrity": "sha512-OB2k1dlxs5/NAABqeKV2FUHkSD2BbENsCak8yULVcymn3fHIPDVa9TI3SDnJSWYSllZmSYuZXy2gTnsT+Sut1A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "dependencies": { + "@fastify/ajv-compiler": "^4.0.5", + "@fastify/error": "^4.0.0", + "@fastify/fast-json-stringify-compiler": "^5.0.0", + "@fastify/proxy-addr": "^5.0.0", + "abstract-logging": "^2.0.1", + "avvio": "^9.0.0", + "fast-json-stringify": "^7.0.0", + "find-my-way": "^9.6.0", + "light-my-request": "^6.0.0", + "pino": "^9.14.0 || ^10.1.0", + "process-warning": "^5.1.0", + "rfdc": "^1.3.1", + "secure-json-parse": "^4.0.0", + "semver": "^7.6.0", + "toad-cache": "^3.7.0" + } + }, + "node_modules/fastify-plugin": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-6.0.1.tgz", + "integrity": "sha512-4wW4RxSmvPXLrh5mgQEiu43qU3bw44N2ss4FTkTkQIbprUhE+/s43QX0j/ttcztdWK7PEkHqVthdc5GdEGDvjA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ] + }, + "node_modules/fastq": { + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.3.tgz", + "integrity": "sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-my-way": { + "version": "9.10.0", + "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.10.0.tgz", + "integrity": "sha512-eru+uCPQ2g+nLBkf84MHP/KyNNMar0BDy0j+i8Fb72GF9/YHBZ58q7FODUtfVV7XU9nfRUHXSmaM8NmZcIbZ4Q==", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-querystring": "^1.0.0", + "safe-regex2": "^5.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/fix-dts-default-cjs-exports": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fix-dts-default-cjs-exports/-/fix-dts-default-cjs-exports-1.0.1.tgz", + "integrity": "sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==", + "dev": true, + "dependencies": { + "magic-string": "^0.30.17", + "mlly": "^1.7.4", + "rollup": "^4.34.8" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function.prototype.name": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.2.0.tgz", + "integrity": "sha512-jObKIik1P2QjPHP5nz5BaOtUlfgS0fWo8IUByNXkM+o+02sJOi94em77GwJKQSJ3gfPHdgzLNrHc1uokV4P/ew==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2", + "hasown": "^2.0.4", + "is-callable": "^1.2.7", + "is-document.all": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-symbol-description": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-tsconfig": { + "version": "4.14.3", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.3.tgz", + "integrity": "sha512-++QEw4DIY7WGoukz+/+A/8dGYPT9l9yIadnmSgZ8Rjr3YVSVDipQSO9CdnJo9ePqFqUUqh+wk9uIaoiAwsiPkA==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/helmet": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.3.0.tgz", + "integrity": "sha512-Qgpiaws3Sm30Av8Eah6sjMCZZwjlBu+E68rhpCWBshY1lb09HtLwj5GviX0OyQIn+ulUS0iX0AxN5n3tLZzz1w==", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/EvanHahn" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/import-fresh/node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/ipaddr.js": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.5.0.tgz", + "integrity": "sha512-aq+t5NAc+cS6rZQQVWC2x98CPqGtKKTMDd4Gaodv0wShnItdKg/51djkGJ1hqH+Oy0ivDftCbSLCQob8zso01w==", + "engines": { + "node": ">= 10" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bun-module": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-bun-module/-/is-bun-module-2.0.0.tgz", + "integrity": "sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.7.1" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.17.0", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.17.0.tgz", + "integrity": "sha512-J/vG0zBCbIKOQFfufSwyXdMrsohyJIUNkrnmo6WZGzoM7tr/lsbfW5b2BvisL6zsyMzK9UxV9L6c7AoFbyXHOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-data-view": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-document.all": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-document.all/-/is-document.all-1.0.0.tgz", + "integrity": "sha512-+XSoyS05OdBbhFuELhgTCpFNHkpBOJqtsZfUFFpe5QTw+9Sjbh8zitxhQkYAo6wV7e1Vb8cAPvpCk9jGam/82g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/iterator.prototype": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", + "integrity": "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "get-proto": "^1.0.0", + "has-symbols": "^1.1.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/joycon": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", + "integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==", + "dev": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true + }, + "node_modules/js-yaml": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsdom": { + "version": "25.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-25.0.1.tgz", + "integrity": "sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.1.0", + "data-urls": "^5.0.0", + "decimal.js": "^10.4.3", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.5", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.12", + "parse5": "^7.1.2", + "rrweb-cssom": "^0.7.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.0.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^2.11.2" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-ref-resolver": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.1.tgz", + "integrity": "sha512-w+HwgljXpVwZ3IidoUR1qPZxSOQ+NRQWuVe6a5pe8XbHsNZE4UAwVPUvzZagn7yBKbJtt6UJnRFYbNTRPZI45Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", + "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.0" + }, + "bin": { + "json5": "lib/cli.js" + } + }, + "node_modules/jsx-ast-utils": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", + "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.6", + "array.prototype.flat": "^1.3.1", + "object.assign": "^4.1.4", + "object.values": "^1.1.6" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/language-subtag-registry": { + "version": "0.3.23", + "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz", + "integrity": "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/language-tags": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/language-tags/-/language-tags-1.0.9.tgz", + "integrity": "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==", + "dev": true, + "license": "MIT", + "dependencies": { + "language-subtag-registry": "^0.3.20" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/light-my-request": { + "version": "6.8.0", + "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.8.0.tgz", + "integrity": "sha512-5Q8PjoSEdtGIB8fikZsTem5vgRo4F2mR6vUKTDW/H1g6MtAt8e3duQbg/QxL5yzg6BZhor2KOUm3hhWMWLTZwg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "dependencies": { + "@fastify/deepmerge": "^3.1.0", + "cookie": "^2.0.0", + "process-warning": "^5.0.0", + "set-cookie-parser": "^3.0.1" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true + }, + "node_modules/load-tsconfig": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/load-tsconfig/-/load-tsconfig-0.2.5.tgz", + "integrity": "sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==", + "dev": true, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/loose-envify/node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mlly": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", + "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==", + "dev": true, + "dependencies": { + "acorn": "^8.16.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.3" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nan": { + "version": "2.29.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.29.0.tgz", + "integrity": "sha512-GlGk3HIvitbvs+LT3g6XUP1kpirKNvmDFwF/bmo6XNWSb/eYEs/O4bfgIEIXCZ+lIOTS5xNwDvSGMw6FJdAhtA==", + "license": "MIT", + "optional": true + }, + "node_modules/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/napi-postinstall": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", + "integrity": "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==", + "dev": true, + "license": "MIT", + "bin": { + "napi-postinstall": "lib/cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/napi-postinstall" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/next": { + "version": "15.5.27", + "resolved": "https://registry.npmjs.org/next/-/next-15.5.27.tgz", + "integrity": "sha512-F82CrlPZ8GRxBH9RIIoR3qU1t5RT3dv1K8moe8SjlDEquiFipvEI13R1Om90TaUOEwShkWCMlg8+gvTNzfMhvg==", + "license": "MIT", + "dependencies": { + "@next/env": "15.5.27", + "@swc/helpers": "0.5.15", + "caniuse-lite": "^1.0.30001579", + "postcss": "8.4.31", + "styled-jsx": "5.1.6" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "node": "^18.18.0 || ^19.8.0 || >= 20.0.0" + }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "15.5.27", + "@next/swc-darwin-x64": "15.5.27", + "@next/swc-linux-arm64-gnu": "15.5.27", + "@next/swc-linux-arm64-musl": "15.5.27", + "@next/swc-linux-x64-gnu": "15.5.27", + "@next/swc-linux-x64-musl": "15.5.27", + "@next/swc-win32-arm64-msvc": "15.5.27", + "@next/swc-win32-x64-msvc": "15.5.27", + "sharp": "^0.34.3 || ^0.35.4" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0", + "@playwright/test": "^1.51.1", + "babel-plugin-react-compiler": "*", + "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "sass": "^1.3.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@playwright/test": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "sass": { + "optional": true + } + } + }, + "node_modules/next/node_modules/postcss": { + "version": "8.4.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.6", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/node-addon-api": { + "version": "8.9.2", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.2.tgz", + "integrity": "sha512-VijLXbi3UACN69I0JVXJsX4tjACjNoQDgv2gTF6sx2wWEi8tkSg2eX8p5gSIFi8z2+DL3oHmY6OyKce38SDolg==", + "license": "MIT", + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, + "node_modules/node-exports-info": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.2.tgz", + "integrity": "sha512-kXs9Go0cah0qHVV2v389IXQLdLCeE1xfFtjOAF+iobu0OIoG1pje8At2vMHyaPMiPMnG/LWP50twML21eMcAag==", + "dev": true, + "license": "MIT", + "dependencies": { + "array.prototype.flatmap": "^1.3.3", + "es-errors": "^1.3.0", + "object.entries": "^1.1.9", + "semver": "^6.3.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/node-exports-info/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, + "node_modules/node-releases": { + "version": "2.0.58", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.58.tgz", + "integrity": "sha512-BdiKAgbfRlvfMq/XzO/AF9NPmaA8a+YpLAyAFTVFBj3gARrAIfWvNq7Q7NE4se3KgFu0KkrvQMxTS0XVnsGzHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/nwsapi": { + "version": "2.2.28", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.28.tgz", + "integrity": "sha512-IlVB7OS7qrOsVYlpnFIkETjMwT9jwvmocJmmM+GZU/PAB3uGi9Ezd7vcWhWBUnSc0ya4ppmQITOyP1ez9gg8cg==", + "dev": true, + "license": "MIT" + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.entries": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz", + "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.fromentries": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", + "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.groupby": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.3.tgz", + "integrity": "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.values": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", + "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/own-keys": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.2.tgz", + "integrity": "sha512-19YVAg7T+WTrxggPukVq7DjTv6+PJ867TmhCvBsYwmbFCsZd344rq2Ld1p0wo8f8Qrrhgp82c6FJRqdXWtSEhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "get-intrinsic": "^1.3.0", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/pg": { + "version": "8.23.1", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.1.tgz", + "integrity": "sha512-aL96AHANtWjPLDOLqnhx+ngp9+UK7ETEU8VJrDCGvsSSi/mGLcWYsS6Herg7lmaBJe4uwrfqsa7gTEFaSizDoQ==", + "dependencies": { + "pg-connection-string": "^2.14.1", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.16.1", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.4.1" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.1.tgz", + "integrity": "sha512-6PQbsFWZcp9EmJEwy5cGQ2La+AMWpP46lgbb8X+U/XsHIUweYDNCpeuKck5RxL2MdVFi7krbbEi5nX4Zh7JhrQ==", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.1.tgz", + "integrity": "sha512-qR3kGNPBLpCNtz0evbKA0Y/MRFXwSSdT+pTJvYp/bXTcReZbvX1kzF0IyTc1QnxqF7AZbOeBhNL8R5mYQZV/MA==" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.1.tgz", + "integrity": "sha512-p9VOFMiHB/ZbJATetbg+99PxssTVSQRnyuPSQ67mN1+1KBOjZaZ83ZQzltnxPhJwSsC3nwVjJ10DVJlerbFzLg==" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pino": { + "version": "9.14.0", + "resolved": "https://registry.npmjs.org/pino/-/pino-9.14.0.tgz", + "integrity": "sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^2.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^3.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-2.0.0.tgz", + "integrity": "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==" + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "dev": true, + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, + "node_modules/playwright": { + "version": "1.64.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.0.tgz", + "integrity": "sha512-kVzTnYFYEyQ9RGzBjNCd2+yEqWmVbxl3AHPynv3qQ6JTjv4a+kwRcnqQwIpuP3NnrnIrE/Q6wWAIAvMfIE2/Yg==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.64.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.64.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.0.tgz", + "integrity": "sha512-T9r+MZkTECl2+oUcZ26YgZzTrYidTsIhYYkJix+6iDOymOb1FB2RLLSOxRGnZGv5DStt+aduIVbt+E8WygXYpg==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/postcss": { + "version": "8.5.30", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.30.tgz", + "integrity": "sha512-zkd+e9S1AQHhmaBKye9zlFHWjOwVgqXl1q3ECYy8561K7qxR2cREf/STLskulBFd78k6AOFeA+ff1TtEzk9bAA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.20", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/pretty-format/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT" + }, + "node_modules/process-warning": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.1.1.tgz", + "integrity": "sha512-oCkNVj4OKnKEcocVBcDfiIugN1/cE6rgfT3nCtzeqZPA8+rIxMaQ0xXzIkGP8Gj2qUaP1UR7uhJ1VD8lUCpzCg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ] + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==" + }, + "node_modules/react": { + "version": "19.1.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", + "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.1.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", + "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.26.0" + }, + "peerDependencies": { + "react": "^19.1.0" + } + }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-smooth": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz", + "integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==", + "license": "MIT", + "dependencies": { + "fast-equals": "^5.0.1", + "prop-types": "^15.8.1", + "react-transition-group": "^4.4.5" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/recharts": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz", + "integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==", + "deprecated": "1.x and 2.x branches are no longer active. Bump to Recharts v3 to receive latest features and bugfixes. See https://github.com/recharts/recharts/wiki/3.0-migration-guide", + "license": "MIT", + "dependencies": { + "clsx": "^2.0.0", + "eventemitter3": "^4.0.1", + "lodash": "^4.17.21", + "react-is": "^18.3.1", + "react-smooth": "^4.0.4", + "recharts-scale": "^0.4.4", + "tiny-invariant": "^1.3.1", + "victory-vendor": "^36.6.8" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/recharts-scale": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz", + "integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==", + "license": "MIT", + "dependencies": { + "decimal.js-light": "^2.4.1" + } + }, + "node_modules/recharts/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve": { + "version": "2.0.0-next.7", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz", + "integrity": "sha512-tqt+NBWwyaMgw3zDsnygx4CByWjQEJHOPMdslYhppaQSJUtL/D4JO9CcBBlhPoI8lz9oJIDXkwXfhF4aWqP8xQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.2", + "node-exports-info": "^1.6.0", + "object-keys": "^1.1.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/ret": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz", + "integrity": "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==", + "engines": { + "node": ">=10" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==" + }, + "node_modules/rollup": { + "version": "4.64.5", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.64.5.tgz", + "integrity": "sha512-EAP5ZHcz2ijbgCWwPkpI7zh6bfsri2Ez5FtCy2dCp1k62duXzfG7DLGGh7RU2QQXWwavT+u4qpvDF/TWOiiGTw==", + "dev": true, + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.64.5", + "@rollup/rollup-android-arm64": "4.64.5", + "@rollup/rollup-darwin-arm64": "4.64.5", + "@rollup/rollup-darwin-x64": "4.64.5", + "@rollup/rollup-freebsd-arm64": "4.64.5", + "@rollup/rollup-freebsd-x64": "4.64.5", + "@rollup/rollup-linux-arm-gnueabihf": "4.64.5", + "@rollup/rollup-linux-arm-musleabihf": "4.64.5", + "@rollup/rollup-linux-arm64-gnu": "4.64.5", + "@rollup/rollup-linux-arm64-musl": "4.64.5", + "@rollup/rollup-linux-loong64-gnu": "4.64.5", + "@rollup/rollup-linux-loong64-musl": "4.64.5", + "@rollup/rollup-linux-ppc64-gnu": "4.64.5", + "@rollup/rollup-linux-ppc64-musl": "4.64.5", + "@rollup/rollup-linux-riscv64-gnu": "4.64.5", + "@rollup/rollup-linux-riscv64-musl": "4.64.5", + "@rollup/rollup-linux-s390x-gnu": "4.64.5", + "@rollup/rollup-linux-x64-gnu": "4.64.5", + "@rollup/rollup-linux-x64-musl": "4.64.5", + "@rollup/rollup-openbsd-x64": "4.64.5", + "@rollup/rollup-openharmony-arm64": "4.64.5", + "@rollup/rollup-win32-arm64-msvc": "4.64.5", + "@rollup/rollup-win32-ia32-msvc": "4.64.5", + "@rollup/rollup-win32-x64-gnu": "4.64.5", + "@rollup/rollup-win32-x64-msvc": "4.64.5", + "fsevents": "~2.3.2" + } + }, + "node_modules/rrweb-cssom": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz", + "integrity": "sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safe-array-concat": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.4.tgz", + "integrity": "sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "get-intrinsic": "^1.3.0", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-regex2": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.1.2.tgz", + "integrity": "sha512-tAtCGFPPP02VroDVzp4daGi6bC7jWG+41D47GKf18FeH/ubvKQHT9VmhAkvtjNOmJwqtApzWeUpEhkyEYULvmw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "dependencies": { + "ret": "~0.5.0" + }, + "bin": { + "safe-regex2": "bin/safe-regex2.js" + } + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "engines": { + "node": ">=10" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/scheduler": { + "version": "0.26.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz", + "integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==", + "license": "MIT" + }, + "node_modules/secure-json-parse": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.1.tgz", + "integrity": "sha512-lnd81MQEZKjhW/Gd2TQqdd4XP6QYNdDW/EP+I7nTrFHP9y2LmaGPIHk9xsnzcOiEr1c39c/e84xFgGviQKBTRw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ] + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/set-cookie-parser": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.3.tgz", + "integrity": "sha512-xletSdAI5efyVJjsatBbzDkXl3M/Zmad3xfnQe7ZXuBB0yNwW7C2WFsNGjdRNfZunmNHQuX79xs/ACMTLCdcew==" + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/sharp": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.5.tgz", + "integrity": "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/colour": "^1.1.0", + "detect-libc": "^2.1.2", + "semver": "^7.8.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.35.5", + "@img/sharp-darwin-x64": "0.35.5", + "@img/sharp-freebsd-wasm32": "0.35.5", + "@img/sharp-libvips-darwin-arm64": "1.3.4", + "@img/sharp-libvips-darwin-x64": "1.3.4", + "@img/sharp-libvips-linux-arm": "1.3.4", + "@img/sharp-libvips-linux-arm64": "1.3.4", + "@img/sharp-libvips-linux-ppc64": "1.3.4", + "@img/sharp-libvips-linux-riscv64": "1.3.4", + "@img/sharp-libvips-linux-s390x": "1.3.4", + "@img/sharp-libvips-linux-x64": "1.3.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.4", + "@img/sharp-libvips-linuxmusl-x64": "1.3.4", + "@img/sharp-linux-arm": "0.35.5", + "@img/sharp-linux-arm64": "0.35.5", + "@img/sharp-linux-ppc64": "0.35.5", + "@img/sharp-linux-riscv64": "0.35.5", + "@img/sharp-linux-s390x": "0.35.5", + "@img/sharp-linux-x64": "0.35.5", + "@img/sharp-linuxmusl-arm64": "0.35.5", + "@img/sharp-linuxmusl-x64": "0.35.5", + "@img/sharp-webcontainers-wasm32": "0.35.5", + "@img/sharp-win32-arm64": "0.35.5", + "@img/sharp-win32-ia32": "0.35.5", + "@img/sharp-win32-x64": "0.35.5" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true + }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, + "node_modules/source-map": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "dev": true, + "engines": { + "node": ">= 12" + } + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/ssh2": { + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.17.0.tgz", + "integrity": "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==", + "hasInstallScript": true, + "dependencies": { + "asn1": "^0.2.6", + "bcrypt-pbkdf": "^1.0.2" + }, + "engines": { + "node": ">=10.16.0" + }, + "optionalDependencies": { + "cpu-features": "~0.0.10", + "nan": "^2.23.0" + } + }, + "node_modules/ssh2-sftp-client": { + "version": "12.1.1", + "resolved": "https://registry.npmjs.org/ssh2-sftp-client/-/ssh2-sftp-client-12.1.1.tgz", + "integrity": "sha512-wYVDgwkpcKG2iPGQQ+QR33xkWqLFIaVrYvA+uON4pmxTPaPuB81f1aooUEPN75e/9DCK6rrKYXb6zR6zP3+EtA==", + "license": "Apache-2.0", + "dependencies": { + "concat-stream": "^2.0.0", + "ssh2": "^1.16.0" + }, + "engines": { + "node": ">=18.20.4" + }, + "funding": { + "type": "individual", + "url": "https://square.link/u/4g7sPflL" + } + }, + "node_modules/stable-hash": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", + "integrity": "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==", + "dev": true, + "license": "MIT" + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true + }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string.prototype.includes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", + "integrity": "sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/string.prototype.matchall": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.1.0.tgz", + "integrity": "sha512-tHNHTxInrYLCga9O9YGxWA3G9/nnzQw8UGAyqGx3Ar1pSTTzIuM4woFSq4SowkXCjJIwq5sIiQvEfRI9tCH1qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.2", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.2", + "get-intrinsic": "^1.3.0", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "regexp.prototype.flags": "^1.5.4", + "set-function-name": "^2.0.2", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.repeat": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz", + "integrity": "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.5" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.11.tgz", + "integrity": "sha512-PwvK7BU+CMTJGYQCTZb5RWXIML92lftJLhQz1tBzgKiqGxJaMlBAa48POXaNAC2s4y8jr3EFqrkF9+44neS46w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.2", + "es-object-atoms": "^1.1.2", + "has-property-descriptors": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.10.tgz", + "integrity": "sha512-2+3aDAOmPTmuFwjDnmJG2ctEkQKVki7vOSqaxkv42Mowj1V6PnvuwFCRrR5lChUux1TBskPjfkeTOhqczDMxTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/styled-jsx": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", + "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==", + "license": "MIT", + "dependencies": { + "client-only": "0.0.1" + }, + "engines": { + "node": ">= 12.0.0" + }, + "peerDependencies": { + "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tailwindcss": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", + "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/thread-stream": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.2.0.tgz", + "integrity": "sha512-zLBvqpwr4Esa0kRjcrzGU6zL25lePWaCLMx0RQFrmteozIfeNdaMLpG5U7PeHzvlFkAWaRKA9/KVW4F60iB+qw==", + "dependencies": { + "real-require": "^0.2.0" + } + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.6.tgz", + "integrity": "sha512-u8KszXvGfU68hVcZpRHKG28T0krMuv2G5nDhiHaMLen/gIuFEgIJhaJuO69qjnXg5paSrbPMFfx3brNuN8eVSg==", + "dev": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toad-cache": { + "version": "3.7.4", + "resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.4.tgz", + "integrity": "sha512-m1TdR/rvT7kgGJZhspNtXdsdYk0fddFpJJFlG5s+UkPFo6lkLoZ3YLOaovPYjq1R75NP5JfeTlSHaOsE09peCg==", + "engines": { + "node": ">=20" + } + }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true + }, + "node_modules/tsconfig-paths": { + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", + "integrity": "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json5": "^0.0.29", + "json5": "^1.0.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tsup": { + "version": "8.5.1", + "resolved": "https://registry.npmjs.org/tsup/-/tsup-8.5.1.tgz", + "integrity": "sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==", + "dev": true, + "dependencies": { + "bundle-require": "^5.1.0", + "cac": "^6.7.14", + "chokidar": "^4.0.3", + "consola": "^3.4.0", + "debug": "^4.4.0", + "esbuild": "^0.27.0", + "fix-dts-default-cjs-exports": "^1.0.0", + "joycon": "^3.1.1", + "picocolors": "^1.1.1", + "postcss-load-config": "^6.0.1", + "resolve-from": "^5.0.0", + "rollup": "^4.34.8", + "source-map": "^0.7.6", + "sucrase": "^3.35.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.11", + "tree-kill": "^1.2.2" + }, + "bin": { + "tsup": "dist/cli-default.js", + "tsup-node": "dist/cli-node.js" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@microsoft/api-extractor": "^7.36.0", + "@swc/core": "^1", + "postcss": "^8.4.12", + "typescript": ">=4.5.0" + }, + "peerDependenciesMeta": { + "@microsoft/api-extractor": { + "optional": true + }, + "@swc/core": { + "optional": true + }, + "postcss": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/tsx": { + "version": "4.23.15", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.15.tgz", + "integrity": "sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==", + "dev": true, + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tsx/node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/tweetnacl": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==", + "license": "Unlicense" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.5.tgz", + "integrity": "sha512-0FHJvLPqZ7KJzp17O13jfsAjsqazgrxBu2zEK95PmUz8lv2+GjRuxUInCr2Rk9Dms3ihN21zJ929ZO43yJ95QQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.9", + "for-each": "^0.3.5", + "gopd": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.10" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.8.tgz", + "integrity": "sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "for-each": "^0.3.5", + "gopd": "^1.2.0", + "is-typed-array": "^1.1.15", + "possible-typed-array-names": "^1.1.0", + "reflect.getprototypeof": "^1.0.10" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/ufo": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz", + "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", + "dev": true + }, + "node_modules/unbox-primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true + }, + "node_modules/unrs-resolver": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.12.2.tgz", + "integrity": "sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "napi-postinstall": "^0.3.4" + }, + "funding": { + "url": "https://opencollective.com/unrs-resolver" + }, + "optionalDependencies": { + "@unrs/resolver-binding-android-arm-eabi": "1.12.2", + "@unrs/resolver-binding-android-arm64": "1.12.2", + "@unrs/resolver-binding-darwin-arm64": "1.12.2", + "@unrs/resolver-binding-darwin-x64": "1.12.2", + "@unrs/resolver-binding-freebsd-x64": "1.12.2", + "@unrs/resolver-binding-linux-arm-gnueabihf": "1.12.2", + "@unrs/resolver-binding-linux-arm-musleabihf": "1.12.2", + "@unrs/resolver-binding-linux-arm64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-arm64-musl": "1.12.2", + "@unrs/resolver-binding-linux-loong64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-loong64-musl": "1.12.2", + "@unrs/resolver-binding-linux-ppc64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-riscv64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-riscv64-musl": "1.12.2", + "@unrs/resolver-binding-linux-s390x-gnu": "1.12.2", + "@unrs/resolver-binding-linux-x64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-x64-musl": "1.12.2", + "@unrs/resolver-binding-openharmony-arm64": "1.12.2", + "@unrs/resolver-binding-wasm32-wasi": "1.12.2", + "@unrs/resolver-binding-win32-arm64-msvc": "1.12.2", + "@unrs/resolver-binding-win32-ia32-msvc": "1.12.2", + "@unrs/resolver-binding-win32-x64-msvc": "1.12.2" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.4.tgz", + "integrity": "sha512-t8wonIOdQyOjtPd2VktlBu6QjTVTks6v8bSi7kd0r2Vll8TK1JYDZtRRdbmCHOAMphkZgrzK+L+vXK4pyZ8t4A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/uuid": { + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz", + "integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, + "node_modules/victory-vendor": { + "version": "36.9.2", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", + "integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, + "node_modules/vite": { + "version": "6.4.4", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.4.tgz", + "integrity": "sha512-xGjU/SyjeNyb0E4KxXBvYz/1C+3JXuujvf2hXNOtnlcE+58o5ajd9N2/n2VhkiJvpCP2qQasnUpx5PXNeI+p1Q==", + "dev": true, + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite/node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/vitest": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz", + "integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.7", + "@vitest/mocker": "3.2.7", + "@vitest/pretty-format": "^3.2.7", + "@vitest/runner": "3.2.7", + "@vitest/snapshot": "3.2.7", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.7", + "@vitest/ui": "3.2.7", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.24", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.24.tgz", + "integrity": "sha512-wk4Mf4pR5mRP7eYuuTBCIQ9d0ud2Fv2jRLQpfgnRjbOxAFHmjKFValgTpitVKzJJS8ajnYQV2Du1SZ8j6b/EUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ws": { + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz", + "integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "shared": { + "name": "@atm/shared", + "version": "1.0.0", + "dependencies": { + "uuid": "^11.1.1", + "zod": "^3.25.76" + }, + "devDependencies": { + "@types/node": "^20.19.43", + "typescript": "^5.9.3", + "vitest": "^3.2.7" + } + }, + "tools/simulator": { + "name": "@atm/simulator", + "version": "1.0.0", + "dependencies": { + "@atm/shared": "*" + } + }, + "web": { + "name": "@atm/web", + "version": "1.0.0", + "dependencies": { + "@atm/shared": "*", + "next": "15.5.27", + "react": "19.1.0", + "react-dom": "19.1.0", + "recharts": "^2.15.4" + }, + "devDependencies": { + "@eslint/eslintrc": "^3", + "@playwright/test": "^1.64.0", + "@tailwindcss/postcss": "^4", + "@testing-library/dom": "^10.4.2", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.3", + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "@vitejs/plugin-react": "^4.7.0", + "eslint": "^9", + "eslint-config-next": "15.5.27", + "jsdom": "^25.0.1", + "tailwindcss": "^4", + "typescript": "^5", + "vitest": "^3.2.7" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..315a54b --- /dev/null +++ b/package.json @@ -0,0 +1,18 @@ +{ + "name": "atm10-stats", + "private": true, + "type": "module", + "workspaces": [ + "shared", + "backend", + "web", + "tools/simulator" + ], + "engines": { + "node": ">=22" + }, + "scripts": { + "test": "npm run test --workspaces --if-present", + "typecheck": "npm run typecheck --workspaces --if-present" + } +} diff --git a/shared/fixtures/protocol/advancement.json b/shared/fixtures/protocol/advancement.json new file mode 100644 index 0000000..af72555 --- /dev/null +++ b/shared/fixtures/protocol/advancement.json @@ -0,0 +1 @@ +{ "id": "0199d6a0-0000-7000-8000-000000000007", "type": "advancement", "ts": 1760260131149, "uuid": "308a693c-2bd8-4d6b-8790-670aef5be601", "name": "CDOGamer", "advancementId": "mysticalagriculture:infusion", "title": "Infusing Efficiency" } diff --git a/shared/fixtures/protocol/dimension_change.json b/shared/fixtures/protocol/dimension_change.json new file mode 100644 index 0000000..13b04f8 --- /dev/null +++ b/shared/fixtures/protocol/dimension_change.json @@ -0,0 +1 @@ +{ "id": "0199d6a0-0000-7000-8000-000000000008", "type": "dimension_change", "ts": 1760271736798, "uuid": "308a693c-2bd8-4d6b-8790-670aef5be601", "name": "CDOGamer", "from": "minecraft:overworld", "to": "minecraft:the_nether" } diff --git a/shared/fixtures/protocol/entity_kill.json b/shared/fixtures/protocol/entity_kill.json new file mode 100644 index 0000000..d812ae6 --- /dev/null +++ b/shared/fixtures/protocol/entity_kill.json @@ -0,0 +1 @@ +{ "id": "0199d6a0-0000-7000-8000-000000000006", "type": "entity_kill", "ts": 1760262000000, "uuid": "308a693c-2bd8-4d6b-8790-670aef5be601", "name": "CDOGamer", "dimension": "minecraft:the_end", "victimType": "minecraft:ender_dragon", "isBoss": true, "weapon": "mekanismtools:refined_obsidian_sword" } diff --git a/shared/fixtures/protocol/file_sync.json b/shared/fixtures/protocol/file_sync.json new file mode 100644 index 0000000..bdc2e2a --- /dev/null +++ b/shared/fixtures/protocol/file_sync.json @@ -0,0 +1 @@ +{ "id": "0199d6a0-0000-7000-8000-00000000000a", "type": "file_sync", "ts": 1760264000000, "path": "world/ftbquests/308a693c-2bd8-4d6b-8790-670aef5be601.snbt", "sha256": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08", "content": "{\n\tversion: 1\n}\n" } diff --git a/shared/fixtures/protocol/health.json b/shared/fixtures/protocol/health.json new file mode 100644 index 0000000..2bc85e0 --- /dev/null +++ b/shared/fixtures/protocol/health.json @@ -0,0 +1 @@ +{ "id": "0199d6a0-0000-7000-8000-00000000000b", "type": "health", "ts": 1760265060000, "windowStart": 1760265000000, "windowEnd": 1760265060000, "msptAvg": 38.5, "msptP95": 61.2, "msptMax": 210.0, "tps": 20.0, "players": 3, "chunks": 1840, "heapUsedMb": 2200, "heapMaxMb": 8192 } diff --git a/shared/fixtures/protocol/player_death.json b/shared/fixtures/protocol/player_death.json new file mode 100644 index 0000000..983a753 --- /dev/null +++ b/shared/fixtures/protocol/player_death.json @@ -0,0 +1 @@ +{ "id": "0199d6a0-0000-7000-8000-000000000005", "type": "player_death", "ts": 1760261000000, "uuid": "308a693c-2bd8-4d6b-8790-670aef5be601", "name": "CDOGamer", "dimension": "minecraft:the_nether", "cause": "minecraft:mob_attack", "killerType": "minecraft:wither_skeleton", "killerName": null } diff --git a/shared/fixtures/protocol/player_join.json b/shared/fixtures/protocol/player_join.json new file mode 100644 index 0000000..db083e6 --- /dev/null +++ b/shared/fixtures/protocol/player_join.json @@ -0,0 +1 @@ +{ "id": "0199d6a0-0000-7000-8000-000000000003", "type": "player_join", "ts": 1760259783006, "uuid": "308a693c-2bd8-4d6b-8790-670aef5be601", "name": "CDOGamer" } diff --git a/shared/fixtures/protocol/player_leave.json b/shared/fixtures/protocol/player_leave.json new file mode 100644 index 0000000..068647e --- /dev/null +++ b/shared/fixtures/protocol/player_leave.json @@ -0,0 +1 @@ +{ "id": "0199d6a0-0000-7000-8000-000000000004", "type": "player_leave", "ts": 1760270000000, "uuid": "308a693c-2bd8-4d6b-8790-670aef5be601", "name": "CDOGamer" } diff --git a/shared/fixtures/protocol/server_start.json b/shared/fixtures/protocol/server_start.json new file mode 100644 index 0000000..6644c70 --- /dev/null +++ b/shared/fixtures/protocol/server_start.json @@ -0,0 +1 @@ +{ "id": "0199d6a0-0000-7000-8000-000000000001", "type": "server_start", "ts": 1760259000000, "mcVersion": "1.21.1", "neoforgeVersion": "21.1.219", "javaVersion": "21.0.5", "mods": [ { "id": "mekanism", "version": "10.7.19", "name": "Mekanism" }, { "id": "ftbquests", "version": "2101.1.36", "name": "FTB Quests" } ] } diff --git a/shared/fixtures/protocol/server_stop.json b/shared/fixtures/protocol/server_stop.json new file mode 100644 index 0000000..f821297 --- /dev/null +++ b/shared/fixtures/protocol/server_stop.json @@ -0,0 +1 @@ +{ "id": "0199d6a0-0000-7000-8000-000000000002", "type": "server_stop", "ts": 1760300000000 } diff --git a/shared/fixtures/protocol/stats_snapshot.json b/shared/fixtures/protocol/stats_snapshot.json new file mode 100644 index 0000000..ffca50c --- /dev/null +++ b/shared/fixtures/protocol/stats_snapshot.json @@ -0,0 +1 @@ +{ "id": "0199d6a0-0000-7000-8000-000000000009", "type": "stats_snapshot", "ts": 1760263000000, "uuid": "308a693c-2bd8-4d6b-8790-670aef5be601", "name": "CDOGamer", "full": false, "stats": { "minecraft:custom": { "minecraft:play_time": 1175736, "minecraft:deaths": 8 }, "minecraft:mined": { "mekanism:osmium_ore": 12 } } } diff --git a/shared/package.json b/shared/package.json new file mode 100644 index 0000000..a330abc --- /dev/null +++ b/shared/package.json @@ -0,0 +1,23 @@ +{ + "name": "@atm/shared", + "version": "1.0.0", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts", + "./api": "./src/api.ts" + }, + "scripts": { + "test": "vitest run", + "typecheck": "tsc -p tsconfig.json" + }, + "dependencies": { + "uuid": "^11.1.1", + "zod": "^3.25.76" + }, + "devDependencies": { + "@types/node": "^20.19.43", + "typescript": "^5.9.3", + "vitest": "^3.2.7" + } +} diff --git a/shared/src/api.ts b/shared/src/api.ts new file mode 100644 index 0000000..14e1725 --- /dev/null +++ b/shared/src/api.ts @@ -0,0 +1,153 @@ +// DTOs of the public read API, shared by backend and web. + +export type Unit = 'hours' | 'count' | 'km' | 'blocks'; +export type Period = 'all' | '30d' | '7d'; +export type ItemCategory = 'mined' | 'crafted' | 'used' | 'picked_up'; +export const ITEM_CATEGORIES: ItemCategory[] = ['mined', 'crafted', 'used', 'picked_up']; + +export interface OverviewDto { + online: number; + totalPlayers: number; + totalPlayHours: number; + totalDeaths: number; + totalMobKills: number; + questsCompleted: number; + advancements: number; + lastUpdate: number | null; +} + +export interface OnlinePlayerDto { + uuid: string; + name: string | null; + since: number; + dimension: string | null; +} +export interface OnlineDto { + players: OnlinePlayerDto[]; +} + +export interface BoardMetaDto { + id: string; + title: string; + description: string; + unit: Unit; +} +export interface LeaderboardEntryDto { + rank: number; + uuid: string; + name: string | null; + value: number; +} +export interface LeaderboardsDto { + boards: (BoardMetaDto & { top: LeaderboardEntryDto[] })[]; +} +export interface LeaderboardDto { + board: BoardMetaDto; + period: Period; + entries: LeaderboardEntryDto[]; +} + +export interface PlayerListItemDto { + uuid: string; + name: string | null; + lastSeen: number | null; + playHours: number; + online: boolean; +} +export interface PlayerListDto { + players: PlayerListItemDto[]; + total: number; +} + +export interface PlayerQuestsDto { + teamId: string; + teamName: string | null; + completed: number; + total: number | null; + chapters: { chapterId: string; title: string | null; completed: number; total: number }[]; + /** last completed quests that have a title in the quest config (newest first, max. 10) */ + recent: { questId: string; title: string; chapterTitle: string | null; at: number }[]; +} + +export interface PlayerProfileDto { + uuid: string; + name: string | null; + firstSeen: number; + lastSeen: number | null; + online: boolean; + playHours: number; + sessions: number; + ranks: Record; + /** minecraft:custom counters except distances (raw values; play_time in ticks) */ + general: Record; + /** distances in cm by key (minecraft:walk_one_cm …) */ + movement: Record; + deathsByCause: { cause: string; count: number }[]; + killedBy: { entity: string; count: number }[]; + killsByType: { entity: string; count: number }[]; + bossKills: { entity: string; at: number }[]; + dimensions: { dimension: string; firstAt: number }[]; + advancements: { id: string; title: string | null; at: number }[]; + quests: PlayerQuestsDto; + /** minutes online per [weekday 0=Mon..6=Sun][hour 0..23], last 90 days, server time zone */ + activityHeatmap: number[][]; +} + +export interface HistoryDto { + metric: string; + unit: Unit; + points: { t: number; v: number }[]; +} + +export interface ItemsDto { + category: ItemCategory; + mods: { namespace: string; total: number; items: { key: string; value: number }[] }[]; +} + +export interface ServerInfoDto { + startedAt: number; + mcVersion: string; + neoforgeVersion: string; + javaVersion: string; + modCount: number; +} + +export interface ModsDto { + server: ServerInfoDto | null; + mods: { id: string; name: string; version: string; activity: number; category: string | null }[]; + /** activity per category (tech/magic/farming/other) across all visible players */ + categories: { category: string; activity: number }[]; +} + +export interface QuestsDto { + defsKnown: boolean; + chapters: { chapterId: string; title: string | null; total: number; maxCompleted: number; teamsFinished: number }[]; + teams: { teamId: string; name: string | null; members: { uuid: string; name: string | null }[]; completed: number }[]; +} + +export interface HealthPointDto { + t: number; + msptAvg: number; + msptP95: number; + tps: number; + players: number; + heapUsedMb: number; + heapMaxMb: number; +} +export interface ServerDto { + info: ServerInfoDto | null; + health: HealthPointDto[]; +} + +export interface SourcesDto { + push: { lastEventAt: number | null; lastServerStart: number | null; reachable: boolean }; + sftp: { enabled: boolean; lastSyncAt: number | null; lastError: string | null }; + limitations: string[]; +} + +export type LiveKind = 'online' | 'death' | 'advancement' | 'quest' | 'counters'; +export interface LiveEvent { + id: number; + kind: LiveKind; + data: Record; +} diff --git a/shared/src/ids.ts b/shared/src/ids.ts new file mode 100644 index 0000000..c18ceac --- /dev/null +++ b/shared/src/ids.ts @@ -0,0 +1,8 @@ +import { v5 } from 'uuid'; + +/** Namespace for deterministic event IDs of SFTP-derived events. Never change: IDs must stay stable across versions. */ +export const SFTP_NAMESPACE = 'b3a1f0de-7c2e-4f43-9a51-6d2a1c0e9f10'; + +export function deterministicId(key: string): string { + return v5(key, SFTP_NAMESPACE); +} diff --git a/shared/src/index.ts b/shared/src/index.ts new file mode 100644 index 0000000..99524a8 --- /dev/null +++ b/shared/src/index.ts @@ -0,0 +1,2 @@ +export * from './protocol.ts'; +export * from './ids.ts'; diff --git a/shared/src/protocol.ts b/shared/src/protocol.ts new file mode 100644 index 0000000..4449506 --- /dev/null +++ b/shared/src/protocol.ts @@ -0,0 +1,117 @@ +import { z } from 'zod'; + +export const PROTOCOL_VERSION = 1; +export const MAX_BATCH_EVENTS = 500; + +const uuid = z.string().uuid(); +const resourceId = z.string().min(1).max(256); +const playerName = z.string().min(1).max(32); + +const base = { id: uuid, ts: z.number().int().nonnegative() }; +const player = { uuid, name: playerName }; + +export const serverStartSchema = z.object({ + ...base, + type: z.literal('server_start'), + mcVersion: z.string().max(64), + neoforgeVersion: z.string().max(64), + javaVersion: z.string().max(64), + mods: z.array(z.object({ id: z.string().max(128), version: z.string().max(128), name: z.string().max(256) })).max(5000), +}); +export const serverStopSchema = z.object({ ...base, type: z.literal('server_stop') }); +export const playerJoinSchema = z.object({ ...base, ...player, type: z.literal('player_join') }); +export const playerLeaveSchema = z.object({ ...base, ...player, type: z.literal('player_leave') }); +export const playerDeathSchema = z.object({ + ...base, + ...player, + type: z.literal('player_death'), + dimension: resourceId, + cause: resourceId, + killerType: resourceId.nullable(), + killerName: z.string().max(256).nullable(), +}); +export const entityKillSchema = z.object({ + ...base, + ...player, + type: z.literal('entity_kill'), + dimension: resourceId, + victimType: resourceId, + isBoss: z.boolean(), + weapon: resourceId.nullable(), +}); +export const advancementSchema = z.object({ + ...base, + uuid, + /** null when derived from world/advancements files, which carry no player name */ + name: playerName.nullable(), + type: z.literal('advancement'), + advancementId: resourceId, + title: z.string().max(256).nullable(), +}); +export const dimensionChangeSchema = z.object({ + ...base, + ...player, + type: z.literal('dimension_change'), + from: resourceId, + to: resourceId, +}); +export const statsSnapshotSchema = z.object({ + ...base, + type: z.literal('stats_snapshot'), + uuid, + name: playerName.nullable(), + full: z.boolean(), + stats: z.record(resourceId, z.record(resourceId, z.number().int().nonnegative())), +}); +export const fileSyncSchema = z.object({ + ...base, + type: z.literal('file_sync'), + path: z.string().min(1).max(512).regex(/^[^\\]+$/).refine((p) => !p.split('/').includes('..'), 'path traversal'), + sha256: z.string().regex(/^[0-9a-f]{64}$/), + content: z.string().max(1_600_000), +}); +export const healthSchema = z.object({ + ...base, + type: z.literal('health'), + windowStart: z.number().int(), + windowEnd: z.number().int(), + msptAvg: z.number().nonnegative(), + msptP95: z.number().nonnegative(), + msptMax: z.number().nonnegative(), + tps: z.number().nonnegative(), + players: z.number().int().nonnegative(), + chunks: z.number().int().nonnegative(), + heapUsedMb: z.number().nonnegative(), + heapMaxMb: z.number().nonnegative(), +}); + +export const atmEventSchema = z.discriminatedUnion('type', [ + serverStartSchema, + serverStopSchema, + playerJoinSchema, + playerLeaveSchema, + playerDeathSchema, + entityKillSchema, + advancementSchema, + dimensionChangeSchema, + statsSnapshotSchema, + fileSyncSchema, + healthSchema, +]); + +export type AtmEvent = z.infer; +export type EventType = AtmEvent['type']; +export type EventOf = Extract; + +/** Batch envelope; events are validated individually by the server so one bad event does not drop the batch. */ +export const ingestBatchSchema = z.object({ + protocol: z.literal(PROTOCOL_VERSION), + events: z.array(z.unknown()).min(1).max(MAX_BATCH_EVENTS), +}); +export type IngestBatch = { protocol: 1; events: AtmEvent[] }; + +export interface IngestResponse { + accepted: string[]; + duplicate: string[]; + rejected: { id: string | null; reason: string }[]; +} diff --git a/shared/test/protocol.test.ts b/shared/test/protocol.test.ts new file mode 100644 index 0000000..0ae54a0 --- /dev/null +++ b/shared/test/protocol.test.ts @@ -0,0 +1,34 @@ +import { readdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { atmEventSchema, deterministicId, ingestBatchSchema } from '../src/index.ts'; + +const dir = join(import.meta.dirname, '..', 'fixtures', 'protocol'); + +describe('protocol v1', () => { + it('every fixture validates', () => { + const files = readdirSync(dir).filter((f) => f.endsWith('.json')); + expect(files.length).toBe(11); + for (const f of files) { + const parsed = atmEventSchema.parse(JSON.parse(readFileSync(join(dir, f), 'utf8'))); + expect(parsed.type).toBe(f.replace('.json', '')); + } + }); + + it('rejects unknown type', () => { + const r = atmEventSchema.safeParse({ id: '0199d6a0-0000-7000-8000-000000000001', type: 'nope', ts: 1 }); + expect(r.success).toBe(false); + }); + + it('rejects batch with 501 events', () => { + const ev = JSON.parse(readFileSync(join(dir, 'server_stop.json'), 'utf8')); + const r = ingestBatchSchema.safeParse({ protocol: 1, events: Array.from({ length: 501 }, () => ev) }); + expect(r.success).toBe(false); + }); + + it('deterministicId is stable', () => { + expect(deterministicId('a')).toBe(deterministicId('a')); + expect(deterministicId('a')).not.toBe(deterministicId('b')); + expect(deterministicId('a')).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-5[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/); + }); +}); diff --git a/shared/tsconfig.json b/shared/tsconfig.json new file mode 100644 index 0000000..1c88b13 --- /dev/null +++ b/shared/tsconfig.json @@ -0,0 +1 @@ +{ "extends": "../tsconfig.base.json", "include": ["src", "test"] } diff --git a/tools/anonymize-log.mjs b/tools/anonymize-log.mjs new file mode 100644 index 0000000..3819946 --- /dev/null +++ b/tools/anonymize-log.mjs @@ -0,0 +1,16 @@ +#!/usr/bin/env node +// Entfernt Spieler-IP-Adressen aus einem Minecraft-Log, damit es als Test-Fixture versioniert werden kann. +// Aufruf: node tools/anonymize-log.mjs +import { readFileSync, writeFileSync } from 'node:fs'; + +const [input, output] = process.argv.slice(2); +if (!input || !output) { + console.error('usage: node tools/anonymize-log.mjs '); + process.exit(1); +} +const text = readFileSync(input, 'utf8') + .replace(/\/\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}:\d+/g, '/0.0.0.0:0') + .replace(/\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b/g, '0.0.0.0') + // login positions ("logged in with entity id … at (x, y, z)") would reveal where players build + .replace(/ at \(-?[\d.]+, -?[\d.]+, -?[\d.]+\)/g, ' at (0.0, 0.0, 0.0)'); +writeFileSync(output, text); diff --git a/tools/rcon.mjs b/tools/rcon.mjs new file mode 100644 index 0000000..8c67067 --- /dev/null +++ b/tools/rcon.mjs @@ -0,0 +1,47 @@ +#!/usr/bin/env node +// Minimaler RCON-Client für lokale Tests: node tools/rcon.mjs +import net from 'node:net'; + +const [host, port, password, ...cmd] = process.argv.slice(2); +if (!host || !port || !password || cmd.length === 0) { + console.error('usage: node tools/rcon.mjs '); + process.exit(1); +} + +const packet = (id, type, body) => { + const payload = Buffer.from(body, 'utf8'); + const buf = Buffer.alloc(14 + payload.length); + buf.writeInt32LE(10 + payload.length, 0); + buf.writeInt32LE(id, 4); + buf.writeInt32LE(type, 8); + payload.copy(buf, 12); + return buf; +}; + +const socket = net.connect(Number(port), host, () => socket.write(packet(1, 3, password))); +let buf = Buffer.alloc(0); +let authed = false; +socket.on('data', (chunk) => { + buf = Buffer.concat([buf, chunk]); + while (buf.length >= 4 && buf.length >= buf.readInt32LE(0) + 4) { + const len = buf.readInt32LE(0); + const id = buf.readInt32LE(4); + const body = buf.toString('utf8', 12, 4 + len - 2); + buf = buf.subarray(4 + len); + if (!authed) { + if (id === -1) { + console.error('rcon: authentication failed'); + process.exit(2); + } + authed = true; + socket.write(packet(2, 2, cmd.join(' '))); + } else { + if (body) console.log(body); + socket.end(); + } + } +}); +socket.on('error', (e) => { + console.error(`rcon: ${e.message}`); + process.exit(3); +}); diff --git a/tools/simulator/README.md b/tools/simulator/README.md new file mode 100644 index 0000000..2ae2ed3 --- /dev/null +++ b/tools/simulator/README.md @@ -0,0 +1,19 @@ +# Ingest-Simulator + +Erzeugt plausible Ereignisse im echten Protokoll v1 (gleiche Batches, gzip und HMAC-Signatur wie die Mod), +damit Backend und Frontend ohne Minecraft-Server ausprobiert werden können. + +```bash +npm run sim -w tools/simulator -- \ + --endpoint http://127.0.0.1:3001 \ + --token \ + --secret \ + --players 5 --minutes 240 [--realtime] +``` + +- Startwerte der Zähler stammen aus `backend/test/fixtures/stats/*.json` (echte Serverdaten), weitere Spieler beginnen bei 0. +- Erzeugt Sessions, Health-Werte im Minutenraster, Stats-Snapshots alle 5 Minuten, Tode, Advancements, Boss-Kills, + Dimensionswechsel und den FTB-Quest-Fortschritt aus dem Fixture. +- `--realtime` sendet danach alle 10 Sekunden neue Ereignisse (für die Live-Ansicht). + +Die simulierten Werte sind **Testdaten** und gehören nicht in die Produktionsdatenbank. diff --git a/tools/simulator/package.json b/tools/simulator/package.json new file mode 100644 index 0000000..1f8fc6a --- /dev/null +++ b/tools/simulator/package.json @@ -0,0 +1,13 @@ +{ + "name": "@atm/simulator", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "sim": "tsx src/index.ts", + "typecheck": "tsc -p tsconfig.json" + }, + "dependencies": { + "@atm/shared": "*" + } +} diff --git a/tools/simulator/src/index.ts b/tools/simulator/src/index.ts new file mode 100644 index 0000000..5bab3a7 --- /dev/null +++ b/tools/simulator/src/index.ts @@ -0,0 +1,230 @@ +// Ingest-Simulator: erzeugt plausible Ereignisse im echten Protokoll (gleiche Batches und Signatur wie die Mod), +// damit Backend und Frontend ohne Minecraft-Server getestet werden können. +// +// npm run sim -w tools/simulator -- --endpoint http://localhost:3001 --token --secret [--players 5] [--minutes 120] [--realtime] +import { createHash, createHmac, randomUUID } from 'node:crypto'; +import { readdirSync, readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { gzipSync } from 'node:zlib'; +import { atmEventSchema, deterministicId, type AtmEvent } from '@atm/shared'; + +const here = dirname(fileURLToPath(import.meta.url)); +const FIXTURES = join(here, '..', '..', '..', 'backend', 'test', 'fixtures'); + +function arg(name: string, fallback?: string): string { + const i = process.argv.indexOf(`--${name}`); + const v = i >= 0 ? process.argv[i + 1] : process.env[`SIM_${name.toUpperCase()}`]; + if (v === undefined && fallback === undefined) throw new Error(`missing --${name}`); + return v ?? fallback!; +} + +const endpoint = arg('endpoint', 'http://localhost:3001').replace(/\/+$/, ''); +const token = arg('token'); +const secret = arg('secret'); +const playerCount = Number(arg('players', '5')); +const minutes = Number(arg('minutes', '120')); +const realtime = process.argv.includes('--realtime'); + +const NAMES = ['CDOGamer', 'Bob', 'Steve_Builder', 'Alexandra', 'RedstoneRita', 'MekaMax', 'ArsArne', 'QuestQueen', 'Ferro', 'Nyx']; +const DIMS = ['minecraft:overworld', 'minecraft:the_nether', 'minecraft:the_end', 'allthemodium:mining', 'allthemodium:the_other']; +const CAUSES = ['minecraft:fall', 'minecraft:lava', 'minecraft:mob_attack', 'minecraft:explosion', 'minecraft:drown', 'minecraft:magic']; +const KILLERS = ['minecraft:zombie', 'minecraft:skeleton', 'minecraft:creeper', 'minecraft:wither_skeleton', 'cataclysm:ignis']; +const BOSSES = ['minecraft:ender_dragon', 'minecraft:wither', 'cataclysm:ignis', 'cataclysm:netherite_monstrosity']; +const ADVANCEMENTS: [string, string][] = [ + ['minecraft:story/mine_stone', 'Stone Age'], + ['minecraft:story/enter_the_nether', 'We Need to Go Deeper'], + ['minecraft:nether/obtain_blaze_rod', 'Into Fire'], + ['mekanism:root', 'Mekanism'], + ['mysticalagriculture:infusion', 'Infusing Efficiency'], + ['ars_nouveau:root', 'Ars Nouveau'], + ['allthemodium:allthemodium', 'The Strongest Metal'], +]; + +const rnd = (n: number) => Math.floor(Math.random() * n); +const pick = (xs: T[]) => xs[rnd(xs.length)]!; + +type Stats = Record>; +interface Player { + uuid: string; + name: string; + stats: Stats; +} + +/** Real counters from the fixture stats files as starting point, random fresh players for the rest. */ +function loadPlayers(): Player[] { + const players: Player[] = []; + const files = readdirSync(join(FIXTURES, 'stats')).filter((f) => f.endsWith('.json')); + for (let i = 0; i < playerCount; i++) { + const file = files[i]; + const stats: Stats = file ? JSON.parse(readFileSync(join(FIXTURES, 'stats', file), 'utf8')).stats : { 'minecraft:custom': {} }; + const name = NAMES[i % NAMES.length]!; + // stable uuid per simulated name, so repeated runs update the same players + players.push({ uuid: file ? file.replace('.json', '') : deterministicId(`sim-player:${name}`), name, stats }); + } + return players; +} + +function grow(p: Player, minutesPlayed: number) { + const add = (cat: string, key: string, v: number) => { + p.stats[cat] ??= {}; + p.stats[cat]![key] = (p.stats[cat]![key] ?? 0) + Math.max(0, Math.round(v)); + }; + const ticks = minutesPlayed * 1200; + add('minecraft:custom', 'minecraft:play_time', ticks); + add('minecraft:custom', 'minecraft:total_world_time', ticks); + add('minecraft:custom', 'minecraft:walk_one_cm', minutesPlayed * (2000 + rnd(8000))); + add('minecraft:custom', 'minecraft:sprint_one_cm', minutesPlayed * rnd(6000)); + add('minecraft:custom', 'minecraft:fly_one_cm', minutesPlayed * rnd(12000)); + add('minecraft:custom', 'minecraft:jump', minutesPlayed * rnd(30)); + add('minecraft:custom', 'minecraft:mob_kills', rnd(minutesPlayed)); + add('minecraft:custom', 'lootr:looted_stat', rnd(3)); + add('minecraft:mined', pick(['minecraft:stone', 'minecraft:deepslate', 'alltheores:osmium_ore', 'minecraft:diamond_ore']), minutesPlayed * rnd(40)); + add('minecraft:crafted', pick(['mekanism:steel_casing', 'powah:energy_cell_basic', 'ars_nouveau:source_gem', 'mysticalagriculture:inferium_essence']), rnd(20)); + add('minecraft:used', pick(['mekanism:configurator', 'irons_spellbooks:scroll', 'minecraft:bread']), rnd(15)); + add('minecraft:killed', pick(KILLERS), rnd(5)); +} + +// Representative excerpt of the ATM10 mod list (the real list arrives with server_start from the mod). +const MODS = [ + ['mekanism', '10.7.19', 'Mekanism'], + ['powah', '6.2.10', 'Powah'], + ['ars_nouveau', '5.13.1', 'Ars Nouveau'], + ['mysticalagriculture', '8.0.28', 'Mystical Agriculture'], + ['ftbquests', '2101.1.36', 'FTB Quests'], + ['apotheosis', '8.8.0', 'Apotheosis'], + ['irons_spellbooks', '3.16.3', "Iron's Spells 'n Spellbooks"], + ['allthemodium', '3.0.1', 'Allthemodium'], +].map(([id, version, name]) => ({ id: id!, version: version!, name: name! })); + +function timeline(players: Player[], now: number): AtmEvent[] { + const start = now - minutes * 60_000; + const out: AtmEvent[] = []; + const ev = (type: string, ts: number, payload: object) => out.push({ id: randomUUID(), type, ts: Math.round(ts), ...payload } as AtmEvent); + + ev('server_start', start, { mcVersion: '1.21.1', neoforgeVersion: '21.1.219', javaVersion: '21.0.12', mods: MODS }); + + for (let t = start; t < now; t += 60_000) { + const online = Math.max(1, Math.min(players.length, 2 + Math.round(Math.sin(t / 3.6e6) * 2))); + ev('health', t + 60_000, { + windowStart: t, + windowEnd: t + 60_000, + msptAvg: 25 + rnd(25), + msptP95: 45 + rnd(40), + msptMax: 60 + rnd(400), + tps: 20, + players: online, + chunks: 1500 + rnd(800), + heapUsedMb: 5000 + rnd(3000), + heapMaxMb: 10240, + }); + } + + for (const [i, p] of players.entries()) { + const who = { uuid: p.uuid, name: p.name }; + let t = start + i * 7 * 60_000; + while (t < now) { + const join = t; + const length = (20 + rnd(90)) * 60_000; + const leave = join + length; + ev('player_join', join, who); + ev('stats_snapshot', join + 1000, { ...who, full: true, stats: structuredClone(p.stats) }); + for (let s = join + 5 * 60_000; s < Math.min(leave, now); s += 5 * 60_000) { + grow(p, 5); + ev('stats_snapshot', s, { ...who, full: false, stats: structuredClone(p.stats) }); + if (Math.random() < 0.15) { + ev('player_death', s + 1000, { + ...who, + dimension: pick(DIMS), + cause: pick(CAUSES), + killerType: Math.random() < 0.5 ? pick(KILLERS) : null, + killerName: null, + }); + } + if (Math.random() < 0.1) { + const [advancementId, title] = pick(ADVANCEMENTS); + ev('advancement', s + 2000, { ...who, advancementId, title }); + } + if (Math.random() < 0.05) { + ev('entity_kill', s + 3000, { ...who, dimension: pick(DIMS), victimType: pick(BOSSES), isBoss: true, weapon: 'allthemodium:alloy_sword' }); + } + if (Math.random() < 0.1) ev('dimension_change', s + 4000, { ...who, from: 'minecraft:overworld', to: pick(DIMS) }); + } + // the last session of the first players stays open, so the portal shows players online + if (leave < now || i >= 3) ev('player_leave', Math.min(leave, now - 1000), who); + t = leave + (30 + rnd(120)) * 60_000; + } + } + + // FTB quest progress of the first player (real fixture file) + const questFile = join(FIXTURES, 'ftbquests', '308a693c-2bd8-4d6b-8790-670aef5be601.snbt'); + const content = readFileSync(questFile, 'utf8'); + ev('file_sync', now - 1000, { + path: 'world/ftbquests/308a693c-2bd8-4d6b-8790-670aef5be601.snbt', + sha256: createHash('sha256').update(content).digest('hex'), + content, + }); + return out.sort((a, b) => a.ts - b.ts); +} + +async function send(events: AtmEvent[]) { + for (const e of events) atmEventSchema.parse(e); + const body = gzipSync(Buffer.from(JSON.stringify({ protocol: 1, events }))); + const ts = Date.now(); + const sig = createHmac('sha256', secret) + .update(`${ts}.${createHash('sha256').update(body).digest('hex')}`) + .digest('hex'); + const res = await fetch(`${endpoint}/api/ingest`, { + method: 'POST', + headers: { + authorization: `Bearer ${token}`, + 'content-type': 'application/json', + 'content-encoding': 'gzip', + 'x-atm-server': 'simulator', + 'x-atm-timestamp': String(ts), + 'x-atm-signature': sig, + }, + body, + }); + const json = (await res.json()) as { accepted?: string[]; duplicate?: string[]; rejected?: unknown[]; error?: string }; + if (!res.ok) throw new Error(`HTTP ${res.status}: ${JSON.stringify(json)}`); + return json; +} + +async function main() { + const players = loadPlayers(); + const events = timeline(players, Date.now()); + let accepted = 0; + for (let i = 0; i < events.length; i += 500) { + const batch = events.slice(i, i + 500); + const r = await send(batch); + accepted += r.accepted?.length ?? 0; + if (r.rejected?.length) console.warn('rejected:', r.rejected); + } + console.log(`simuliert: ${players.length} Spieler, ${minutes} min, ${events.length} Ereignisse, ${accepted} neu angenommen`); + + if (!realtime) return; + console.log('Echtzeit-Modus: sendet alle 10 s neue Ereignisse (Strg+C beendet)'); + for (;;) { + await new Promise((r) => setTimeout(r, 10_000)); + const p = pick(players); + grow(p, 1); + const live: AtmEvent[] = [ + { id: randomUUID(), type: 'stats_snapshot', ts: Date.now(), uuid: p.uuid, name: p.name, full: false, stats: structuredClone(p.stats) }, + ]; + if (Math.random() < 0.3) { + live.push({ id: randomUUID(), type: 'player_death', ts: Date.now(), uuid: p.uuid, name: p.name, dimension: pick(DIMS), cause: pick(CAUSES), killerType: pick(KILLERS), killerName: null }); + } + if (Math.random() < 0.3) { + const [advancementId, title] = pick(ADVANCEMENTS); + live.push({ id: randomUUID(), type: 'advancement', ts: Date.now(), uuid: p.uuid, name: p.name, advancementId: `${advancementId}_${rnd(1000)}`, title }); + } + await send(live); + console.log(`live: ${live.map((e) => e.type).join(', ')} (${p.name})`); + } +} + +main().catch((err) => { + console.error(err instanceof Error ? err.message : err); + process.exit(1); +}); diff --git a/tools/simulator/tsconfig.json b/tools/simulator/tsconfig.json new file mode 100644 index 0000000..008c945 --- /dev/null +++ b/tools/simulator/tsconfig.json @@ -0,0 +1 @@ +{ "extends": "../../tsconfig.base.json", "include": ["src"] } diff --git a/tsconfig.base.json b/tsconfig.base.json new file mode 100644 index 0000000..bc5cd5e --- /dev/null +++ b/tsconfig.base.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "noUncheckedIndexedAccess": true, + "esModuleInterop": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "isolatedModules": true, + "allowImportingTsExtensions": true, + "noEmit": true + } +} diff --git a/web/.gitignore b/web/.gitignore new file mode 100644 index 0000000..efb1fb6 --- /dev/null +++ b/web/.gitignore @@ -0,0 +1,43 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts +/test-results/ +/playwright-report/ diff --git a/web/Dockerfile b/web/Dockerfile new file mode 100644 index 0000000..df461c0 --- /dev/null +++ b/web/Dockerfile @@ -0,0 +1,28 @@ +# Build context: repository root (docker build -f web/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 web --include-workspace-root +COPY tsconfig.base.json ./ +COPY shared shared +COPY web web +ENV NEXT_TELEMETRY_DISABLED=1 +# Rewrites are resolved at build time; inside Compose the API is reachable as "api". +ARG API_INTERNAL_URL=http://api:3001 +ENV API_INTERNAL_URL=$API_INTERNAL_URL +RUN npm run build -w web + +FROM node:22-alpine +ENV NODE_ENV=production NEXT_TELEMETRY_DISABLED=1 PORT=3000 HOSTNAME=0.0.0.0 API_INTERNAL_URL=http://api:3001 +WORKDIR /app +# Standalone output mirrors the monorepo layout: server.js lives in web/. +COPY --from=build --chown=node:node /repo/web/.next/standalone ./ +COPY --from=build --chown=node:node /repo/web/.next/static ./web/.next/static +USER node +EXPOSE 3000 +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s CMD wget -qO- http://127.0.0.1:3000/ >/dev/null || exit 1 +CMD ["node", "web/server.js"] diff --git a/web/e2e/smoke.spec.ts b/web/e2e/smoke.spec.ts new file mode 100644 index 0000000..0ef8bf7 --- /dev/null +++ b/web/e2e/smoke.spec.ts @@ -0,0 +1,47 @@ +import { expect, test } from '@playwright/test'; + +test('overview shows status, totals and leaderboards', async ({ page }) => { + await page.goto('/'); + await expect(page.getByText('Spieler online')).toBeVisible(); + await expect(page.getByText('Spielzeit gesamt')).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Bestenlisten' })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Dauerzocker' })).toBeVisible(); + const overflow = await page.evaluate(() => document.documentElement.scrollWidth > window.innerWidth); + expect(overflow).toBe(false); +}); + +test('clicking a player opens the profile', async ({ page }) => { + await page.goto('/leaderboards/playtime'); + const first = page.locator('ol li a').first(); + const name = (await first.textContent())!.trim(); + await first.click(); + await expect(page.getByRole('heading', { level: 1, name })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Wann gespielt wird' })).toBeVisible(); +}); + +test('leaderboard periods switch', async ({ page }) => { + await page.goto('/leaderboards/deaths'); + await page.getByRole('link', { name: '7 Tage' }).click(); + await expect(page).toHaveURL(/period=7d/); + await expect(page.getByText('Zuwachs der letzten 7 Tage', { exact: false })).toBeVisible(); +}); + +test('unknown player shows the not-found page', async ({ page }) => { + const res = await page.goto('/players/DiesenSpielerGibtEsNicht'); + expect(res?.status()).toBe(404); + await expect(page.getByText('Hier gibt es nichts abzubauen')).toBeVisible(); +}); + +test('live endpoint streams server-sent events', async ({ page }) => { + await page.goto('/'); + const first = await page.evaluate(async () => { + const ctrl = new AbortController(); + const res = await fetch('/api/live', { signal: ctrl.signal }); + const reader = res.body!.getReader(); + const { value } = await reader.read(); + ctrl.abort(); + return { type: res.headers.get('content-type'), text: new TextDecoder().decode(value) }; + }); + expect(first.type).toContain('text/event-stream'); + expect(first.text).toContain(': connected'); +}); diff --git a/web/eslint.config.mjs b/web/eslint.config.mjs new file mode 100644 index 0000000..719cea2 --- /dev/null +++ b/web/eslint.config.mjs @@ -0,0 +1,25 @@ +import { dirname } from "path"; +import { fileURLToPath } from "url"; +import { FlatCompat } from "@eslint/eslintrc"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const compat = new FlatCompat({ + baseDirectory: __dirname, +}); + +const eslintConfig = [ + ...compat.extends("next/core-web-vitals", "next/typescript"), + { + ignores: [ + "node_modules/**", + ".next/**", + "out/**", + "build/**", + "next-env.d.ts", + ], + }, +]; + +export default eslintConfig; diff --git a/web/next.config.ts b/web/next.config.ts new file mode 100644 index 0000000..ce49149 --- /dev/null +++ b/web/next.config.ts @@ -0,0 +1,31 @@ +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import type { NextConfig } from 'next'; + +const api = process.env.API_INTERNAL_URL ?? 'http://127.0.0.1:3001'; + +const nextConfig: NextConfig = { + output: 'standalone', + // Trace from the monorepo root so the workspace package @atm/shared is included. + outputFileTracingRoot: join(dirname(fileURLToPath(import.meta.url)), '..'), + transpilePackages: ['@atm/shared'], + poweredByHeader: false, + // In production Traefik routes /api directly to the API; the rewrite covers local development. + async rewrites() { + return [{ source: '/api/:path*', destination: `${api}/api/:path*` }]; + }, + async headers() { + return [ + { + source: '/:path*', + headers: [ + { key: 'X-Content-Type-Options', value: 'nosniff' }, + { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' }, + { key: 'X-Frame-Options', value: 'DENY' }, + ], + }, + ]; + }, +}; + +export default nextConfig; diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..535bdf3 --- /dev/null +++ b/web/package.json @@ -0,0 +1,38 @@ +{ + "name": "@atm/web", + "version": "1.0.0", + "private": true, + "scripts": { + "dev": "next dev -p 3000", + "build": "next build", + "start": "next start -p 3000", + "lint": "eslint", + "test": "vitest run", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@atm/shared": "*", + "next": "15.5.27", + "react": "19.1.0", + "react-dom": "19.1.0", + "recharts": "^2.15.4" + }, + "devDependencies": { + "@eslint/eslintrc": "^3", + "@playwright/test": "^1.64.0", + "@tailwindcss/postcss": "^4", + "@testing-library/dom": "^10.4.2", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.3", + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "@vitejs/plugin-react": "^4.7.0", + "eslint": "^9", + "eslint-config-next": "15.5.27", + "jsdom": "^25.0.1", + "tailwindcss": "^4", + "typescript": "^5", + "vitest": "^3.2.7" + } +} diff --git a/web/playwright.config.ts b/web/playwright.config.ts new file mode 100644 index 0000000..a4a0a21 --- /dev/null +++ b/web/playwright.config.ts @@ -0,0 +1,13 @@ +import { defineConfig, devices } from '@playwright/test'; + +// Smoke test against a running stack (docker compose … up, simulator data loaded): +// E2E_BASE_URL=http://localhost:3000 npx playwright test -c web/playwright.config.ts +export default defineConfig({ + testDir: './e2e', + timeout: 30_000, + use: { baseURL: process.env.E2E_BASE_URL ?? 'http://localhost:3000', colorScheme: 'dark' }, + projects: [ + { name: 'desktop', use: { ...devices['Desktop Chrome'] } }, + { name: 'mobile', use: { ...devices['Pixel 7'] } }, + ], +}); diff --git a/web/postcss.config.mjs b/web/postcss.config.mjs new file mode 100644 index 0000000..c7bcb4b --- /dev/null +++ b/web/postcss.config.mjs @@ -0,0 +1,5 @@ +const config = { + plugins: ["@tailwindcss/postcss"], +}; + +export default config; diff --git a/web/src/app/admin/AdminPanel.tsx b/web/src/app/admin/AdminPanel.tsx new file mode 100644 index 0000000..53fab0e --- /dev/null +++ b/web/src/app/admin/AdminPanel.tsx @@ -0,0 +1,219 @@ +'use client'; + +import { useCallback, useEffect, useState } from 'react'; +import { PlayerHead } from '@/components/PlayerHead'; +import { Section } from '@/components/Section'; +import { formatDateTime, formatRelative } from '@/lib/format'; + +interface Status { + push: { lastEventAt: number | null }; + sftp: { enabled: boolean; lastSyncAt: number | null; lastEventAt: number | null; pendingRequests: number }; + events: number; + activeTokens: number; + lastErrors: { at: number; source: string; message: string }[]; +} +interface AdminPlayer { + uuid: string; + name: string | null; + hidden: boolean; + lastSeen: number | null; +} + +async function call(path: string, init: RequestInit & { csrf?: string } = {}): Promise { + const headers: Record = { 'content-type': 'application/json' }; + if (init.csrf) headers['x-csrf-token'] = init.csrf; + const res = await fetch(path, { ...init, headers, credentials: 'same-origin' }); + if (!res.ok) throw Object.assign(new Error(`HTTP ${res.status}`), { status: res.status }); + return (await res.json()) as T; +} + +export function AdminPanel() { + const [session, setSession] = useState<{ username: string; csrf: string } | null | undefined>(undefined); + + useEffect(() => { + call<{ username: string; csrf: string }>('/api/admin/session') + .then(setSession) + .catch(() => setSession(null)); + }, []); + + if (session === undefined) return

Lade …

; + if (session === null) return ; + return setSession(null)} />; +} + +function Login({ onLogin }: { onLogin: (s: { username: string; csrf: string }) => void }) { + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + + async function submit(e: React.FormEvent) { + e.preventDefault(); + const form = new FormData(e.currentTarget); + setBusy(true); + setError(null); + try { + const s = await call<{ username: string; csrf: string }>('/api/admin/login', { + method: 'POST', + body: JSON.stringify({ username: form.get('username'), password: form.get('password') }), + }); + onLogin(s); + } catch (err) { + const status = (err as { status?: number }).status; + setError(status === 429 ? 'Zu viele Versuche. Warte eine Minute.' : 'Benutzername oder Passwort ist falsch.'); + } finally { + setBusy(false); + } + } + + return ( +
+

Verwaltung

+
+ + + {error && ( +

+ {error} +

+ )} + +
+

Admin-Konten werden auf dem Server mit „npm run cli -- admin:create <name>“ angelegt.

+
+ ); +} + +function Dashboard({ session, onLogout }: { session: { username: string; csrf: string }; onLogout: () => void }) { + const [status, setStatus] = useState(null); + const [players, setPlayers] = useState([]); + const [token, setToken] = useState(null); + const [notice, setNotice] = useState(null); + const now = Date.now(); + + const load = useCallback(async () => { + try { + const [s, p] = await Promise.all([call('/api/admin/status'), call<{ players: AdminPlayer[] }>('/api/admin/players')]); + setStatus(s); + setPlayers(p.players); + } catch (err) { + if ((err as { status?: number }).status === 401) onLogout(); + } + }, [onLogout]); + + useEffect(() => { + void load(); + }, [load]); + + const post = (path: string, body?: unknown) => + call(path, { method: 'POST', csrf: session.csrf, body: body === undefined ? undefined : JSON.stringify(body) }); + + async function toggle(p: AdminPlayer) { + await post(`/api/admin/players/${p.uuid}/hidden`, { hidden: !p.hidden }); + setNotice(`${p.name ?? p.uuid} ist jetzt ${p.hidden ? 'wieder sichtbar' : 'ausgeblendet'}.`); + await load(); + } + + async function rotate() { + if (!window.confirm('Neues Ingest-Token erzeugen? Das alte wird sofort ungültig; die Mod muss danach das neue Token bekommen.')) return; + const r = (await post('/api/admin/ingest-token/rotate')) as { token: string }; + setToken(r.token); + } + + async function sync() { + await post('/api/admin/sync'); + setNotice('SFTP-Abgleich angefordert. Der Worker startet ihn innerhalb von 30 Sekunden.'); + await load(); + } + + async function logout() { + await post('/api/admin/logout').catch(() => {}); + onLogout(); + } + + return ( +
+
+

Verwaltung

+ +
+ {notice && ( +

+ {notice} +

+ )} + + {status && ( +
+
+

Letzte Meldung {formatRelative(status.push.lastEventAt, now)}.

+

+ ca. {status.events.toLocaleString("de-DE")} gespeicherte Ereignisse, {status.activeTokens} aktive Tokens. +

+ + {token && ( +
+

Neues Token, wird nur jetzt angezeigt. In config/atmstats-server.toml als token eintragen:

+ {token} +
+ )} +
+
+

+ {status.sftp.enabled ? `Letzter Abgleich ${formatRelative(status.sftp.lastSyncAt, now)}.` : 'Nicht eingerichtet (SFTP_* in der .env).'} +

+ {status.sftp.pendingRequests > 0 &&

{status.sftp.pendingRequests} Anforderung(en) offen.

} + +
+
+ )} + +
+ {!status?.lastErrors.length ? ( +

Keine Fehler protokolliert.

+ ) : ( +
    + {status.lastErrors.map((e, i) => ( +
  • + {formatDateTime(e.at)}, {e.source}: + {e.message} +
  • + ))} +
+ )} +
+ +
+

Ausgeblendete Spieler erscheinen in keiner öffentlichen Ansicht, Rangliste oder Live-Meldung.

+
    + {players.map((p) => ( +
  • + + {p.name ?? p.uuid} + {formatRelative(p.lastSeen, now)} + +
  • + ))} +
+
+
+ ); +} diff --git a/web/src/app/admin/page.tsx b/web/src/app/admin/page.tsx new file mode 100644 index 0000000..730eb31 --- /dev/null +++ b/web/src/app/admin/page.tsx @@ -0,0 +1,8 @@ +import type { Metadata } from 'next'; +import { AdminPanel } from './AdminPanel'; + +export const metadata: Metadata = { title: 'Verwaltung', robots: { index: false } }; + +export default function AdminPage() { + return ; +} diff --git a/web/src/app/atm10/page.tsx b/web/src/app/atm10/page.tsx new file mode 100644 index 0000000..2e4bfc3 --- /dev/null +++ b/web/src/app/atm10/page.tsx @@ -0,0 +1,122 @@ +import type { ModsDto, QuestsDto } from '@atm/shared/api'; +import type { Metadata } from 'next'; +import { BarList } from '@/components/BarList'; +import { Section } from '@/components/Section'; +import { ApiDown, PageTitle } from '@/components/States'; +import { apiGet } from '@/lib/api'; +import { formatCount } from '@/lib/format'; + +export const dynamic = 'force-dynamic'; +export const metadata: Metadata = { title: 'ATM10' }; + +// Categorical slots in fixed order (validated reference palette); "other" stays neutral. +const CATEGORY = { + tech: { label: 'Technik', color: 'var(--color-series-1)' }, + magic: { label: 'Magie', color: 'var(--color-series-2)' }, + farming: { label: 'Farming', color: 'var(--color-series-3)' }, + other: { label: 'Andere Mods', color: 'var(--color-stone-3)' }, +} as const; + +export default async function Atm10Page() { + let mods: ModsDto; + let quests: QuestsDto; + try { + [mods, quests] = await Promise.all([apiGet('/api/mods'), apiGet('/api/quests')]); + } catch { + return ; + } + const active = mods.mods.filter((m) => m.activity > 0).slice(0, 20); + + return ( +
+ + Womit auf dem Server gearbeitet wird: Aktivität nach Bereich und Mod, abgeleitet aus abgebauten, hergestellten, benutzten und + aufgesammelten Items, sowie der Fortschritt in den FTB Quests. + + +
+
+
    + {mods.categories.map((c) => { + const meta = CATEGORY[c.category as keyof typeof CATEGORY] ?? CATEGORY.other; + const max = Math.max(...mods.categories.map((x) => x.activity), 1); + return ( +
  • +
    + + + {meta.label} + + {formatCount(c.activity)} +
    +
    +
    +
    +
  • + ); + })} +
+ {mods.categories.length === 0 &&

Noch keine Item-Statistiken vorhanden.

} +

+ Zuordnung der Mods zu Bereichen: Technik (z. B. Mekanism, Powah, Create), Magie (z. B. Ars Nouveau, Iron’s Spells), Farming + (z. B. Mystical Agriculture). Vanilla-Items zählen nicht mit. +

+
+ +
+
    + {active.map((m) => { + const meta = CATEGORY[(m.category ?? 'other') as keyof typeof CATEGORY]; + return ( +
  • +
    + + {m.name} + {meta.label} + + {formatCount(m.activity)} +
    +
    +
    +
    +
  • + ); + })} +
+ {active.length === 0 &&

Die Modliste kommt mit dem nächsten Serverstart der Statistik-Mod.

} +
+
+ +
+ {quests.defsKnown ? ( + ({ + label: `${c.title ?? c.chapterId}: bestes Team ${c.maxCompleted}/${c.total}${c.teamsFinished ? `, ${c.teamsFinished} fertig` : ''}`, + value: c.total ? (c.maxCompleted / c.total) * 100 : 0, + }))} + format={(v) => `${Math.round(v)} %`} + color="var(--color-unobtainium)" + /> + ) : ( +

+ Kapitel und Quest-Titel erscheinen, sobald die Quest-Konfiguration des Servers übertragen wurde. Bis dahin zählt jede + abgeschlossene Quest-ID. +

+ )} + {quests.teams.length > 0 && ( + <> +

Teams

+ ({ + label: t.members.map((m) => m.name ?? '?').join(', ') || t.name || t.teamId, + value: t.completed, + }))} + format={formatCount} + color="var(--color-unobtainium)" + /> + + )} +
+
+ ); +} diff --git a/web/src/app/globals.css b/web/src/app/globals.css new file mode 100644 index 0000000..42284a4 --- /dev/null +++ b/web/src/app/globals.css @@ -0,0 +1,84 @@ +@import "tailwindcss"; + +/* + * Deepslate: the dark stone of the Minecraft underground as surface family. + * Accents are the three All-The-Mods signature ores, each with one job: + * allthemodium (gold) – emphasis, ranks, links + * vibranium (green) – live / online state + * unobtainium (violet) – quests + * Chart series use the validated reference palette (dark steps), see --series-*. + */ +@theme { + --color-slate-0: #1a1b23; + --color-slate-1: #22242e; + --color-slate-2: #2b2e3a; + --color-slate-3: #3a3e4d; + --color-stone: #e8e6df; + --color-stone-2: #b4b2ba; + --color-stone-3: #8a8893; + --color-gold: #f1b33c; + --color-gold-dim: #8a6a2a; + --color-vibranium: #46d99a; + --color-unobtainium: #b693ff; + --color-danger: #e66767; + + --color-series-1: #3987e5; + --color-series-2: #d95926; + --color-series-3: #199e70; + + --font-sans: var(--font-bricolage), ui-sans-serif, system-ui, sans-serif; + --font-pixel: var(--font-pixel-face), ui-monospace, monospace; +} + +:root { + color-scheme: dark; +} + +html { + background: var(--color-slate-0); +} + +body { + background: var(--color-slate-0); + color: var(--color-stone); + font-family: var(--font-sans); + font-feature-settings: "tnum" 1; + -webkit-font-smoothing: antialiased; +} + +/* Subtle block grid, like looking at a stone wall from close up. */ +.block-texture { + background-image: + linear-gradient(to right, rgb(255 255 255 / 0.025) 1px, transparent 1px), + linear-gradient(to bottom, rgb(255 255 255 / 0.025) 1px, transparent 1px); + background-size: 32px 32px; +} + +.pixelated { + image-rendering: pixelated; +} + +:focus-visible { + outline: 2px solid var(--color-gold); + outline-offset: 2px; +} + +@media (prefers-reduced-motion: no-preference) { + .ticker-enter { + animation: ticker-in 400ms ease-out; + } + @keyframes ticker-in { + from { + opacity: 0; + transform: translateY(-4px); + } + } + .pulse-dot { + animation: pulse 2.4s ease-in-out infinite; + } + @keyframes pulse { + 50% { + opacity: 0.35; + } + } +} diff --git a/web/src/app/icon.svg b/web/src/app/icon.svg new file mode 100644 index 0000000..2b1180f --- /dev/null +++ b/web/src/app/icon.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/web/src/app/layout.tsx b/web/src/app/layout.tsx new file mode 100644 index 0000000..06c3c95 --- /dev/null +++ b/web/src/app/layout.tsx @@ -0,0 +1,32 @@ +import type { Metadata, Viewport } from 'next'; +import { Bricolage_Grotesque, Silkscreen } from 'next/font/google'; +import { Footer } from '@/components/Footer'; +import { Header } from '@/components/Header'; +import './globals.css'; + +const bricolage = Bricolage_Grotesque({ subsets: ['latin'], variable: '--font-bricolage', display: 'swap' }); +const pixel = Silkscreen({ subsets: ['latin'], variable: '--font-pixel-face', weight: ['400', '700'], display: 'swap' }); + +export const metadata: Metadata = { + title: { default: 'ATM10 Stats', template: '%s – ATM10 Stats' }, + description: 'Statistiken, Ranglisten und Spielerprofile unseres All-the-Mods-10-Servers.', +}; + +export const viewport: Viewport = { themeColor: '#1a1b23' }; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + + + Zum Inhalt + +
+
+ {children} +
+