The previous GitHub history was lost with the account (the local .git was missing its refs and most objects), so this repository starts from the existing code. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
209 lines
8.1 KiB
JavaScript
209 lines
8.1 KiB
JavaScript
import fsp from 'node:fs/promises';
|
|
import path from 'node:path';
|
|
import { PLAYER_NAME_REGEX } from '../adapters/serverAdapter.js';
|
|
|
|
// A persistent XUID→name directory of players who REALLY accessed the world.
|
|
//
|
|
// Why not the world files directly? A Bedrock world is a compressed LevelDB that
|
|
// stores player data keyed by XUID with NO readable gamertag — the name only ever
|
|
// appears in the server log, at login. So we build the mapping from the log, but
|
|
// keyed by XUID (the canonical identity: survives renames, never duplicates) and
|
|
// persisted here so it outlives log rotation.
|
|
//
|
|
// "Really accessed" = a `Player Spawned` line. A player rejected by the allowlist
|
|
// gets a `Player connected` line but is disconnected BEFORE spawning, so keying on
|
|
// spawn is what excludes them (Scenario 1). Because entries are persisted, players
|
|
// who joined while the server was public stay listed even after an allowlist is
|
|
// later enabled and they're no longer on it (Scenario 2).
|
|
//
|
|
// File lives at the data root (sibling of allowlist.json) so it's bundled into the
|
|
// world export/backup, like the rest of CraftDock's metadata.
|
|
|
|
const FILE = 'craftdock_players.json';
|
|
const LEGACY_HISTORY = 'craftdock_players_history.json';
|
|
|
|
// Bedrock spawn line, e.g.
|
|
// [2026-07-19 02:29:10:123 INFO] Player Spawned: Lucas Guerra xuid: 2535407895138987, pfid: ...
|
|
// Gamertags may contain spaces, so capture non-greedily up to " xuid:".
|
|
const SPAWN_RE = /Player Spawned:\s*(.+?)\s+xuid:\s*(\d+)/gi;
|
|
|
|
// Control chars docker interleaves as stream-framing bytes; strip them but keep
|
|
// newlines so log lines stay separated. Built from char codes to avoid embedding
|
|
// raw control bytes in source.
|
|
const CTRL_RE = new RegExp('[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F\\x7F-\\x9F]', 'g');
|
|
|
|
// Module-level cursor (single server instance): Unix seconds of the last log scan.
|
|
// First scan reads a bounded tail; later scans read only new lines via `since`, so
|
|
// the 2s status poll never re-parses the whole (ever-growing) container log.
|
|
let lastScanSec = 0;
|
|
|
|
function filePath(dataRoot) {
|
|
return path.join(dataRoot, FILE);
|
|
}
|
|
|
|
export async function readDirectory(dataRoot) {
|
|
try {
|
|
const raw = await fsp.readFile(filePath(dataRoot), 'utf8');
|
|
const data = JSON.parse(raw);
|
|
return data && typeof data === 'object' && !Array.isArray(data) ? data : {};
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
async function writeDirectory(dataRoot, dir) {
|
|
try {
|
|
const file = filePath(dataRoot);
|
|
await fsp.writeFile(file, JSON.stringify(dir, null, 2), 'utf8');
|
|
await fsp.chmod(file, 0o666).catch(() => {});
|
|
} catch { /* best-effort: a failed write just means we re-scan next tick */ }
|
|
}
|
|
|
|
// One-time import of the old name-only history array. Those entries have no XUID,
|
|
// so they're keyed `legacy:<name>` and superseded the first time the same name is
|
|
// seen spawning with a real XUID (see mergeSpawn).
|
|
async function migrateLegacy(dataRoot, dir) {
|
|
const legacyPath = path.join(dataRoot, LEGACY_HISTORY);
|
|
let names;
|
|
try {
|
|
names = JSON.parse(await fsp.readFile(legacyPath, 'utf8'));
|
|
} catch {
|
|
return false;
|
|
}
|
|
if (!Array.isArray(names)) { await fsp.rm(legacyPath, { force: true }).catch(() => {}); return false; }
|
|
|
|
const known = new Set(Object.values(dir).map((e) => e.name));
|
|
for (const name of names) {
|
|
if (typeof name === 'string' && PLAYER_NAME_REGEX.test(name) && !known.has(name)) {
|
|
dir[`legacy:${name}`] = { name, xuid: null, firstSeen: null, lastSeen: null };
|
|
known.add(name);
|
|
}
|
|
}
|
|
await fsp.rm(legacyPath, { force: true }).catch(() => {});
|
|
return true;
|
|
}
|
|
|
|
// Merge one spawn (xuid + current name) into the directory. Returns true if the
|
|
// directory changed. Promotes any matching `legacy:<name>` placeholder to the real
|
|
// XUID-keyed entry.
|
|
function mergeSpawn(dir, xuid, name, nowIso) {
|
|
if (!/^\d+$/.test(xuid) || !PLAYER_NAME_REGEX.test(name)) return false;
|
|
|
|
const legacyKey = `legacy:${name}`;
|
|
const hadLegacy = legacyKey in dir;
|
|
if (hadLegacy) delete dir[legacyKey];
|
|
|
|
const existing = dir[xuid];
|
|
if (!existing) {
|
|
dir[xuid] = { name, xuid, firstSeen: nowIso, lastSeen: nowIso };
|
|
return true;
|
|
}
|
|
// Same identity: refresh the name (rename) and lastSeen.
|
|
const renamed = existing.name !== name;
|
|
existing.name = name;
|
|
existing.lastSeen = nowIso;
|
|
if (!existing.xuid) existing.xuid = xuid;
|
|
return renamed || hadLegacy;
|
|
}
|
|
|
|
let lastDirectoryUpdateMs = 0;
|
|
let cachedDirectoryList = null;
|
|
|
|
async function scanSpawns(dockerService, sinceSec, logger) {
|
|
const spawns = [];
|
|
try {
|
|
const container = await dockerService.getContainer();
|
|
const opts = { stdout: true, stderr: true, follow: false };
|
|
if (sinceSec) opts.since = sinceSec;
|
|
else opts.tail = 2000;
|
|
const buf = await container.logs(opts);
|
|
const text = buf.toString('utf8').replace(CTRL_RE, '');
|
|
let m;
|
|
while ((m = SPAWN_RE.exec(text)) !== null) {
|
|
spawns.push({ name: m[1].trim(), xuid: m[2] });
|
|
}
|
|
SPAWN_RE.lastIndex = 0;
|
|
} catch (err) {
|
|
logger?.warn('player directory scanSpawns failed', { error: err?.message });
|
|
}
|
|
return spawns;
|
|
}
|
|
|
|
// For Java, the world already carries name↔uuid in usercache.json (real world
|
|
// data), so seed the directory from it — no log scanning needed.
|
|
async function seedFromUsercache(dataRoot, dir, nowIso) {
|
|
let changed = false;
|
|
try {
|
|
const cache = JSON.parse(await fsp.readFile(path.join(dataRoot, 'usercache.json'), 'utf8'));
|
|
if (Array.isArray(cache)) {
|
|
for (const e of cache) {
|
|
if (e?.name && e?.uuid && PLAYER_NAME_REGEX.test(e.name) && !dir[e.uuid]) {
|
|
dir[e.uuid] = { name: e.name, xuid: e.uuid, firstSeen: nowIso, lastSeen: nowIso };
|
|
changed = true;
|
|
}
|
|
}
|
|
}
|
|
} catch { /* no usercache (Bedrock, or none yet) */ }
|
|
return changed;
|
|
}
|
|
|
|
/**
|
|
* Update and return the player directory as a flat, sorted list.
|
|
* @returns {Promise<Array<{xuid: string|null, name: string, firstSeen: string|null, lastSeen: string|null}>>}
|
|
*/
|
|
export async function updatePlayerDirectory({ dataRoot, dockerService, edition, logger, minIntervalMs = 0 }) {
|
|
const now = Date.now();
|
|
if (minIntervalMs > 0 && cachedDirectoryList && (now - lastDirectoryUpdateMs < minIntervalMs)) {
|
|
return cachedDirectoryList;
|
|
}
|
|
|
|
const dir = await readDirectory(dataRoot);
|
|
const nowIso = new Date(now).toISOString();
|
|
let changed = await migrateLegacy(dataRoot, dir);
|
|
|
|
if (edition === 'java') {
|
|
if (await seedFromUsercache(dataRoot, dir, nowIso)) changed = true;
|
|
} else if (dockerService) {
|
|
// Capture the cursor BEFORE scanning so lines written during the scan aren't
|
|
// lost on the next pass.
|
|
const nowSec = Math.floor(now / 1000);
|
|
const spawns = await scanSpawns(dockerService, lastScanSec || undefined, logger);
|
|
lastScanSec = nowSec;
|
|
for (const s of spawns) {
|
|
if (mergeSpawn(dir, s.xuid, s.name, nowIso)) changed = true;
|
|
}
|
|
}
|
|
|
|
if (changed) await writeDirectory(dataRoot, dir);
|
|
cachedDirectoryList = Object.values(dir).sort((a, b) => a.name.localeCompare(b.name));
|
|
lastDirectoryUpdateMs = Date.now();
|
|
return cachedDirectoryList;
|
|
}
|
|
|
|
|
|
/** Resolve an XUID to its current gamertag (Bedrock console commands are name-based). */
|
|
export async function resolveName(dataRoot, xuid) {
|
|
const dir = await readDirectory(dataRoot);
|
|
return dir[xuid]?.name || null;
|
|
}
|
|
|
|
/**
|
|
* Server-side paginated + filtered view of the directory. Scales to very large
|
|
* directories: the client fetches one page at a time instead of the whole list.
|
|
* @returns {Promise<{ items: Array, total: number, page: number, pageSize: number }>}
|
|
*/
|
|
export async function queryDirectory(dataRoot, { page = 1, pageSize = 25, q = '' } = {}) {
|
|
const dir = await readDirectory(dataRoot);
|
|
const needle = String(q || '').trim().toLowerCase();
|
|
let items = Object.values(dir);
|
|
if (needle) items = items.filter((e) => e.name?.toLowerCase().includes(needle));
|
|
items.sort((a, b) => a.name.localeCompare(b.name));
|
|
|
|
const total = items.length;
|
|
const size = Math.max(1, pageSize | 0);
|
|
const pageCount = Math.max(1, Math.ceil(total / size));
|
|
const current = Math.min(Math.max(1, page | 0), pageCount);
|
|
const start = (current - 1) * size;
|
|
return { items: items.slice(start, start + size), total, page: current, pageSize: size };
|
|
}
|