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 }; }