#!/usr/bin/env node
/* RL Live Tracker — optional companion server.
 * Serves the tracker page plus two online extras the portable file
 * can't fetch itself (CORS): Liquipedia esports schedule + Steam player count.
 * Zero dependencies. The tracker works without this server (open the HTML directly);
 * only the online extras need it.
 */
'use strict';
const http = require('http');
const fs = require('fs');
const path = require('path');
const net = require('net');
const zlib = require('zlib');

const PORT = Number(process.env.PORT) || 8341;
/* Loopback only (user's decision, 2026-07-26). This process holds the user's
 * RapidAPI key and personal telemetry, and /api/playerranks has no
 * authentication — on 0.0.0.0 anyone on the network could spend the
 * 5-lookups/day quota. The board and overlay run on this machine, so nothing
 * in daily use needs the network. Opt back in with HOST=0.0.0.0 (LAN devices,
 * e.g. a board on another PC); overwriting the stored key stays loopback-only
 * either way (see playerRanks). */
const HOST = process.env.HOST || '127.0.0.1';
const LAN_OPEN = HOST !== '127.0.0.1' && HOST !== 'localhost' && HOST !== '::1';
/* ROOT is where the data lives: profile.json, matches/, reports/, the keys.
 * Under SEA that is the folder the exe sits in — the portable pack IS the
 * working directory. RL_ROOT overrides it for development only: the dev config
 * (port 8342) can then read the real archive without a second copy, which is
 * the only way to see the coach's surfaces against real numbers before a
 * release. It is never set in the shipped launchers, and the server still only
 * listens on loopback, so it opens nothing. */
let ROOT = process.env.RL_ROOT || __dirname;
try{ if (require('node:sea').isSea() && !process.env.RL_ROOT) ROOT = path.dirname(process.execPath); }catch{}
const UA = 'GitatoRLTracker/1.0 (dexo.colt@gmail.com)';

/* Crash-safe JSON write: truncating the real file first means a crash (or the
 * user closing the exe) mid-write loses the whole thing — the rank cache is a
 * month of hard-won lookups against a 5/day quota, and a match digest can
 * never be recorded again. Write aside, then rename (atomic on NTFS).
 * The director keeps its own copy of this in director/store.js, so the server
 * stays independent of a module that is allowed to fail to load. */
function writeJSONAtomic(file, obj){
  // The temp name carries the pid: two server processes writing the same file
  // would otherwise share one temp path and clobber each other's half-written
  // bytes before either rename lands. A second process should never get this
  // far (see the EADDRINUSE guard at listen), but the archive is unrebuildable,
  // so the cheap defence stays.
  const tmp = file + '.' + process.pid + '.tmp';
  fs.writeFileSync(tmp, JSON.stringify(obj));
  fs.renameSync(tmp, file);
}

/* ---------------- tiny cache ---------------- */
const cache = new Map(); // key -> {t, ttl, data}
function cached(key){ const c = cache.get(key); return c && Date.now() - c.t < c.ttl ? c.data : null; }
function put(key, data, ttl){ cache.set(key, { t: Date.now(), ttl, data }); return data; }

/* ---------------- steam player count ---------------- */
async function playerCount(){
  const hit = cached('pc');
  if (hit) return hit;
  const r = await fetch('https://api.steampowered.com/ISteamUserStats/GetNumberOfCurrentPlayers/v1/?appid=252950');
  if (!r.ok) throw new Error('steam http ' + r.status);
  const j = await r.json();
  const n = j?.response?.player_count;
  if (typeof n !== 'number') throw new Error('steam shape');
  return put('pc', { playerCount: n, source: 'Steam (Rocket League app 252950)' }, 5 * 60e3);
}

/* ---------------- liquipedia esports schedule ---------------- */
function deent(s){
  return s.replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>')
          .replace(/&#(\d+);/g, (_, n) => String.fromCharCode(n)).replace(/&quot;/g, '"').replace(/&nbsp;/g, ' ');
}
function parseMatches(html){
  const out = [];
  const chunks = html.split('<div class="match-info">');
  for (let i = 1; i < chunks.length && out.length < 60; i++){
    const c = chunks[i].slice(0, 6000);
    const ts = Number((c.match(/data-timestamp="(\d+)"/) || [])[1] || 0);
    if (!ts) continue;
    const names = [];
    const nameRe = /<span class="name"[^>]*>(?:<a[^>]*>)?([^<]+)/g;
    let m;
    while ((m = nameRe.exec(c)) && names.length < 2) names.push(deent(m[1].trim()));
    if (names.length < 2) continue;
    const scores = [...c.matchAll(/scoreholder-score[^>]*>([^<]*)</g)].map(x => x[1].trim()).filter(Boolean);
    let tournament = '';
    const tb = c.match(/match-info-tournament-name[^>]*>([\s\S]{0,400}?)<\/div>/);
    if (tb) tournament = deent(tb[1].replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim());
    if (!tournament){
      const tt = c.match(/match-info-tournament[\s\S]{0,200}?title="([^"]+)"/);
      if (tt) tournament = deent(tt[1].split('/').slice(-2).join(' — ').replace(/_/g, ' '));
    }
    out.push({
      team1: names[0], team2: names[1],
      score: scores.length === 2 ? scores.join('–') : null,
      timestamp: ts,
      live: scores.length === 2 && ts * 1000 <= Date.now(),
      tournament
    });
  }
  const now = Date.now();
  return out
    .filter(x => x.timestamp * 1000 > now - 3 * 3600e3)  // live-ish + upcoming only
    .sort((a, b) => (b.live - a.live) || (a.timestamp - b.timestamp))
    .slice(0, 12);
}
async function esports(){
  const hit = cached('es');
  if (hit) return hit;
  const r = await fetch('https://liquipedia.net/rocketleague/api.php?action=parse&page=Liquipedia:Matches&format=json&prop=text', {
    headers: { 'User-Agent': UA, 'Accept-Encoding': 'gzip' }
  });
  if (!r.ok) throw new Error('liquipedia http ' + r.status);
  const j = await r.json();
  const html = j?.parse?.text?.['*'];
  if (!html) throw new Error('liquipedia shape');
  return put('es', { matches: parseMatches(html), attribution: 'Liquipedia (CC BY-SA 3.0)' }, 10 * 60e3);
}

/* ---------------- game stream bridge (TCP -> SSE) ----------------
 * The game's Stats API is a raw TCP stream of back-to-back JSON objects
 * (no delimiters, and NOT an actual WebSocket despite the docs), so
 * browsers can't read it directly. This bridge splits the stream on
 * top-level object boundaries and forwards each message over SSE.
 */
function makeSplitter(emit){
  let buf = '', depth = 0, inStr = false, esc = false, objStart = -1, i = 0;
  return function feed(chunk){
    buf += chunk;
    for (; i < buf.length; i++){
      const c = buf[i];
      if (inStr){
        if (esc) esc = false;
        else if (c === '\\') esc = true;
        else if (c === '"') inStr = false;
      } else if (c === '"') inStr = true;
      else if (c === '{'){ if (depth === 0) objStart = i; depth++; }
      else if (c === '}' && depth > 0 && --depth === 0 && objStart >= 0){
        emit(buf.slice(objStart, i + 1));
        objStart = -1;
      }
    }
    if (depth === 0){ buf = ''; i = 0; objStart = -1; }
    else if (objStart > 0){ buf = buf.slice(objStart); i = buf.length; objStart = 0; }
    if (buf.length > 5e6){ buf = ''; depth = 0; inStr = false; esc = false; i = 0; objStart = -1; }
  };
}

/* ---------------- shared upstream feed ----------------
 * One TCP connection per game endpoint, fanned out to every consumer.
 * Each /api/stream used to dial the game itself, so board + overlay + main
 * page + the recorder meant four connections all re-splitting the same
 * 60-120 messages/s. Consumers come and go; the socket is opened once,
 * reconnects on its own, and is closed when the last one leaves (except
 * the recorder's, which is the always-on telemetry source).
 */
const RECONNECT_MS = 3000;
const feeds = new Map();               // 'host:port' -> feed

function makeFeed(host, port){
  const subs = new Set();              // fn(rawMessage)
  const watchers = new Set();          // fn(state) — only called when the state CHANGES
  let sock = null, retry = null, state = 'connecting', stopped = false;

  const setState = s => {
    if (s === state) return;           // repeated failed reconnects are not new news
    state = s;
    for (const w of watchers) try{ w(s); }catch{}
  };
  const connect = () => {
    if (stopped) return;
    const split = makeSplitter(raw => { for (const fn of subs) try{ fn(raw); }catch{} });
    sock = net.connect(port, host);
    sock.setEncoding('utf8');
    sock.on('connect', () => setState('connected'));
    sock.on('data', split);
    sock.on('error', () => {});
    sock.on('close', () => {
      sock = null;
      if (stopped) return;
      setState('waiting');
      clearTimeout(retry);
      retry = setTimeout(connect, RECONNECT_MS);
    });
  };
  connect();

  return {
    get state(){ return state; },
    get size(){ return subs.size; },
    add(onMsg, onState){
      subs.add(onMsg);
      if (onState) watchers.add(onState);
      return () => { subs.delete(onMsg); if (onState) watchers.delete(onState); };
    },
    stop(){ stopped = true; clearTimeout(retry); try{ sock && sock.destroy(); }catch{} }
  };
}

function getFeed(host, port){
  const key = host + ':' + port;
  let f = feeds.get(key);
  if (!f) feeds.set(key, f = makeFeed(host, port));
  return f;
}
/* Last page for this endpoint closed: drop the socket, unless it is the
 * recorder's — telemetry keeps recording with no browser open at all. */
function releaseFeed(host, port){
  const key = host + ':' + port;
  if (key === STATS_HOST + ':' + STATS_PORT) return;
  const f = feeds.get(key);
  if (f && !f.size){ f.stop(); feeds.delete(key); }
}

/* Every open /api/stream response; lets the server push its own events
 * (Director debriefs) to all connected pages alongside the game bridge. */
const sseClients = new Set();
function broadcastSSE(obj){
  const line = 'data: ' + JSON.stringify(obj) + '\n\n';
  for (const res of sseClients){ try{ res.write(line); }catch{} }
}

/* The page's default host is "localhost" while the recorder dials 127.0.0.1.
 * Left as-is those are two different feed keys — and therefore two TCP
 * connections to the same game. Fold the loopback spellings together. */
const LOOPBACK = /^(localhost|127\.0\.0\.1|::1|\[::1\])$/i;

function handleStream(url, req, res){
  let host = (url.searchParams.get('host') || '127.0.0.1').replace(/[^a-zA-Z0-9_.:\-]/g, '') || '127.0.0.1';
  if (LOOPBACK.test(host)) host = '127.0.0.1';
  const port = Math.min(65535, Math.max(1, Number(url.searchParams.get('port')) || 49123));
  res.writeHead(200, {
    'Content-Type': 'text/event-stream',
    'Cache-Control': 'no-store',
    'Connection': 'keep-alive',
    'X-Accel-Buffering': 'no'
  });
  const send = payload => { try{ res.write('data: ' + (typeof payload === 'string' ? payload : JSON.stringify(payload)) + '\n\n'); }catch{} };
  sseClients.add(res);
  // a board that (re)opens right after a match still gets the latest debrief,
  // and right after a session still gets that session's report
  if (director){
    const d = director.lastDebrief();
    // Was 10 minutes, which quietly meant a board opened later in the evening
    // showed an empty debrief card even though the debrief existed. The card
    // is the product's centrepiece; showing the latest one is right until a
    // new match replaces it. (The page also fetches /api/debrief at boot, so
    // this is the belt to that pair of braces.)
    if (d && Date.now() - Date.parse(d.at) < 12 * 3600e3) send({ Event: '_director', Data: d });
    const s = director.sessionLatest && director.sessionLatest();
    if (s && Date.now() - Date.parse(s.at) < 60 * 60e3)
      send({ Event: '_session', Data: { schema: 'session/1', at: s.at,
        summary: { matches: s.matches.length, wl: s.wl.w + '-' + s.wl.l, headline: s.headline },
        lines: s.lines, url: s.url } });
    // today's focus has no expiry — it stands until the next session report
    const f = director.focus && director.focus();
    if (f) send({ Event: '_focus', Data: f });
    // a page opened mid-match gets the meter straight away; the periodic
    // broadcast only fires on change, so without this it would sit empty
    // until the next touch moved the number
    const fl = director.focusLive && director.focusLive(rec.m);
    if (fl) send({ Event: '_focusLive', Data: fl });
    // "Ugens fremgang": the running week if it holds anything, otherwise the
    // last closed week's report — a Monday board should not be empty just
    // because the new week has no matches in it yet.
    if (director.weeklyCurrent){
      const wCur = director.weeklyCurrent();
      const w = wCur && wCur.totals.matches ? wCur : (director.weeklyLatest && director.weeklyLatest());
      if (w) send({ Event: '_weekly', Data: w });
    }
  }

  let closed = false;
  const ping = setInterval(() => { try{ res.write(': ping\n\n'); }catch{} }, 15000);

  // The game can emit 60-120 UpdateState/s; forward at most ~10/s (latest wins).
  // Discrete events (goals etc.) pass through immediately.
  let pendingUS = null, usTimer = null;
  const forward = raw => {
    if (raw.startsWith('{"Event":"UpdateState"')){
      pendingUS = raw;
      if (!usTimer) usTimer = setTimeout(() => {
        usTimer = null;
        if (pendingUS && !closed){ send(pendingUS); pendingUS = null; }
      }, 100);
    } else send(raw);
  };

  // a page opened after en spilopdatering skal kende vagtens dom med det samme —
  // banneret må ikke vente på næste 5-minutters-tjek
  if (statsGuard){ const sg = statsGuard.status(); if (sg) send({ Event: '_statsapi', Data: sg }); }
  // og en allerede fundet ny version skal ikke vente på næste døgn-tjek
  if (updateCheck){ const u = updateCheck.status(); if (u && u.updateAvailable) send({ Event: '_update', Data: u }); }

  // join the shared connection and report its real state straight away —
  // a page opened mid-match must not be told "connecting" when it is already live
  const feed = getFeed(host, port);
  send({ Event: '_bridge', Data: { state: feed.state, host, port } });
  const off = feed.add(forward, state => send({ Event: '_bridge', Data: { state } }));

  req.on('close', () => {
    closed = true;
    sseClients.delete(res);
    clearInterval(ping); clearTimeout(usTimer);
    off();
    releaseFeed(host, port);
  });
}

/* ---------------- rank emblems (RocketStats .tga -> .png) ----------------
 * RocketStats (BakkesMod plugin) ships rank emblem images as TGA files in
 * %APPDATA%\bakkesmod\bakkesmod\data\RocketStats\RocketStats_images.
 * Browsers can't render TGA, so convert on the fly (zero deps via zlib).
 */
const RS_IMAGES = process.env.APPDATA
  ? path.join(process.env.APPDATA, 'bakkesmod', 'bakkesmod', 'data', 'RocketStats', 'RocketStats_images')
  : null;
const imgCache = new Map();

const CRC_TABLE = (() => {
  const t = new Int32Array(256);
  for (let n = 0; n < 256; n++){
    let c = n;
    for (let k = 0; k < 8; k++) c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1);
    t[n] = c;
  }
  return t;
})();
function crc32(buf){
  let c = -1;
  for (let i = 0; i < buf.length; i++) c = CRC_TABLE[(c ^ buf[i]) & 0xFF] ^ (c >>> 8);
  return (c ^ -1) >>> 0;
}
function pngEncode(w, h, rgba){
  const stride = w * 4 + 1;
  const raw = Buffer.alloc(stride * h);
  for (let y = 0; y < h; y++){
    raw[y * stride] = 0;                                   // filter: none
    rgba.copy(raw, y * stride + 1, y * w * 4, (y + 1) * w * 4);
  }
  const chunk = (type, data) => {
    const len = Buffer.alloc(4); len.writeUInt32BE(data.length);
    const td = Buffer.concat([Buffer.from(type, 'latin1'), data]);
    const crc = Buffer.alloc(4); crc.writeUInt32BE(crc32(td));
    return Buffer.concat([len, td, crc]);
  };
  const ihdr = Buffer.alloc(13);
  ihdr.writeUInt32BE(w, 0); ihdr.writeUInt32BE(h, 4);
  ihdr[8] = 8; ihdr[9] = 6;                                // 8-bit RGBA
  return Buffer.concat([
    Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]),
    chunk('IHDR', ihdr), chunk('IDAT', zlib.deflateSync(raw)), chunk('IEND', Buffer.alloc(0))
  ]);
}
function tgaToPng(buf){
  const idLen = buf[0], cmType = buf[1], type = buf[2];
  const w = buf.readUInt16LE(12), h = buf.readUInt16LE(14), bpp = buf[16], desc = buf[17];
  if (cmType !== 0 || (type !== 2 && type !== 10) || (bpp !== 24 && bpp !== 32) || !w || !h)
    throw new Error('unsupported tga');
  const bytes = bpp / 8;
  const px = Buffer.alloc(w * h * 4);
  const put = (i, p) => {
    px[i * 4] = buf[p + 2]; px[i * 4 + 1] = buf[p + 1]; px[i * 4 + 2] = buf[p];   // BGR -> RGB
    px[i * 4 + 3] = bytes === 4 ? buf[p + 3] : 255;
  };
  let i = 0, p = 18 + idLen;
  if (type === 2){
    for (; i < w * h; i++, p += bytes) put(i, p);
  } else {                                                 // RLE
    while (i < w * h && p < buf.length){
      const hdr = buf[p++], count = (hdr & 127) + 1;
      if (hdr & 128){ for (let k = 0; k < count && i < w * h; k++, i++) put(i, p); p += bytes; }
      else for (let k = 0; k < count && i < w * h; k++, i++, p += bytes) put(i, p);
    }
  }
  if (!(desc & 32)){                                       // origin bottom-left -> flip
    const row = w * 4, tmp = Buffer.alloc(row);
    for (let y = 0; y < (h >> 1); y++){
      const a = y * row, b = (h - 1 - y) * row;
      px.copy(tmp, 0, a, a + row); px.copy(px, a, b, b + row); tmp.copy(px, b);
    }
  }
  return pngEncode(w, h, px);
}
function rankImage(name){
  if (!/^[A-Za-z_]{1,40}$/.test(name) || !RS_IMAGES) return null;
  if (imgCache.has(name)) return imgCache.get(name);
  // misses are cached too (a null means "no such emblem"), and the name comes
  // off the wire — bound the map so a LAN client can't grow it without limit
  if (imgCache.size > 200) imgCache.clear();
  let png = null;
  try{ png = tgaToPng(fs.readFileSync(path.join(RS_IMAGES, name + '.tga'))); }catch{}
  imgCache.set(name, png);
  return png;
}

/* ---------------- opponent rank lookup ----------------
 * The Stats API feed exposes every player's PrimaryId (Epic|id|0 / Steam|id64|0)
 * and Name but NO ranks, and EAC blocks BakkesMod online — so opponent ranks
 * come from an online lookup (RapidAPI, user-supplied key sent per request as
 * the x-rank-key header). Aggressive caching keeps the free tier viable: one
 * lookup per player per 30 days (RANK_TTL). mock=1 serves deterministic fake
 * ranks for demo mode and pipeline tests.
 * The key IS remembered locally in rank-cache.json, so the board's separate
 * Chrome profile can look up without re-entering it — which makes that file a
 * secret: never serve it, never ship it in a zip.
 *
 * 18/8-2026: provider swapped to rocket-league10 (the old rocket-league1 was
 * cancelled — it answered "Unranked, 355 MMR" and worse). Everything about the
 * provider now lives in director/rankapi.js, shared with rank-snapshot.js.
 * The new one is keyed by platform + DISPLAY NAME, not by the account id, so a
 * lookup needs the feed's Name — see rankNameOf().
 */
/* Free plans are brutally small (10 player lookups/DAY, hard limit) — so
 * successful lookups persist to disk for 30 days and survive restarts, a daily
 * counter stops upstream calls at the limit, and 429 pauses lookups for an hour
 * instead of burning the quota on retries. A player the provider has never
 * heard of (404) is a settled answer, not a failure: remembering it for a week
 * is what stops six unknown console players from eating a day's quota every
 * single evening. */
const RANK_TTL = 30 * 24 * 3600e3, RANK_NEG_TTL = 10 * 60e3, RANK_MISS_TTL = 7 * 24 * 3600e3;
/* PsyNet answers cost nothing, so a cached answer only needs to outlive the
 * lobby it was fetched for: 10 minutes. After that the next roster asks again
 * and gets tonight's rank, not last month's. */
const RANK_PSY_TTL = 10 * 60e3;
const rankapi = (() => {
  try{ return require('module').createRequire(path.join(ROOT, 'x.js'))(path.join(ROOT, 'director', 'rankapi.js')); }
  catch(e){ console.log('[rankapi] modul ikke indlæst — rank-opslag slået fra:', String(e.message || e)); return null; }
})();
const RANK_DAILY_LIMIT = rankapi ? rankapi.DAILY_LIMIT : (Number(process.env.RANK_DAILY_LIMIT) || 10);
const RANK_STATE_FILE = path.join(ROOT, 'rank-cache.json');
const rankState = (() => {
  try{ return JSON.parse(fs.readFileSync(RANK_STATE_FILE, 'utf8')); }catch{ return { players: {}, day: '', used: 0 }; }
})();
if (!rankState.names) rankState.names = {};      // pid -> last display name seen in the feed
let rankPausedUntil = 0;
function rankToday(){ return new Date().toISOString().slice(0, 10); }
function rankSave(){ try{ writeJSONAtomic(RANK_STATE_FILE, rankState); }catch{} }
/* The provider's own remaining count is the truth. RANK_DAILY_LIMIT is merely
 * what WE believe the plan to be — and this machine still carries a
 * RANK_DAILY_LIMIT=950 from the cancelled 1000/day plan, which must not be able
 * to talk us into spending calls the provider will refuse. Whichever says
 * "stop" first wins; only the provider can say "keep going". */
function rankQuota(){
  if (rankState.day !== rankToday()){
    rankState.day = rankToday(); rankState.used = 0; rankState.remaining = null;
    rankPausedUntil = 0; rankSave();
  }
  const remaining = Number.isFinite(rankState.remaining) ? rankState.remaining : null;
  return { used: rankState.used, limit: RANK_DAILY_LIMIT, remaining,
    exhausted: (remaining !== null && remaining <= 0)
      || rankState.used >= RANK_DAILY_LIMIT || Date.now() < rankPausedUntil };
}

function mockRank(pid){
  let h = 0;
  for (const ch of pid) h = (h * 31 + ch.charCodeAt(0)) >>> 0;
  const mk = off => {
    const t = 4 + ((h >>> off) % 17);           // Silver I .. GC III (unsigned shift!)
    const names = ['Unranked','Bronze I','Bronze II','Bronze III','Silver I','Silver II','Silver III',
      'Gold I','Gold II','Gold III','Platinum I','Platinum II','Platinum III','Diamond I','Diamond II',
      'Diamond III','Champion I','Champion II','Champion III','Grand Champion I','Grand Champion II',
      'Grand Champion III','Supersonic Legend'];
    return { label: names[t], division: 1 + ((h >>> (off + 2)) % 4), mmr: 400 + t * 62 + (h % 40) };
  };
  return { p1: mk(1), p2: mk(5), p3: mk(9) };
}

/* The provider is keyed by display name, so a lookup is only possible for a
 * player we have seen named. The feed names everyone in the lobby; remember
 * that, so a retry minutes later (or after a restart) still knows who this pid
 * is even when the roster is long gone. */
let rankNamesDirty = false;
function rankLearnNames(players){
  for (const p of (players || [])){
    const pid = p && (p.PrimaryId || p.pid), name = p && (p.Name || p.name);
    if (!pid || !name || rankState.names[pid] === name) continue;
    rankState.names[pid] = name;
    rankNamesDirty = true;
  }
}
function rankNameOf(pid){ return rankState.names[pid] || null; }

/* Free-tier plans rate-limit hard (429 seen in the wild) — space upstream
 * calls globally and retry failures soon instead of caching them for long. */
let rankQueue = Promise.resolve(), rankLastCall = 0;
const RANK_GAP = 1600, RANK_RETRY_TTL = 90e3;

function throttledFetchRank(pid, name, key){
  const run = async () => {
    const wait = Math.max(0, rankLastCall + RANK_GAP - Date.now());
    if (wait) await new Promise(r => setTimeout(r, wait));
    rankLastCall = Date.now();
    return rankapi.fetchRank({ pid, name, key, log: m => console.log(m) });
  };
  const p = rankQueue.then(run, run);
  rankQueue = p.catch(() => {});
  return p;
}

function isLocalReq(req){
  const a = (req.socket && req.socket.remoteAddress) || '';
  return a === '127.0.0.1' || a === '::1' || a === '::ffff:127.0.0.1';
}

async function playerRanks(url, req){
  const sent = req.headers['x-rank-key'];
  // Only a request from this machine may replace the stored key: the setup
  // field lives in the local page, and a LAN board must never be able to
  // overwrite (or silently swap) the user's key on disk.
  if (sent && sent !== rankState.key && isLocalReq(req)){ rankState.key = sent; rankSave(); }
  const key = sent || rankState.key;                     // remembered locally so every window can look up
  const mock = url.searchParams.get('mock') === '1';
  const ids = (url.searchParams.get('players') || '').split(',').filter(Boolean).slice(0, 8);
  /* Names ride along index-aligned with players. Each one is encoded
   * separately before the list is joined, because a display name may contain
   * the comma we split on. The page has the live roster; the server's own
   * memory of the feed covers retries after the lobby is gone. */
  const sentNames = (url.searchParams.get('names') || '').split(',').map(s => {
    try{ return decodeURIComponent(s); }catch{ return s; }
  });
  const out = {};
  let dirty = false;
  /* PsyNet first (18/8-2026): the whole roster in ONE call, straight from the
   * game's backend, no quota. Anything it answers is cached for RANK_PSY_TTL and
   * served from the loop below; anything it cannot answer (never played ranked,
   * exotic platform prefix) is a real "no data" and gets the short negative
   * TTL. If the reader session is down, the loop falls through to rankapi —
   * a stale PsyNet answer (data present) still counts as known for RANK_TTL,
   * so a hiccup never spends the 10/day quota on players we already hold. */
  const psyLive = !mock && skillsAvailable();
  let psySource = null;
  if (psyLive){
    const isBackend = c => c && (c.source === 'psynet' || c.source === 'relay');
    const need = ids.filter(pid => {
      const c = rankState.players[pid];
      return !(isBackend(c) && Date.now() - c.t < (c.data ? RANK_PSY_TTL : RANK_NEG_TTL));
    });
    if (need.length){
      const r = await skillsLookup(need);        // logs its own failures; null = both sources down
      if (r){
        psySource = r.source;
        for (const pid of need){
          const data = (r.ranks && r.ranks[pid]) || null;
          rankState.players[pid] = { t: Date.now(), ttl: data ? RANK_PSY_TTL : RANK_NEG_TTL, data, source: r.source };
          dirty = true;
          if (data && director && director.onRank){
            try{ director.onRank(pid, data); }catch(e){ console.log('[weekly] rank-fejl:', String(e.message || e)); }
          }
        }
      }
    }
  }
  for (let i = 0; i < ids.length; i++){            // client sends opponents first — quota goes to them
    const pid = ids[i];
    // mock is checked BEFORE the cache: a real negative cache entry used to
    // leak into demo mode and show empty rank badges
    if (mock){ out[pid] = mockRank(pid); continue; }   // never touches disk state or quota
    const name = sentNames[i] || rankNameOf(pid);
    if (name && rankState.names[pid] !== name){ rankState.names[pid] = name; rankNamesDirty = true; }
    const t = rankapi && rankapi.target(pid, name);
    const c = rankState.players[pid];
    /* A cached answer is about a specific identity. If the player has since
     * renamed, the old miss says nothing about the new name — re-open it. */
    const sameIdent = !c || !c.ident || !t || c.ident === t.ident;
    if (c && sameIdent && Date.now() - c.t < (c.data ? RANK_TTL : c.ttl || RANK_NEG_TTL)){ out[pid] = c.data; continue; }
    let data = null, ttl = RANK_NEG_TTL;
    const q = rankQuota();
    if (!t){
      /* No platform we can ask about (Unknown|0|0), or no name yet. The first
       * is permanent, the second resolves itself the moment the feed names
       * them — so only the permanent one is worth a long silence. */
      const unmapped = !(rankapi && rankapi.PLATFORM[String(pid).split('|')[0]]);
      out[pid] = null;
      if (unmapped){ rankState.players[pid] = { t: Date.now(), ttl: RANK_TTL, data: null }; dirty = true; }
      continue;
    }
    if (key && !q.exhausted){
      const r = await throttledFetchRank(pid, name, key);
      data = r.data;
      /* Every upstream call costs the same at the provider whether it comes
       * back with a rank or with "player not found", so count attempts — and
       * let the response's own remaining overwrite the count whenever it is
       * there, since that survives restarts, the RapidAPI web console and any
       * other machine sharing this key. */
      rankState.used++;
      if (Number.isFinite(r.remaining)) rankState.remaining = r.remaining;
      dirty = true;
      /* A hit needs no ttl — data present means RANK_TTL. Everything else is a
       * decision about how long to stay quiet about this player. */
      if (!data){
        if (r.status === 429){ rankPausedUntil = Date.now() + 3600e3; ttl = 60e3; console.log('[rankapi] kvote/rate ramt — pauser opslag 1 time'); }
        else if (r.status === 403){ rankPausedUntil = Date.now() + 3600e3; ttl = 60e3; console.log('[rankapi] 403 — nøglen har ikke abonnement på denne API; pauser opslag 1 time'); }
        else if (r.status === 404) ttl = RANK_MISS_TTL;   // provider has never seen this player
        else ttl = r.retryable ? RANK_RETRY_TTL : RANK_NEG_TTL;
      }
    }
    rankState.players[pid] = { t: Date.now(), ttl, data, ident: t.ident };
    if (data) dirty = true;
    // M4: a fresh lookup is the only rank measurement this product ever gets
    // (10 free per day). Hand it to the director so the weekly report can draw
    // the curve; it keeps the tracked player's points and drops everyone else's.
    if (data && director && director.onRank){
      try{ director.onRank(pid, data); }catch(e){ console.log('[weekly] rank-fejl:', String(e.message || e)); }
    }
    out[pid] = data;
  }
  if (dirty || rankNamesDirty){
    const keys = Object.keys(rankState.players);
    if (keys.length > 800) for (const k of keys.slice(0, keys.length - 800)) delete rankState.players[k];
    const names = Object.keys(rankState.names);
    if (names.length > 2000) for (const k of names.slice(0, names.length - 2000)) delete rankState.names[k];
    rankNamesDirty = false;
    rankSave();
  }
  /* provider names the source that served THIS answer: a fresh backend call
   * says which one, a cache-only answer says "psynet" (the board treats both
   * backend sources alike — short ttl, no quota display). */
  return { ranks: out,
    provider: mock ? 'mock' : psyLive ? (psySource || 'psynet') : (key ? 'rapidapi' : 'none'),
    quota: (mock || psyLive) ? null : rankQuota() };
}

/* ---------------- match telemetry recorder ----------------
 * Always-on: keeps its own TCP connection to the game (independent of any
 * browser) and writes one compact JSON digest per match to <ROOT>/matches/.
 * This is the long-term dataset for coaching analysis (/rl-coach).
 */
// folded through the same loopback normalisation the pages use, or STATS_HOST=localhost
// would key a second feed and the recorder would open its own duplicate socket
const STATS_HOST = (h => LOOPBACK.test(h) ? '127.0.0.1' : h)(process.env.STATS_HOST || '127.0.0.1');
const STATS_PORT = Number(process.env.STATS_PORT) || 49123;
/* Loft for hitEvents pr. digest. En onlinekamp lander på ~300-600 events; den
 * målte 2-timers træningsrunde 30/7 ville have givet ~4.000. 6.000 dækker
 * begge med luft, og overskridelse tælles i hitEventsDropped — aldrig tavst. */
const HIT_EVENTS_MAX = 6000;
const MATCH_DIR = path.join(ROOT, 'matches');
const ADAPTER_VERSION = '1.4.0';   // 1.1.0 digest envelope · 1.2.0 movement + per-second curve (M4b) · 1.3.0 hitEvents (per-hit tidsakse) · 1.4.0 playlistId (rå Game.PlaylistId)

/* RL Director (M1): deterministic debrief engine, loaded from disk so it also
 * works inside the SEA exe (whose builtin require can't load local files). */
let director = null;
try{
  const req = require('module').createRequire(path.join(ROOT, 'x.js'));
  // broadcastSSE is declared earlier in this file, so the session engine can
  // push its after-session report to every connected board.
  director = req(path.join(ROOT, 'director', 'director.js'))
    .init({ root: ROOT, log: m => console.log(m), broadcast: o => broadcastSSE(o) });
  console.log('[director] klar (pack ' + director.PACK_VERSION + ')');
}catch(e){ console.log('[director] ikke indlæst — debriefs slået fra:', String(e.message || e)); }

/* Reach-tælleren (13/8): tæller åbninger af coach-fladerne — logik og
 * principper i director/reach.js. Egen try/catch og egen createRequire:
 * reach skal også måle et board der kører uden fungerende director. */
let reach = null;
try{
  const reqR = require('module').createRequire(path.join(ROOT, 'x.js'));
  reach = reqR(path.join(ROOT, 'director', 'reach.js')).init({ root: ROOT, log: m => console.log(m) });
}catch(e){ console.log('[reach] ikke indlæst (fortsætter uden):', String(e.message || e)); }

/* Testrunden (25/8, gate 6): første-kørsels-valget "Join the test round?" og
 * feedback-beskeder til collectoren. Tynde ruter her — logik og tekster bor i
 * director/testround.js, som kan ændres uden SEA-genbyg. Egen try/catch og
 * egen createRequire: en tracker uden modulet er stadig en tracker. */
let testround = null;
try{
  const reqTR = require('module').createRequire(path.join(ROOT, 'x.js'));
  testround = reqTR(path.join(ROOT, 'director', 'testround.js')).init({ root: ROOT, log: m => console.log(m) });
}catch(e){ console.log('[testround] ikke indlæst (fortsætter uden):', String(e.message || e)); }

/* StatsAPI-vagten (14/8): to Epic-opdateringer i træk har nulstillet
 * DefaultStatsAPI.ini (PacketSendRate=0) og efterladt trackeren stum. Vagten
 * finder ini'en via Epic-launcherens manifester, retter raten selv og melder
 * `_statsapi` over SSE når et menneske skal genstarte spillet. Logik og
 * fælder: director/statsapi-guard.js. Egen try/catch: en tracker uden vagt
 * skal stadig være en tracker. */
let statsGuard = null;
try{
  const reqG = require('module').createRequire(path.join(ROOT, 'x.js'));
  statsGuard = reqG(path.join(ROOT, 'director', 'statsapi-guard.js')).init({
    root: ROOT, log: m => console.log(m),
    desiredRate: Number(process.env.STATSAPI_RATE) || 120,
    expectPort: STATS_PORT,
    iniOverride: process.env.STATSAPI_INI || null,
    onChange: s => broadcastSSE({ Event: '_statsapi', Data: s })
  }).start(5 * 60e3);
}catch(e){ console.log('[statsapi] vagt ikke indlæst (fortsætter uden):', String(e.message || e)); }

/* Versions-tjek (14/8): stille dagligt opslag mod det offentlige releases-repo
 * (github.com/HilsenFar/rl-tracker-releases) — fase 1 af opdaterings-kanalen:
 * besked på boardet, aldrig selv-opdatering. Anonymt kald; slås fra med
 * "updateCheck": false i director-ai.json eller UPDATE_CHECK=0.
 * Logik: director/update-check.js. */
const APP_VERSION = '2026.08.26.1';
let updateCheck = null;
try{
  let updOff = process.env.UPDATE_CHECK === '0';
  try{ if (JSON.parse(fs.readFileSync(path.join(ROOT, 'director-ai.json'), 'utf8')).updateCheck === false) updOff = true; }catch{}
  if (updOff){
    console.log('[update] versions-tjek slået fra (config/env)');
  } else {
    const reqU = require('module').createRequire(path.join(ROOT, 'x.js'));
    updateCheck = reqU(path.join(ROOT, 'director', 'update-check.js')).init({
      log: m => console.log(m), appVersion: APP_VERSION,
      onChange: s => { if (s.updateAvailable) broadcastSSE({ Event: '_update', Data: s }); }
    }).start();
  }
}catch(e){ console.log('[update] ikke indlæst (fortsætter uden):', String(e.message || e)); }

/* PsyNet (18/8-2026): rank fra spillets EGEN backend via en separat læserkonto —
 * ægte MMR/tier for spilleren og hele lobbyen i ét kald, ingen døgnkvote.
 * Brugerens fund (AeonLucid/RocketLeaguePublic + dank/rlapi); alt om kæden,
 * DuplicateLogin-reglen og token-rotationen står i director/psynet.js.
 * rocket-league10 (rankapi.js) bliver fallback, ikke erstattet: en tracker
 * uden læserkonto skal stadig kunne slå op som før. */
let psynet = null;
try{
  const reqP = require('module').createRequire(path.join(ROOT, 'x.js'));
  psynet = reqP(path.join(ROOT, 'director', 'psynet.js')).init({
    ROOT, log: m => console.log(m),
    trackedPid: () => { try{ return director && director.getTracked ? director.getTracked().pid : null; }catch{ return null; } },
    onEvent: ev => broadcastSSE({ Event: '_psynet', Data: { ...ev, status: psynet ? psynet.status() : null } })
  });
}catch(e){ console.log('[psynet] ikke indlæst (fortsætter med rankapi):', String(e.message || e)); }

/* Rank-relæet (18/8, aften): collect.gitato.net holder ÉN læserkonto for alle
 * trackere. Standardvejen — ingen tester skal oprette noget. Rækkefølge:
 * egen læserkonto (hvis forbundet) → relæ → rocket-league10. Slås fra med
 * RANK_RELAY=off eller "rankRelay": false i director-ai.json. */
let rankRelay = null;
try{
  let relayUrl = process.env.RANK_RELAY;
  if (relayUrl === undefined){
    try{ const c = JSON.parse(fs.readFileSync(path.join(ROOT, 'director-ai.json'), 'utf8'));
      if (c.rankRelay !== undefined) relayUrl = c.rankRelay; }catch{}
  }
  const reqRR = require('module').createRequire(path.join(ROOT, 'x.js'));
  rankRelay = reqRR(path.join(ROOT, 'director', 'rankrelay.js')).init({ url: relayUrl, log: m => console.log(m), userAgent: UA });
  console.log(rankRelay.enabled() ? '[rankrelay] standard-kilde: ' + rankRelay.url : '[rankrelay] slået fra');
}catch(e){ console.log('[rankrelay] ikke indlæst (fortsætter uden):', String(e.message || e)); }

/* Én dør til "ranks fra spillets backend": egen læserkonto først, ellers relæet.
 * Returnerer { ranks: {pid: data|null}, source } eller null når ingen af dem
 * kan lige nu — så tager rankapi (kvote) over hos kalderen. */
async function skillsLookup(pids, opts){
  if (psynet && psynet.available()){
    try{ return { ranks: await psynet.getSkills(pids), source: 'psynet' }; }
    catch(e){ console.log('[psynet] opslag fejlede: ' + String(e.message || e)); }
  }
  if (rankRelay && rankRelay.available()){
    try{ return { ranks: await rankRelay.getSkills(pids, opts), source: 'relay' }; }
    /* the module logs HTTP/network failures itself; anything else here is a
     * programming error and must be heard, not swallowed (18/8 15:24-16:00: a
     * missing parameter threw ReferenceError into an empty catch and every
     * badge said "unavailable" for half an hour) */
    catch(e){ if (!/relæ|HTTP|timeout|backoff/.test(String(e && e.message))) console.log('[rankrelay] intern fejl: ' + String(e && e.stack || e)); }
  }
  return null;
}
function skillsAvailable(){
  return !!((psynet && psynet.available()) || (rankRelay && rankRelay.available()));
}

/* Efter hver onlinekamp: spillerens egen rank slås op via PsyNet, så ugekurven
 * (weekly.onRank) får ét ægte punkt PR. KAMP i stedet for ét pr. dagskvote, og
 * boardet får MMR-deltaet. Backend-tallet opdateres nogle sekunder efter
 * kampen (målt 18/8: inden for 2 min) — derfor to forsøg; onRank dropper selv
 * et punkt der ikke har flyttet sig. */
function psynetSelfLookup(reason){
  if (!skillsAvailable()) return;
  let pid = null;
  try{ pid = director && director.getTracked ? director.getTracked().pid : null; }catch{}
  if (!pid) return;
  const prevEntry = rankState.players[pid];
  const prev = prevEntry && prevEntry.data ? prevEntry.data : null;
  // fresh: bypass the relay's cache — we want the number AFTER this match, not
  // the one the board fetched at kickoff (18/8 15:21: "0 friske", no delta)
  skillsLookup([pid], { fresh: true }).then(r => {
    const data = r && r.ranks && r.ranks[pid];
    if (!data) return;
    rankState.players[pid] = { t: Date.now(), ttl: RANK_PSY_TTL, data, source: r.source };
    rankSave();
    if (director && director.onRank){ try{ director.onRank(pid, data); }catch(e){ console.log('[weekly] rank-fejl:', String(e.message || e)); } }
    const delta = {};
    for (const k of ['p1', 'p2', 'p3']){
      if (data[k] && prev && prev[k] && Number.isFinite(data[k].mmr) && Number.isFinite(prev[k].mmr) && data[k].mmr !== prev[k].mmr)
        delta[k] = data[k].mmr - prev[k].mmr;
    }
    if (Object.keys(delta).length) console.log('[psynet] MMR-delta (' + reason + '): '
      + Object.entries(delta).map(([k, d]) => k + ' ' + (d > 0 ? '+' : '') + d).join(', '));
    broadcastSSE({ Event: '_rank', Data: { pid, data, prev, delta, at: Date.now(), reason } });
  }).catch(e => console.log('[psynet] selv-opslag fejlede: ' + String(e.message || e)));
}

const rec = { m: null, clock: null, overtime: false, awaitKickoff: false, roundStartClock: null, lastEndedGuid: null, mv: null, lastHit: null };

function recNewMatch(guid){
  rec.m = {
    guid: guid || null, startedAt: new Date().toISOString(), endedAt: null,
    /* Spillets egen playlist-id, rå og uoversat (Game.PlaylistId). Holdstørrelse
     * alene kan ikke se en privat lobby: 14/8-2026 blev en privat kamp med 4
     * aktive spillere klassificeret "1v1", fordi slut-rosteren kun holdt 2
     * (digest 654FBADA…, slutscore 1-8) — og den flød ind i ugens 1v1-pulje.
     * Additivt felt: gamle digests er blot digests uden det. null betyder
     * "feedet sendte aldrig id'et" (recWrite logger det, så den første kamp
     * efter deploy afgør præmissen). Fortolkningen af id'et (privat/ranked/…)
     * bor i director/metrics.js — aldrig her (samme princip som bhist). */
    playlistId: null,
    arena: '', teams: [], winnerTeamNum: null, overtime: false, abandoned: false,
    players: [], goals: [], scoreline: [], kickoffs: [], demos: [],
    hits: {},                                  // name -> {count,sumSpeed,maxSpeed,plusY,minusY}
    /* Per-hit events WITH a time axis (30/7-2026). The aggregate above answers
     * "hvor hårdt i snit", aldrig "HVORNÅR" — og både de psykologiske vinduer
     * (touchkraft i de 90 s efter et indkasseret mål) og brugerens
     * modstander-tryk-heuristik (stimer af modstander-touches lige efter egne)
     * kræver sekvensen. Boost har allerede sin tidsakse via timeline (M4b);
     * hits var det sidste aggregat uden én. Additivt felt — intet eksisterende
     * ændrer form, og gamle digests er blot digests uden feltet. */
    hitEvents: [],                             // {t: kampur|null, w: sek. siden start, name, team, spd, y}
    boost: {},                                 // name -> {samples,sum,low} (own team only)
    passes: {},                                // name -> count; his touch then a team-mate's, nobody in between
    movement: {},                              // name -> time-weighted movement totals (M4b, own team only)
    timeline: {},                              // name -> per-second curve; compacted to arrays at write
    movementGaps: 0                            // frames whose gap was too long to be play; diagnostics only
  };
  rec.clock = null; rec.overtime = false; rec.awaitKickoff = false; rec.lastHit = null;
  rec.mStartMs = Date.now();                   // vægur-basis for hitEvents.w — kampuret står stille i træning
  rec.mv = { team: null, clock: null, segClock: null, clockAt: 0, lastAt: 0,
             pending: [], p: new Map(), seg: new Map(), resync: true };
}

/* ---------------- movement sampling (M4b) ----------------
 * Players[].Speed is a SPECTATOR field. Live-probed against a running 3v3 on
 * 2026-07-27 (123k frames): it is present for the user's OWN TEAM in every
 * frame and absent for opponents — except during goal replays, where the
 * camera has a target and the feed hands out that player's full record. Every
 * accumulator below therefore runs on non-replay frames only, and "own team"
 * is the TeamNum that carries movement fields in those frames.
 *
 * The feed OMITS falsy values rather than sending them: across those 123k
 * frames `Boost: 0` was sent 0 times, `bOnGround: false` 0 times and
 * `Speed: 0` 0 times. For a player we already know is on the movement team, a
 * missing field therefore means 0/false — not "unknown".
 *
 * Time, not samples, is the unit. The feed runs at ~100 Hz but is not promised
 * to, so everything is weighted by real elapsed dt; counting samples would
 * quietly turn a sample rate into a clock (DESIGN §3).
 *
 * The match clock gates it, and the gate is RETROACTIVE on purpose. The clock
 * ticks once a second while play runs and freezes during kickoff countdowns,
 * replays and pauses — but a freeze can only be recognised after the fact, so
 * a plain "has the clock moved lately" test lets the first second of every
 * countdown through. That second is a car sitting perfectly still, and it lands
 * straight in the hesitation proxy: measured against a synthetic countdown it
 * leaked 0.87 s of zero-speed time per stop. So frames accumulate into a
 * SEGMENT instead, and a segment is only booked when the clock actually ticks —
 * proof that the whole segment was live play. A frozen clock throws its segment
 * away and nothing reaches the archive.
 *
 * The cost is the final partial second of each segment, which never gets its
 * closing tick. It is under a second per stoppage, it is the same in every
 * match, and every comparison here is a player against his own history — so it
 * cancels. The alternative was counting standstill as play, which does not.
 */
const MOVE_STALL_MS  = 1500;   // clock ticks 1/s; longer than this means it is not running
const MOVE_DT_CAP_MS = 50;     // ~100 Hz nominal — a longer gap is a hitch, not 50 ms of play
const MOVE_BIN_W     = 2.5;    // speed-histogram bin width, in whatever unit the feed speaks
const MOVE_BINS      = 41;     // 0..100 plus one overflow bin — covers km/h and mph outright
const MOVE_TL_MAX    = 1500;   // per-second curve cap per player (25 min of clock) — OT is not unbounded
const MOVE_BOOST_W   = 5;      // boost histogram bin width (boost is 0-100)
const MOVE_BOOST_BINS = 20;

function mvNew(){
  return { sec: 0, frames: 0, spdInt: 0, spdMax: 0, ssMaxSpd: 0, boostInt: 0,
           ssSec: 0, airSec: 0, wallSec: 0, boostingSec: 0, slideSec: 0,
           // Time per boost band. The old accumulator counted SAMPLES below a
           // threshold frozen at capture time; this stores the distribution, so
           // where "low" sits stays a decision the metric engine can revise —
           // and the threshold lives in exactly one place (metrics.BOOST_LOW_AT)
           // instead of being duplicated here.
           bhist: new Array(MOVE_BOOST_BINS).fill(0), bhistW: MOVE_BOOST_W,
           // binWidth from the start, not stamped on at write time: the focus
           // meter runs computeMetrics on the match IN PROGRESS, and a
           // histogram without its bin width is unreadable — the metric would
           // silently go missing mid-match and only appear once it ended.
           hist: new Array(MOVE_BINS).fill(0), binWidth: MOVE_BIN_W,
           jumps: 0, jumpBumps: 0, jumpWalls: 0 };
}
function mvAcc(m, name){ return m.movement[name] || (m.movement[name] = mvNew()); }

/* Book a proven-live segment: totals add, peaks take the larger, and the
 * segment becomes one point on the per-second curve. */
function mvBook(m, mv){
  for (const [name, s] of mv.seg){
    if (!(s.sec > 0)) continue;
    const a = mvAcc(m, name);
    a.sec += s.sec; a.frames += s.frames; a.spdInt += s.spdInt; a.boostInt += s.boostInt;
    a.ssSec += s.ssSec; a.airSec += s.airSec; a.wallSec += s.wallSec;
    a.boostingSec += s.boostingSec; a.slideSec += s.slideSec;
    if (s.spdMax > a.spdMax) a.spdMax = s.spdMax;
    if (s.ssMaxSpd > a.ssMaxSpd) a.ssMaxSpd = s.ssMaxSpd;
    for (let i = 0; i < MOVE_BINS; i++) a.hist[i] += s.hist[i];
    for (let i = 0; i < MOVE_BOOST_BINS; i++) a.bhist[i] += s.bhist[i];
    const tl = m.timeline[name] || (m.timeline[name] = []);
    if (tl.length < MOVE_TL_MAX)
      tl.push({ t: mv.segClock, sec: s.sec, spd: s.spdInt, bst: s.boostInt, air: s.airSec });
  }
  mv.seg = new Map();
}

function recMovement(d, m, now){
  const mv = rec.mv;
  if (!mv) return;
  const clk = d.Game.TimeSeconds;

  if (clk !== mv.clock){                       // the clock moved: the segment just closed was live play
    if (mv.clock !== null) mvBook(m, mv);
    mv.clock = clk; mv.clockAt = now; mv.segClock = clk;
  }
  if (mv.team === null){
    for (const p of d.Players)
      if (p && (typeof p.Speed === 'number' || typeof p.Boost === 'number')){ mv.team = p.TeamNum; break; }
    if (mv.team === null){ mv.lastAt = now; return; }
  }

  /* Replay frames carry every player (the camera has a target) and are never
   * play. A clock that has not ticked for MOVE_STALL_MS is a countdown, a
   * replay or a pause — but "not ticked" alone is too blunt at the RESTART:
   * the clock only ticks once a second, so the first second back on the pitch
   * still looks stalled, and that second is the kickoff sprint. The feed
   * settles it: a car at rest has its Speed field omitted entirely, so a
   * frozen countdown has NOBODY carrying a speed while the moment after "GO"
   * has everybody. Stalled AND nobody moving is a stoppage; stalled while the
   * cars are moving is the restart, and it counts.
   *
   * It cannot let a stoppage back in: a segment is still only booked when the
   * clock actually ticks, and the countdown that always precedes a restart
   * clears whatever the celebration before it had accumulated. */
  const stalled = now - mv.clockAt >= MOVE_STALL_MS;
  const moving = !stalled || d.Players.some(p => p && p.TeamNum === mv.team && typeof p.Speed === 'number');
  if (d.Game.bReplay || !moving){
    if (mv.seg.size) mv.seg = new Map();
    mv.lastAt = now; mv.resync = true;         // state is stale; do not read a jump across the gap
    return;
  }

  let dtMs = mv.lastAt ? now - mv.lastAt : 0;
  mv.lastAt = now;
  if (dtMs <= 0) return;
  if (dtMs > MOVE_DT_CAP_MS){ m.movementGaps = (m.movementGaps || 0) + 1; dtMs = MOVE_DT_CAP_MS; }
  const dt = dtMs / 1000;
  const resync = mv.resync; mv.resync = false;

  for (const p of d.Players){
    if (!p || !p.Name || p.TeamNum !== mv.team) continue;
    /* A demolished player is not a slow player. For the ~3 s until the car
     * respawns there IS no car, so the feed omits Speed and bOnGround — which
     * reads, field for field, exactly like standing still in mid-air. Counted,
     * every demo would add three seconds of "hesitation" and three seconds of
     * "off the ground" to the two metrics least able to survive it.
     * bHasCar is the discriminator, and it is trustworthy for the same measured
     * reason: the feed omits falsy values, so bHasCar === true means a car. */
    if (p.bHasCar !== true){ mv.p.delete(p.Name); continue; }
    let s = mv.seg.get(p.Name);
    if (!s) mv.seg.set(p.Name, s = mvNew());
    const spd = typeof p.Speed === 'number' ? p.Speed : 0;
    const bst = typeof p.Boost === 'number' ? p.Boost : 0;
    const onG = p.bOnGround === true, onW = p.bOnWall === true;

    s.frames++; s.sec += dt;
    s.spdInt += spd * dt;                       // speed x time: distance, once the unit is known
    s.boostInt += bst * dt;
    if (spd > s.spdMax) s.spdMax = spd;
    if (p.bSupersonic === true){
      s.ssSec += dt;
      // The unit probe. Supersonic is a fixed 2200 uu/s and the car caps at
      // 2300, so the fastest sample seen WHILE supersonic lands in a band that
      // names the unit outright: ~79-83 km/h, ~49-51 mph, or 2200-2300 uu/s.
      // (The flag itself lingers below the threshold after a hard stop, so the
      // minimum while supersonic proves nothing — the maximum does.)
      if (spd > s.ssMaxSpd) s.ssMaxSpd = spd;
    }
    if (!onG) s.airSec += dt;
    if (onW) s.wallSec += dt;
    if (p.bBoosting === true) s.boostingSec += dt;
    if (p.bPowersliding === true) s.slideSec += dt;
    // Time per speed band, so a "slow" threshold stays a decision the metric
    // engine makes later and can revise — never one frozen into the archive.
    s.hist[Math.min(MOVE_BINS - 1, Math.max(0, Math.floor(spd / MOVE_BIN_W)))] += dt;
    s.bhist[Math.min(MOVE_BOOST_BINS - 1, Math.max(0, Math.floor(bst / MOVE_BOOST_W)))] += dt;

    /* Jumps, derived (M4b §3b). A car leaves the ground for one of three
     * reasons: it jumped, it was hit, or it drove off a wall. CarTouches is a
     * per-player counter, so a bump shows up as an increment; bOnWall names the
     * third. What is left is a jump. Double jumps stay unmeasurable — the
     * second one happens while already airborne, so nothing changes state, and
     * the feed carries no vertical speed to see it with. */
    let st = mv.p.get(p.Name);
    if (!st) mv.p.set(p.Name, st = { onG, ctVal: p.CarTouches, ctAt: 0, wallAt: 0 });
    if (typeof p.CarTouches === 'number' && p.CarTouches !== st.ctVal){ st.ctVal = p.CarTouches; st.ctAt = now; }
    if (onW) st.wallAt = now;
    if (!resync && st.onG && !onG && p.bHasCar === true)
      mv.pending.push({ name: p.Name, at: now, wall: now - st.wallAt < 250 });
    st.onG = onG;
  }

  // A pending take-off is judged once its +/-1 s contact window has passed.
  for (let i = mv.pending.length - 1; i >= 0; i--){
    const j = mv.pending[i];
    if (now - j.at < 1000) continue;
    mv.pending.splice(i, 1);
    const st = mv.p.get(j.name), a = mvAcc(m, j.name);
    if (st && st.ctAt > j.at - 1000) a.jumpBumps++;
    else if (j.wall) a.jumpWalls++;
    else a.jumps++;
  }
}

/* Match over: round the totals and fold the per-second buckets into the
 * parallel arrays the curve is drawn from. Rounding happens once, here, so the
 * archive stays small and every reader sees the same numbers — never a float
 * that renders differently in two places. */
function mvCompact(m){
  const r = (v, n) => { const f = Math.pow(10, n); return Math.round(v * f) / f; };
  for (const name of Object.keys(m.movement || {})){
    const a = m.movement[name];
    if (!(a.sec > 0)){ delete m.movement[name]; continue; }
    a.sec = r(a.sec, 2); a.spdInt = r(a.spdInt, 1); a.boostInt = r(a.boostInt, 1);
    a.spdMax = r(a.spdMax, 2); a.ssMaxSpd = r(a.ssMaxSpd, 2);
    a.ssSec = r(a.ssSec, 2); a.airSec = r(a.airSec, 2); a.wallSec = r(a.wallSec, 2);
    a.boostingSec = r(a.boostingSec, 2); a.slideSec = r(a.slideSec, 2);
    a.hist = a.hist.map(v => r(v, 2));
    a.bhist = a.bhist.map(v => r(v, 2));
  }
  for (const name of Object.keys(m.timeline || {})){
    const buckets = m.timeline[name];
    const t = [], spd = [], bst = [], air = [];
    for (const b of buckets){
      if (!(b.sec > 0)) continue;
      t.push(b.t);
      spd.push(r(b.spd / b.sec, 1));
      bst.push(Math.round(b.bst / b.sec));
      air.push(r(b.air / b.sec, 2));
    }
    if (t.length) m.timeline[name] = { t, spd, bst, air };
    else delete m.timeline[name];
  }
}
function recWrite(){
  const m = rec.m;
  rec.m = null;
  if (!m || !m.players.length || !m.scoreline.length) return;
  if (m.abandoned && !m.goals.length && !m.kickoffs.length) return;   // no gameplay observed (post-game artifact)
  m.endedAt = new Date().toISOString();
  mvCompact(m);
  // digest envelope (rl-director/DESIGN.md §3): downstream consumers key on these
  m.schema = 'digest/1';
  m.game = 'rl';
  m.adapterVersion = ADAPTER_VERSION;
  const sizes = [0, 0];
  for (const p of m.players) if (p.team === 0 || p.team === 1) sizes[p.team]++;
  // players is the FINAL snapshot — a leaver vacates it, so judge two-sidedness
  // on match history too (goals/kickoff first-touches carry team numbers).
  // Same evidence rule as metrics.validity(); freeplay has neither guid nor
  // two-sided history and still lands as 'offline'.
  const twoSided = (sizes[0] && sizes[1])
    || new Set(m.goals.map(g => g.team)).size > 1
    || new Set(m.kickoffs.map(k => k.team)).size > 1;
  m.mode = m.guid && twoSided ? 'online' : 'offline';
  const teamSize = Math.max(sizes[0], sizes[1]);
  m.playlist = teamSize >= 1 && teamSize <= 4 ? teamSize + 'v' + teamSize : 'other';
  // Feltnavnet Game.PlaylistId er brugerens egen feed-aflæsning; de offentlige
  // referencer dokumenterer det ikke. Én linje pr. online-kamp gør et fravær
  // synligt i stedet for tavst — en digest med null her må aldrig ligne måling.
  if (m.mode === 'online' && m.playlistId === null)
    console.log('[recorder] Game.PlaylistId aldrig set i feedet — digest.playlistId = null');
  const stamp = m.startedAt.replace(/[:.]/g, '-').slice(0, 19);
  const file = stamp + '-' + (m.guid || 'offline').slice(0, 12) + '.json';
  try{
    fs.mkdirSync(MATCH_DIR, { recursive: true });
    writeJSONAtomic(path.join(MATCH_DIR, file), m);
    console.log('[recorder] kamp gemt:', stamp, m.teams.map(t => t.score).join('-'),
      m.abandoned ? '(afbrudt)' : '');
  }catch(e){ console.log('[recorder] skrivefejl:', String(e.message || e)); }
  // Deliberately outside the write's try: a failed disk write (full disk,
  // locked file, a second process) used to skip the debrief entirely, so the
  // user lost the coaching as well as the archive entry. The digest is in
  // memory either way — the debrief can still be produced from it.
  if (director){
    try{
      const debrief = director.onDigest(m, file);
      if (debrief) broadcastSSE({ Event: '_director', Data: debrief });
    }catch(e){ console.log('[director] fejl:', String(e.message || e)); }
  }
  // Own rank after the match (PsyNet). Three tries: ranked wins book within
  // 25 s, but a tournament measured 18/8 booked one win only ~8 min after the
  // match (and a loss never) — so a late look catches what the early ones miss.
  if (m.mode === 'online' && !m.abandoned && (psynet || rankRelay)){
    setTimeout(() => psynetSelfLookup('kamp+25s'), 25e3);
    setTimeout(() => psynetSelfLookup('kamp+150s'), 150e3);
    setTimeout(() => psynetSelfLookup('kamp+10min'), 600e3);
  }
}
function recOnMsg(raw){
  let msg; try{ msg = JSON.parse(raw); }catch{ return; }
  let d = msg.Data;
  if (typeof d === 'string'){ try{ d = JSON.parse(d); }catch{ return; } }
  d = d || {};
  switch (msg.Event){
    case 'MatchCreated':
      if (rec.m) { rec.m.abandoned = true; recWrite(); }
      recNewMatch(d.MatchGuid);
      if (director && director.onMatchStart) director.onMatchStart();   // keeps the idle watchdog off a running match
      break;
    case 'ClockUpdatedSeconds': rec.clock = d.TimeSeconds; if ('bOvertime' in d) rec.overtime = !!d.bOvertime; break;
    case 'RoundStarted': rec.awaitKickoff = true; rec.roundStartClock = rec.clock; rec.lastHit = null; break;
    case 'UpdateState': {
      if (!d.Players || !d.Game) break;
      rankLearnNames(d.Players);        // the rank provider looks players up by name
      // podium/post-game frames after MatchEnded reuse the ended match's guid —
      // recreating from them wrote score-only duplicate digests (day-1 data)
      if (!rec.m && d.MatchGuid && d.MatchGuid === rec.lastEndedGuid) break;
      if (!rec.m) recNewMatch(d.MatchGuid);    // joined mid-match
      const m = rec.m;
      if (d.MatchGuid && !m.guid) m.guid = d.MatchGuid;
      m.arena = d.Game.Arena || m.arena;
      if (typeof d.Game.PlaylistId === 'number') m.playlistId = d.Game.PlaylistId;
      m.overtime = m.overtime || !!d.Game.bOvertime;
      m.teams = (d.Game.Teams || []).map(t => ({ name: t.Name, score: t.Score ?? 0 }));
      m.players = d.Players.map(p => ({
        name: p.Name, pid: p.PrimaryId || null, team: p.TeamNum, score: p.Score ?? 0,
        goals: p.Goals ?? 0, assists: p.Assists ?? 0, saves: p.Saves ?? 0,
        shots: p.Shots ?? 0, touches: p.Touches ?? 0, demos: p.Demos ?? 0
      }));
      const s0 = m.teams[0] ? m.teams[0].score : 0, s1 = m.teams[1] ? m.teams[1].score : 0;
      const last = m.scoreline[m.scoreline.length - 1];
      if (!last || last.s0 !== s0 || last.s1 !== s1)
        m.scoreline.push({ clock: d.Game.TimeSeconds ?? rec.clock, ot: !!d.Game.bOvertime, s0, s1 });
      for (const p of d.Players){
        if (typeof p.Boost !== 'number') continue;          // own team only (SPECTATOR fields)
        const b = m.boost[p.Name] || (m.boost[p.Name] = { samples: 0, sum: 0, low: 0 });
        b.samples++; b.sum += p.Boost;
        if (p.Boost < 15) b.low++;
      }
      recMovement(d, m, Date.now());
      break;
    }
    case 'BallHit': {
      if (!rec.m) break;
      const spd = d.Ball && d.Ball.PostHitSpeed, loc = (d.Ball && d.Ball.Location) || null,
            y = loc && loc.Y,
            /* 26/8 (radar-sporet): X = sidelinje-aksen (banekort), Z = hoejden ved
             * beroering (det foerste aerlige aerial-signal feedet har givet os).
             * Defensivt: mangler felterne, bliver de null — aldrig gaettet. */
            x = loc && loc.X, z = loc && loc.Z;
      for (const pl of (d.Players || [])){
        if (!pl || !pl.Name) continue;
        /* A pass, as far as the feed can honestly see one: his touch, then a
         * team-mate's, with no opponent touch in between. It says nothing about
         * whether it was intended — hence the name is the sequence, not the
         * intent. The chain is cut at every kickoff and every goal, so a touch
         * either side of a restart is never joined into one. */
        const prevHit = rec.lastHit;
        if (prevHit && prevHit.name !== pl.Name && prevHit.team === pl.TeamNum)
          rec.m.passes[prevHit.name] = (rec.m.passes[prevHit.name] || 0) + 1;
        rec.lastHit = { name: pl.Name, team: pl.TeamNum };

        const h = rec.m.hits[pl.Name] || (rec.m.hits[pl.Name] = { count: 0, sumSpeed: 0, maxSpeed: 0, plusY: 0, minusY: 0 });
        h.count++;
        if (typeof spd === 'number'){ h.sumSpeed += spd; if (spd > h.maxSpeed) h.maxSpeed = spd; }
        if (typeof y === 'number'){ y > 0 ? h.plusY++ : h.minusY++; }
        /* Tidsstemplet udgave af samme touch. `t` deler akse med goals/kickoffs
         * (kampuret — null i træning, hvor det aldrig løber), `w` er sekunder
         * på vægguret siden digest-start og overlever både træning og pauser.
         * Cappet, og cappen er aldrig tavs (hitEventsDropped) — en trunkeret
         * liste der ligner en komplet ville lyve om stimerne. */
        if (rec.m.hitEvents.length < HIT_EVENTS_MAX){
          rec.m.hitEvents.push({
            t: typeof rec.clock === 'number' ? rec.clock : null,
            w: Math.round((Date.now() - rec.mStartMs) / 100) / 10,
            name: pl.Name, team: pl.TeamNum,
            spd: typeof spd === 'number' ? Math.round(spd) : null,
            y: typeof y === 'number' ? Math.round(y) : null,
            x: typeof x === 'number' ? Math.round(x) : null,
            z: typeof z === 'number' ? Math.round(z) : null
          });
        } else rec.m.hitEventsDropped = (rec.m.hitEventsDropped || 0) + 1;
        if (rec.awaitKickoff){
          rec.awaitKickoff = false;
          rec.m.kickoffs.push({ clock: rec.clock, firstTouch: pl.Name, team: pl.TeamNum,
            speed: typeof spd === 'number' ? Math.round(spd) : null });
        }
      }
      break;
    }
    case 'GoalScored':
      rec.lastHit = null;                                // the pass chain never crosses a goal
      if (rec.m && d.Scorer && d.Scorer.Name)            // skip phantom goals
        rec.m.goals.push({ clock: rec.clock, ot: rec.overtime, scorer: d.Scorer.Name, team: d.Scorer.TeamNum,
          assist: (d.Assister && d.Assister.Name) || null,
          speed: typeof d.GoalSpeed === 'number' ? Math.round(d.GoalSpeed) : null,
          // goal-line Y: lets metrics calibrate attack direction per match (never assumed)
          impactY: d.ImpactLocation && typeof d.ImpactLocation.Y === 'number' ? Math.round(d.ImpactLocation.Y) : null });
      break;
    case 'StatfeedEvent':
      if (rec.m && d.EventName === 'Demolish' && d.MainTarget)
        rec.m.demos.push({ clock: rec.clock, attacker: d.MainTarget.Name,
          victim: (d.SecondaryTarget && d.SecondaryTarget.Name) || null, team: d.MainTarget.TeamNum });
      break;
    case 'MatchEnded':
      if (rec.m){ rec.lastEndedGuid = rec.m.guid || null; rec.m.winnerTeamNum = d.WinnerTeamNum; recWrite(); }
      break;
    case 'MatchDestroyed':
      if (rec.m){ rec.lastEndedGuid = rec.m.guid || null; rec.m.abandoned = true; recWrite(); }
      break;
  }
}
/* The recorder rides the shared feed and never lets go: telemetry keeps
 * recording with no page open, and the feed reconnects on its own. */
function startRecorder(){
  let wasConnected = false;             // a connect that never landed is not a shutdown
  getFeed(STATS_HOST, STATS_PORT).add(recOnMsg, state => {
    if (state === 'connected'){
      wasConnected = true; console.log('[recorder] forbundet til spillet');
      // et levende feed beviser at ini'en virker — rydder et evt. "genstart spillet"-banner
      if (statsGuard) try{ statsGuard.feedConnected(); }catch{}
      return;
    }
    /* Feedet svarer ikke: spillet er lukket — eller en opdatering er i gang og
     * har måske allerede nulstillet ini'en. Vagtens tjek her (debounced) retter
     * filen FØR spillet starter igen, så næste launch virker fra første sekund. */
    if (statsGuard) try{ statsGuard.feedDropped(); }catch{}
    if (!wasConnected) return;
    wasConnected = false;
    if (rec.m){ rec.m.abandoned = true; recWrite(); }
    // an ESTABLISHED connection dropping means the game itself was shut down
    if (director && director.onGameDisconnect){
      try{ director.onGameDisconnect(); }catch(e){ console.log('[session] fejl:', String(e.message || e)); }
    }
  });
}
startRecorder();
// idle watchdog: the session engine closes a session after IDLE_REPORT_MS
// without a new match (session.js keeps it equal to the session gap)
setInterval(() => {
  if (director && director.tick) try{ director.tick(); }catch(e){ console.log('[session] fejl:', String(e.message || e)); }
}, 60e3);

/* Focus meter: measure the focus metric on the match in progress and push it
 * to the open boards. Only while a match is running and someone is watching —
 * with no page open there is nothing to draw. */
const FOCUS_TICK_MS = 3000;
let lastFocusLive = '';
setInterval(() => {
  if (!director || !director.focusLive || !rec.m || !sseClients.size) return;
  let fl = null;
  try{ fl = director.focusLive(rec.m); }catch{ return; }
  if (!fl) return;
  const sig = fl.metricId + '|' + fl.valueText + '|' + fl.samples;
  if (sig === lastFocusLive) return;                 // nothing moved since last tick
  lastFocusLive = sig;
  broadcastSSE({ Event: '_focusLive', Data: fl });
}, FOCUS_TICK_MS);

/* ---------------- http server ---------------- */
const TYPES = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.png': 'image/png', '.svg': 'image/svg+xml', '.ico': 'image/x-icon' };

function sendJSON(res, code, obj){
  res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
  res.end(JSON.stringify(obj));
}

/* Read a small request body. Capped and destroyed past the cap so a stray or
 * hostile client can't grow the process — the recorder shares it. */
function readBody(req, max){
  return new Promise((resolve, reject) => {
    let b = '';
    req.on('data', d => {
      b += d;
      if (b.length > max){ req.destroy(); reject(new Error('body for stor')); }
    });
    req.on('end', () => resolve(b));
    req.on('error', reject);
  });
}

/* Static files with revalidation instead of no-store: the tracker page is
 * ~120 KB and ds.css another 42 KB, and the board gets reloaded a lot. An
 * ETag turns each reload into a 304 with no body, while 'no-cache' still
 * forces a check every time, so an edited file is never served stale.
 * Text is gzipped for LAN devices; the ETag identifies the file, not the
 * encoding, so Vary keeps a proxy from mixing the two up. */
const TEXTUAL = /^(text\/|application\/(javascript|json)|image\/svg)/;

/* This server listens on 0.0.0.0 so LAN devices can open the board — which
 * means everything it serves is readable by anything on the network. The app
 * directory is NOT a web root: rank-cache.json holds the user's RapidAPI key,
 * profile.json and matches/ hold personal telemetry, and the .bat/.ps1
 * launchers have no business being downloadable. Serve the page's own assets
 * and the generated reports; refuse the rest. */
/* Koerer overlayet? Samme greb som statsapi-guard bruger paa spillet: tasklist
 * er det eneste svar, der ikke kraever rettigheder. "Ved det ikke" behandles som
 * "koerer ikke" — at starte en kopi mere er ufarligt (overlayet er single-instance). */
function overlayRunning(){
  return new Promise(resolve => {
    if (process.platform !== 'win32') return resolve(false);
    try{
      require('child_process').execFile('tasklist',
        ['/FI', 'IMAGENAME eq RLOverlay.exe', '/NH', '/FO', 'CSV'],
        { windowsHide: true, timeout: 8e3 },
        (err, stdout) => resolve(!err && /RLOverlay\.exe/i.test(String(stdout))));
    }catch{ resolve(false); }
  });
}

const PUBLIC_FILES = new Set(['RLLiveTracker.html', 'ds.css', 'icons.svg', 'icon.ico',
  /* Stadium night-skinnets assets (26/8): CC0-teksturer (ambientCG Fabric004 +
   * Metal009) og Archivo variabel-font (OFL) — statiske, ingen spillerdata. */
  'skin-carbon.jpg', 'skin-metal.jpg', 'skin-stadium.jpg', 'archivo-var.woff2']);

/* ---------- Event-radar: aggregat over alle digests (26/8) -----------------
 * Laeser matches/*.json og bygger banetryk langs laengdeaksen: ejerens egne
 * beroeringer, medspilleres og modstanderes, hver i 44 buckets over
 * [-5500, 5500] uu (banen er ±5120, resten er maalzonen). Kampene vendes saa
 * ejerens forsvar ALTID ligger i minus-enden — på tvaers af hold-tildeling.
 * Cache paa (antal filer + nyeste filnavn): en ny kamp invaliderer, alt andet
 * er gratis. Ejeren = director.getTracked() (pid foerst, navn som fallback);
 * kampe hvor ejeren ikke er på banen (gaester m.v.) taelles ikke med. */
const PITCH_BUCKETS = 44, PITCH_SPAN = 5500, PITCH_DOTS_MAX = 4000;
let pitchCache = null;
function pitchAggregate(){
  const dir = path.join(ROOT, 'matches');
  let files = [];
  try{ files = fs.readdirSync(dir).filter(f => /^\d{4}-/.test(f) && f.endsWith('.json')).sort(); }catch{}
  const key = files.length + ':' + (files[files.length - 1] || '');
  if (pitchCache && pitchCache.key === key) return pitchCache.data;
  const tr = director && director.getTracked ? (director.getTracked() || {}) : {};
  const myPid = tr.ownerPid || tr.trackedPid || tr.pid || '';
  const myName = tr.ownerName || tr.trackedName || tr.name || '';
  const mk = () => new Array(PITCH_BUCKETS).fill(0);
  const mk6 = () => [mk(), mk(), mk(), mk(), mk(), mk()];   // kampens 5 minutter + (OT blandes i minut 5)
  const out = { matches: 0, touches: 0, withX: 0, dotsDropped: 0,
                mine: mk(), mates: mk(), opps: mk(),
                layers: { mine: mk6(), mates: mk6(), opps: mk6() },
                goalsFor: 0, goalsAgainst: 0, dots: [],
                /* Skudsteder (26/8): scorerens sidste beroering foer maalet
                 * (samme kampur, hoejst 10 s foer). [x|null, y, minut] —
                 * scoredFrom = mine maal, concededFrom = modstandernes. */
                scoredFrom: [], concededFrom: [] };
  const bucket = y => Math.max(0, Math.min(PITCH_BUCKETS - 1,
    Math.floor((y + PITCH_SPAN) / (2 * PITCH_SPAN) * PITCH_BUCKETS)));
  /* Kampuret taeller NED fra 300; minut-indeks 0-4 = kampens 1.-5. minut.
   * t null, uden for ]0,300] ELLER praecis 0 kan ikke minut-placeres og
   * taeller kun i totalen: private lobbyer uden ur-feed stempler ALT t=0
   * (maalt 26/8: 10.088 hits i 7 filer — de druknede minut 5). Prisen er
   * de faa aegte buzzer-beroeringer. OT kan feedet ikke skelne fra minut 5. */
  const minuteOf = t => typeof t === 'number' && t > 0 && t <= 300
    ? Math.min(4, Math.floor((300 - t) / 60)) : -1;
  for (const f of files){
    let d; try{ d = JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8')); }catch{ continue; }
    /* Kun rigtige kampe paa banekortet: offline (freeplay/traeningsbaner) er
     * 76 solo-filer/19.087 hits der alle ligger i angrebszonen og baerer
     * pakke-timere i stedet for kampur (maalt 26/8). Samme graense som
     * motorens baselines. Casual/turnering/privat-online taeller med. */
    if (d.mode !== 'online') continue;
    const hits = d.hitEvents || [];
    if (!hits.length) continue;
    const me = (d.players || []).find(p =>
      (myPid && p.pid === myPid) || (myName && p.name === myName));
    if (!me) continue;
    const flip = me.team === 1 ? -1 : 1;   // hold 0 forsvarer minus-y i feedet
    out.matches++;
    for (const e of hits){
      if (typeof e.y !== 'number') continue;
      out.touches++;
      const who = e.name === me.name ? 'mine' : e.team === me.team ? 'mates' : 'opps';
      const b = bucket(e.y * flip), min = minuteOf(e.t);
      out[who][b]++;
      if (min >= 0) out.layers[who][min][b]++;
      if (typeof e.x === 'number'){
        out.withX++;
        if (out.dots.length < PITCH_DOTS_MAX)
          out.dots.push([Math.round(e.x * flip), Math.round(e.y * flip),
                         who === 'mine' ? 0 : who === 'mates' ? 1 : 2, min]);
        else out.dotsDropped++;
      }
    }
    for (const g of (d.goals || [])){
      g.team === me.team ? out.goalsFor++ : out.goalsAgainst++;
      /* Skudstedet: scorerens sidste beroering foer maalet. Uret taeller ned,
       * saa beroeringen FOER maalet har hoejere t; naermeste inden for 10 s.
       * Findes ingen (OT/ur-huller), udelades maalet aerligt. Kun mine egne
       * maal og modstandernes — medspilleres er ikke efterspurgt. */
      if (typeof g.clock !== 'number' || !g.scorer) continue;
      const isMine = g.scorer === me.name && g.team === me.team;
      const isOpp = g.team !== me.team;
      if (!isMine && !isOpp) continue;
      let best = null;
      for (const e of hits){
        if (e.name !== g.scorer || typeof e.t !== 'number') continue;
        const dt = e.t - g.clock;
        if (dt >= 0 && dt <= 10 && (!best || e.t < best.t)) best = e;
      }
      if (!best || typeof best.y !== 'number') continue;
      (isMine ? out.scoredFrom : out.concededFrom).push([
        typeof best.x === 'number' ? Math.round(best.x * flip) : null,
        Math.round(best.y * flip), minuteOf(best.t)]);
    }
  }
  pitchCache = { key, data: out };
  return out;
}
function isPublic(rel){
  // A NUL byte reaches here via %00 and makes fs.* throw SYNCHRONOUSLY
  // (ERR_INVALID_ARG_VALUE) — inside an async handler that took the whole
  // process down, recorder and all. Reject the byte before it touches fs.
  if (rel.includes('\0')) return false;
  const parts = rel.split(/[\\/]+/).filter(Boolean);
  if (!parts.length || parts.some(p => p === '..' || p.startsWith('.'))) return false;
  if (parts.length === 1) return PUBLIC_FILES.has(parts[0]);
  if (parts.length === 2) return parts[0] === 'reports' && /\.(html|json)$/i.test(parts[1]);
  // a guest's reports (guest mode, 19/8): guests/<id>/reports/<file> — the
  // id is the director's own sanitised form, letters/digits/underscore only
  return parts.length === 4 && parts[0] === 'guests' && /^[A-Za-z0-9_]{1,80}$/.test(parts[1])
      && parts[2] === 'reports' && /\.(html|json)$/i.test(parts[3]);
}

function sendStatic(req, res, full){
  try{
  fs.stat(full, (err, st) => {
    if (err || !st.isFile()){ res.writeHead(404, { 'Content-Type': 'text/plain' }); return res.end('Not found'); }
    const type = TYPES[path.extname(full).toLowerCase()] || 'application/octet-stream';
    const etag = '"' + st.size.toString(36) + '-' + Math.round(st.mtimeMs).toString(36) + '"';
    const head = { 'Content-Type': type, 'Cache-Control': 'no-cache', 'ETag': etag, 'Vary': 'Accept-Encoding' };
    const inm = req.headers['if-none-match'];
    if (inm === etag || inm === etag.slice(0, -1) + '-gz"'){ res.writeHead(304, { ...head, 'ETag': inm }); return res.end(); }
    fs.readFile(full, (e2, buf) => {
      if (e2){ res.writeHead(404, { 'Content-Type': 'text/plain' }); return res.end('Not found'); }
      const gzipOK = TEXTUAL.test(type) && buf.length > 1024
        && /\bgzip\b/.test(req.headers['accept-encoding'] || '');
      if (!gzipOK){ res.writeHead(200, head); return res.end(buf); }
      zlib.gzip(buf, (e3, gz) => {
        if (e3){ res.writeHead(200, head); return res.end(buf); }
        // distinct ETag per content-coding (RFC 9110) so a cache can't pair a
        // stored gzip body with a 304 from an identity request
        res.writeHead(200, { ...head, 'ETag': etag.slice(0, -1) + '-gz"',
          'Content-Encoding': 'gzip', 'Vary': 'Accept-Encoding' });
        res.end(gz);
      });
    });
  });
  }catch(e){                                   // fs can throw synchronously on bad paths
    try{ res.writeHead(404, { 'Content-Type': 'text/plain' }); res.end('Not found'); }catch{}
  }
}

const server = http.createServer(async (req, res) => {
  const url = new URL(req.url, 'http://x');
  if (url.pathname === '/api/stream') return handleStream(url, req, res);
  const rankImg = url.pathname.match(/^\/api\/rankimg\/([A-Za-z_]{1,40})\.png$/);
  if (rankImg){
    const png = rankImage(rankImg[1]);
    if (!png){ res.writeHead(404); return res.end(); }
    res.writeHead(200, { 'Content-Type': 'image/png', 'Cache-Control': 'public, max-age=86400' });
    return res.end(png);
  }
  try{
    /* Reach: hvilke coach-data bliver overhovedet hentet (panelåbninger og
     * boardets pulls). Kun arten tælles — aldrig indholdet. */
    if (reach && req.method === 'GET' && /^\/api\/(debrief|session|weekly|focus)$/.test(url.pathname))
      reach.hit('api_' + url.pathname.slice(5));
    /* Boardets chrome følger motorens sprog (backlog 11/8, implementeret 14/8).
     * KUN language-feltet forlader processen — director-ai.json bærer også
     * brugerens Anthropic-nøgle og må aldrig serveres i sin helhed. */
    if (url.pathname === '/api/lang'){
      /* Motorens sprog vinder KUN når det faktisk er valgt (director-ai.json
       * findes). Uden config svares null, og klienten følger maskinens eget
       * sprog — en frisk engelsk tester må aldrig møde dansk chrome
       * (Reddit-testrunden 26/8; K4W's danske Windows giver stadig dansk). */
      let lang = null;
      try{ lang = JSON.parse(fs.readFileSync(path.join(ROOT, 'director-ai.json'), 'utf8')).language === 'en' ? 'en' : 'da'; }catch{}
      return sendJSON(res, 200, { lang });
    }
    /* StatsAPI-vagtens dom — til fejlsøgning og /rl-tracker-status. iniPath er
     * en lokal spilsti, ingen hemmelighed; nøglefiler rører vagten aldrig. */
    if (url.pathname === '/api/statsapi')
      return sendJSON(res, 200, statsGuard ? (statsGuard.status() || { pending: true }) : { ok: null, reason: 'vagt ikke indlæst' });
    if (url.pathname === '/api/update')
      return sendJSON(res, 200, updateCheck ? (updateCheck.status() || { pending: true, appVersion: APP_VERSION })
                                            : { disabled: true, appVersion: APP_VERSION });
    /* PsyNet reader account (18/8). Status is readable from any board; the
     * login code and logout are loopback-only POSTs — the code is a one-shot
     * credential for the reader account and must not be settable from a LAN
     * page. The auth file itself is never served (not in PUBLIC_FILES). */
    if (url.pathname === '/api/psynet')
      return sendJSON(res, 200, { ...(psynet ? psynet.status() : { configured: false, connected: false, disabled: true }),
        relay: rankRelay ? rankRelay.status() : null });
    if (url.pathname === '/api/psynet/login' || url.pathname === '/api/psynet/logout'){
      if (!psynet) return sendJSON(res, 503, { ok: false, reason: 'psynet ikke indlæst' });
      if (req.method !== 'POST'){ res.writeHead(405); return res.end(); }
      if (!isLocalReq(req)) return sendJSON(res, 403, { ok: false, reason: 'kun fra denne maskine' });
      if (url.pathname.endsWith('/logout')) return sendJSON(res, 200, psynet.logout());
      let body;
      try{ body = await readBody(req, 8192); }
      catch{ return sendJSON(res, 413, { ok: false, reason: 'body for stor' }); }
      let j = null; try{ j = JSON.parse(body); }catch{}
      if (!j || typeof j !== 'object') return sendJSON(res, 400, { ok: false, reason: 'ugyldig JSON' });
      const r = await psynet.loginWithCode(j.code);
      return sendJSON(res, r.ok ? 200 : 400, { ...r, status: psynet.status() });
    }
    /* Event-radaren (26/8): historisk banetryk fra digests. AERLIGHEDS-REGLEN:
     * gamle kampe kender kun laengdeaksen (y) og tegnes som scanlinjer;
     * beroeringer med x (gemt fra 26/8) returneres som praecise prikker.
     * Alt normaliseres saa ejerens eget maal ligger i minus-enden. */
    if (url.pathname === '/api/pitch') return sendJSON(res, 200, pitchAggregate());
    if (url.pathname === '/api/playercount') return sendJSON(res, 200, await playerCount());
    if (url.pathname === '/api/esports')     return sendJSON(res, 200, await esports());
    if (url.pathname === '/api/playerranks') return sendJSON(res, 200, await playerRanks(url, req));
    if (url.pathname === '/api/debrief')     return sendJSON(res, 200, {
      debrief: director ? director.lastDebrief() : null,
      previous: director && director.prevDebrief ? director.prevDebrief() : null });
    /* Who the coach follows. POST rather than a query parameter on purpose:
     * the server is loopback-only, but any page in the user's browser can
     * reach loopback, and a JSON POST needs a CORS preflight that a foreign
     * origin will not get — a GET with parameters would not. */
    if (url.pathname === '/api/tracked'){
      if (!director) return sendJSON(res, 503, { ok: false, reason: 'director ikke indlæst' });
      if (req.method === 'GET') return sendJSON(res, 200, director.getTracked());
      if (req.method === 'POST'){
        let body;
        try{ body = await readBody(req, 2048); }
        catch{ return sendJSON(res, 413, { ok: false, reason: 'body for stor' }); }
        let j = null;
        try{ j = JSON.parse(body); }catch{}
        if (!j || typeof j !== 'object') return sendJSON(res, 400, { ok: false, reason: 'ugyldig JSON' });
        const r = director.setTracked(j.pid, j.name);
        return sendJSON(res, r.ok ? 200 : 400, r);
      }
      res.writeHead(405); return res.end();
    }
    /* Overlayet startet fra appen (25/8). Produktloven: hver flade skal have ét
     * oplagt næste skridt, og overlayet havde ingen — man skulle vide, at der lå
     * en .bat i vaerktoejer\. GET siger om det overhovedet kan lade sig gøre
     * (exe'en ligger ved siden af serveren) og om det allerede kører; POST
     * starter det. POST, ikke GET, af samme grund som /api/tracked: serveren er
     * loopback-only, men enhver side i brugerens browser kan nå loopback, og en
     * JSON-POST kræver en preflight, som en fremmed origin ikke får. */
    if (url.pathname === '/api/overlay'){
      const exe = path.join(ROOT, 'RLOverlay.exe');
      const have = process.platform === 'win32' && fs.existsSync(exe);
      if (req.method === 'GET')
        return sendJSON(res, 200, { available: have, running: have ? await overlayRunning() : false });
      if (req.method === 'POST'){
        if (!have) return sendJSON(res, 501, { ok: false, reason: 'RLOverlay.exe ligger ikke ved siden af serveren' });
        if (await overlayRunning()) return sendJSON(res, 200, { ok: true, already: true });
        try{
          const { spawn } = require('child_process');
          // detached + unref: overlayet skal overleve, at serveren genstartes
          spawn(exe, [], { cwd: ROOT, detached: true, stdio: 'ignore', windowsHide: true }).unref();
        }catch(e){ return sendJSON(res, 500, { ok: false, reason: String(e.message || e) }); }
        return sendJSON(res, 200, { ok: true, started: true });
      }
      res.writeHead(405); return res.end();
    }
    /* Speed unit (M4b). Stored server-side because the unit is FORMATTING and
     * every number the coach says is formatted in director/ — converting in the
     * browser would put a second authority on what a number means. */
    if (url.pathname === '/api/unit'){
      if (!director || !director.getUnit) return sendJSON(res, 503, { ok: false, reason: 'director ikke indlæst' });
      if (req.method === 'GET') return sendJSON(res, 200, director.getUnit());
      if (req.method === 'POST'){
        let body;
        try{ body = await readBody(req, 512); }
        catch{ return sendJSON(res, 413, { ok: false, reason: 'body for stor' }); }
        let j = null; try{ j = JSON.parse(body); }catch{}
        if (!j || typeof j !== 'object') return sendJSON(res, 400, { ok: false, reason: 'ugyldig JSON' });
        const r = director.setUnit(j.unit);
        return sendJSON(res, r.ok ? 200 : 400, r);
      }
      res.writeHead(405); return res.end();
    }
    /* The one-tap label (M4b §5b). Write-only from the page's point of view —
     * nothing is ever shown back, because a score, a streak or a "you skipped
     * 3" would turn a free answer into an obligation, and an obligated answer
     * measures the obligation. */
    if (url.pathname === '/api/label'){
      if (!director || !director.setMatchLabel) return sendJSON(res, 503, { ok: false, reason: 'director ikke indlæst' });
      if (req.method !== 'POST'){ res.writeHead(405); return res.end(); }
      let body;
      try{ body = await readBody(req, 512); }
      catch{ return sendJSON(res, 413, { ok: false, reason: 'body for stor' }); }
      let j = null; try{ j = JSON.parse(body); }catch{}
      if (!j || typeof j !== 'object') return sendJSON(res, 400, { ok: false, reason: 'ugyldig JSON' });
      const r = director.setMatchLabel(j.file, j.label);
      return sendJSON(res, r.ok ? 200 : 400, r);
    }
    /* Testrunden (25/8, gate 6). POST, ikke GET-parametre, af samme grund som
     * /api/tracked: en fremmed origin i brugerens browser kan nå loopback, men
     * får aldrig sin JSON-POST forbi CORS-preflighten. */
    if (url.pathname === '/api/testround'){
      if (!testround) return sendJSON(res, 503, { ok: false, reason: 'testround ikke indlæst' });
      if (req.method === 'GET') return sendJSON(res, 200, testround.status());
      if (req.method === 'POST'){
        let body;
        try{ body = await readBody(req, 2048); }
        catch{ return sendJSON(res, 413, { ok: false, reason: 'body for stor' }); }
        let j = null; try{ j = JSON.parse(body); }catch{}
        if (!j || typeof j !== 'object' || typeof j.join !== 'boolean')
          return sendJSON(res, 400, { ok: false, reason: 'ugyldig JSON (join skal være true/false)' });
        const r = testround.choose(j.join, j.tester);
        return sendJSON(res, r.ok ? 200 : 400, r);
      }
      res.writeHead(405); return res.end();
    }
    if (url.pathname === '/api/feedback'){
      if (!testround) return sendJSON(res, 503, { ok: false, reason: 'testround ikke indlæst' });
      if (req.method !== 'POST'){ res.writeHead(405); return res.end(); }
      let body;
      try{ body = await readBody(req, 8192); }
      catch{ return sendJSON(res, 413, { ok: false, reason: 'body for stor' }); }
      let j = null; try{ j = JSON.parse(body); }catch{}
      if (!j || typeof j.text !== 'string') return sendJSON(res, 400, { ok: false, reason: 'text mangler' });
      const r = await testround.send(j.text, { app: APP_VERSION, userAgent: UA });
      return sendJSON(res, r.ok ? 200 : 502, r);
    }
    if (url.pathname === '/api/session')     return sendJSON(res, 200, {
      report: director && director.sessionLatest ? director.sessionLatest() : null,
      current: director && director.sessionCurrent ? director.sessionCurrent() : null });
    // "Ugens fremgang": `current` is the running week (rebuilt per request),
    // `latest` the last CLOSED week, which is the one with an HTML report.
    if (url.pathname === '/api/weekly')      return sendJSON(res, 200, {
      current: director && director.weeklyCurrent ? director.weeklyCurrent() : null,
      latest:  director && director.weeklyLatest  ? director.weeklyLatest()  : null
    });
    // Form (23/8): the body barometer — local state, never uploaded
    if (url.pathname === '/api/form')        return sendJSON(res, 200, {
      form: director && director.formCurrent ? director.formCurrent() : null });
    if (url.pathname === '/api/focus')       return sendJSON(res, 200, {
      focus: director && director.focus ? director.focus() : null,
      live:  director && director.focusLive ? director.focusLive(rec.m) : null });
  }catch(e){
    return sendJSON(res, 503, { error: String(e.message || e) });
  }
  let rel = url.pathname === '/' || url.pathname === '/index.html' ? 'RLLiveTracker.html' : url.pathname.slice(1);
  try{ rel = decodeURIComponent(rel); }catch{ res.writeHead(400); return res.end(); }
  // resolve first, then require the result to sit INSIDE ROOT. A bare
  // startsWith(ROOT) also accepts a sibling like "rl-live-stats-backup".
  // Holds even when the server is opened to the network with HOST=0.0.0.0.
  const full = path.resolve(ROOT, rel);
  // both checks matter: isPublic names what may be served, the prefix test
  // makes sure a crafted path can't land outside ROOT (or in a sibling
  // directory, which a bare startsWith(ROOT) would have allowed)
  if (!isPublic(rel) || (full !== ROOT && !full.startsWith(ROOT + path.sep))){
    res.writeHead(404, { 'Content-Type': 'text/plain' });
    return res.end('Not found');
  }
  /* Reach: sideåbninger pr. flade + rapportåbninger pr. rapporttype. */
  if (reach){
    if (rel === 'RLLiveTracker.html')
      reach.hit(url.searchParams.has('board') ? 'page_board'
              : url.searchParams.has('overlay') ? 'page_overlay' : 'page_main');
    else if (/^reports\//.test(rel) && /\.html$/i.test(rel))
      reach.hit(/^reports\/weekly-/.test(rel) ? 'report_weekly'
              : /^reports\/session-/.test(rel) ? 'report_session' : 'report_other');
  }
  sendStatic(req, res, full);
});

/* The recorder shares this process: an unhandled error anywhere would stop
 * telemetry mid-match and lose the digest. Log and keep running instead —
 * a broken request must never cost the user a match. */
process.on('uncaughtException', e => console.log('[server] uventet fejl:', String((e && e.stack) || e)));
process.on('unhandledRejection', e => console.log('[server] uventet afvisning:', String((e && e.stack) || e)));

/* A second launch must die, not linger.
 *
 * The uncaughtException handler above (added to keep a bad request from
 * killing the recorder mid-match) also swallows EADDRINUSE from listen — so a
 * second instance kept running WITHOUT the port: invisible to the board, but
 * with its own feed to the game, racing the real server on profile.json and
 * the match archive. That cost real debriefs on 2026-07-27. Handle the bind
 * error explicitly and exit before the generic handler can rescue us.
 */
server.on('error', e => {
  if (e && e.code === 'EADDRINUSE'){
    console.log('[server] port ' + PORT + ' er optaget — RL Tracker koerer allerede. Lukker denne instans.');
    process.exit(1);
  }
  throw e;
});

server.listen(PORT, HOST, () => {
  console.log('RL Live Tracker server');
  console.log('  Local:   http://localhost:' + PORT + '/');
  if (LAN_OPEN) console.log('  Network: http://<this-machine-ip>:' + PORT + '/   (LAN-adgang slået til via HOST)');
  else console.log('  Network: slået fra (kun denne maskine). Sæt HOST=0.0.0.0 for LAN-boards.');
  console.log('  APIs:    /api/playercount  /api/esports');
  /* Onboarding-loven (15/8, walkthrough-fund 2): dobbeltklik på EXE'EN er den
   * indgang, en fremmed faktisk vælger — så skal exe'en også selv åbne UI'et.
   * Launcherne/deploy sætter RL_NO_OPEN=1 og beholder egen åbne-logik; dev
   * (RL_ROOT) åbner aldrig — en dev-genstart skal ikke stjæle fokus. */
  if (process.env.RL_NO_OPEN !== '1' && !process.env.RL_ROOT){
    console.log('');
    console.log('  Boardet åbner nu i din browser. Luk DETTE vindue for at stoppe trackeren.');
    console.log('  (The board opens in your browser. Close THIS window to stop the tracker.)');
    try{
      require('child_process').spawn('cmd', ['/c', 'start', '', 'http://localhost:' + PORT + '/'],
        { detached: true, stdio: 'ignore', windowsHide: true }).unref();
    }catch{}
  }
});
