diff --git a/src/lib/server/integrations/rcon.ts b/src/lib/server/integrations/rcon.ts index 79892e5..892d76c 100644 --- a/src/lib/server/integrations/rcon.ts +++ b/src/lib/server/integrations/rcon.ts @@ -157,6 +157,25 @@ export function parseInfoLine(text: string): Record { return out; } +/** + * Parse `key: value` header lines from a `status` (print) response, stopping + * at the scoreboard `--- ---` separator so player rows (which can contain + * colons in guids or names) don't leak into the metadata. Plutonium T5's + * `status` puts things like `map: mp_kino` and (on some builds) gametype + * in the header. + */ +export function parseStatusHeader(text: string): Record { + const out: Record = {}; + for (const rawLine of text.split(/\r?\n/)) { + const line = rawLine.trim(); + if (/^-+\s+-+/.test(line)) break; // scoreboard row — stop + if (/^\d+\s+/.test(line)) break; // player row that snuck in + const m = /^([a-z][a-z0-9_]*)\s*:\s*(.+)$/i.exec(line); + if (m) out[m[1].toLowerCase()] = m[2].trim(); + } + return out; +} + export interface RconPlayer { num: number | null; // client slot — print dialect only score: number; @@ -292,9 +311,13 @@ export async function rconStatusResult(server: string): Promise { }); }); +describe('parseStatusHeader', () => { + it('extracts key:value header lines from a Plutonium status body', () => { + const body = [ + 'print', + 'map: mp_kino', + 'num score ping guid name', + '--- ----- ---- -------- ----', + ' 0 12 45 abc123def456 joe' + ].join('\n'); + const h = parseStatusHeader(body); + expect(h.map).toBe('mp_kino'); + // Header keys stop at the scoreboard — guids/names must not leak in. + expect(h.abc123def456).toBeUndefined(); + }); + + it('handles builds that include gametype or other dvars in the header', () => { + const body = [ + 'print', + 'map: mp_ascension', + 'gametype: zclassic', + 'sv_maxclients: 4', + '--- --- ---', + '' + ].join('\n'); + const h = parseStatusHeader(body); + expect(h.map).toBe('mp_ascension'); + expect(h.gametype).toBe('zclassic'); + expect(h.sv_maxclients).toBe('4'); + }); + + it('returns empty object for a statusResponse (no colon header)', () => { + const body = [ + 'statusResponse', + '\\mapname\\mp_kino\\g_gametype\\zclassic', + '10 45 "joe"' + ].join('\n'); + expect(parseStatusHeader(body)).toEqual({}); + }); +}); + describe('formatRawReply', () => { it('renders a small buffer as hex + printable ASCII with an offset', () => { const buf = Buffer.from([0xff, 0xff, 0xff, 0xff, 0x70, 0x72, 0x69, 0x6e, 0x74, 0x0a]);