From d21e8187ff4dee08a1d1d9e639b299be8333932d Mon Sep 17 00:00:00 2001 From: josh Date: Sat, 12 Sep 2026 11:37:25 -0400 Subject: [PATCH] Roster parser: header-driven fixed-width, handle Plutonium T5's 9 columns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The raw hex dump showed Plutonium T5's `status` reply is a 9-column format, not the 5-column shape the parser assumed: num score ping guid name lastmsg address qport rate --- ----- ---- ------- ---------------- ------- --------------------- ----- ----- 0 3040 36 2741626 JoshWright^7 0 68.235.46.100:28960 -14733 25000 Three bugs in the old regex: it required 16+ hex chars for guid (T5 uses 7-digit decimal IDs), it expected name to run to end-of-line (4 trailing columns get glued on), and it left Plutonium color codes (^0..^9) in the rendered name. Rewrote parsePlayerRoster to detect column boundaries from the header line word positions instead — column N runs from word N's start to word N+1's start, matching how CoD's status output actually lays out (values borrow the padding between adjacent columns). Same code path handles the old 5-column format and the T5 9-column format cleanly. stripColorCodes() removes ^\d sequences from names in both dialects. PlayerRoster component only appends `…` when the guid is actually long enough to need truncation. Co-Authored-By: Claude Opus 4.7 --- src/lib/server/integrations/rcon.ts | 101 ++++++++++++++++++---- src/lib/ui/components/PlayerRoster.svelte | 2 +- tests/unit/rcon.test.ts | 45 ++++++++++ 3 files changed, 130 insertions(+), 18 deletions(-) diff --git a/src/lib/server/integrations/rcon.ts b/src/lib/server/integrations/rcon.ts index 04f42b6..839b4f0 100644 --- a/src/lib/server/integrations/rcon.ts +++ b/src/lib/server/integrations/rcon.ts @@ -180,35 +180,102 @@ export interface RconPlayer { num: number | null; // client slot — print dialect only score: number; ping: number; - guid: string | null; // 32-hex — print dialect only + guid: string | null; // format varies by engine (hex, decimal, steam ID) name: string; } +/** + * Strip Plutonium/CoD color codes from a player name (`^0`..`^9`). + * These are display-only escape sequences and confuse the UI if left in. + */ +export function stripColorCodes(s: string): string { + return s.replace(/\^\d/g, ''); +} + +interface Column { + name: string; + start: number; + end: number; // exclusive +} + +/** + * Detect column boundaries from a header line by locating each non-whitespace + * word. Column N spans from word N's start to word N+1's start (last column + * runs to EOL), which is how CoD status output actually lays out — values + * can borrow the padding space between adjacent columns. + */ +function detectHeaderColumns(headerLine: string): Column[] { + const words: Array<{ name: string; start: number }> = []; + const re = /\S+/g; + let m: RegExpExecArray | null; + while ((m = re.exec(headerLine)) !== null) { + words.push({ name: m[0].toLowerCase(), start: m.index }); + } + return words.map((w, idx) => ({ + name: w.name, + start: w.start, + end: idx + 1 < words.length ? words[idx + 1].start : Number.MAX_SAFE_INTEGER + })); +} + /** * Extract every player row from a `status` (print) or `getstatus` * (statusResponse) response. Returns [] when the payload has none. - * Two dialects, same output shape: * - * print: num score ping <32-hex-guid> - * statusResponse: score ping "" + * `status` (print dialect) — parsed as fixed-width based on the header + * word positions. Both the old 5-column format + * + * num score ping guid name + * --- ----- ---- -------- ---- + * 0 12 45 abc123...def joe + * + * and Plutonium T5's 9-column format + * + * num score ping guid name lastmsg address qport rate + * --- ----- ---- ------- ---------------- ------- --------------------- ----- ----- + * 0 3040 36 2741626 JoshWright^7 0 68.235.46.100:28960 -14733 25000 + * + * work through the same code — column boundaries come from where the + * header words start, so trailing columns don't get glued onto the name. + * + * `getstatus` (statusResponse dialect) — Quake3-style `score ping "name"`. */ export function parsePlayerRoster(text: string): RconPlayer[] { const lines = text.split(/\r?\n/); const sep = lines.findIndex((l) => /^-+\s+-+/.test(l.trim())); - if (sep !== -1) { + if (sep > 0) { + const headerLine = lines[sep - 1]; + const cols = detectHeaderColumns(headerLine); + if (cols.length < 4) return []; + + const idxOf = (name: string): number => cols.findIndex((c) => c.name === name); + const numI = idxOf('num'); + const scoreI = idxOf('score'); + const pingI = idxOf('ping'); + const guidI = idxOf('guid'); + const nameI = idxOf('name'); + if (numI < 0 || scoreI < 0 || pingI < 0 || nameI < 0) return []; + + const slice = (line: string, colIdx: number): string => { + const c = cols[colIdx]; + return line.slice(c.start, c.end).trim(); + }; + const out: RconPlayer[] = []; - // Match: num score ping guid(32-hex) name-rest - const re = /^\s*(\d+)\s+(-?\d+)\s+(\d+)\s+([0-9a-fA-F]{16,})\s+(.+?)\s*$/; for (let i = sep + 1; i < lines.length; i++) { - const m = re.exec(lines[i]); - if (!m) continue; - out.push({ - num: Number(m[1]), - score: Number(m[2]), - ping: Number(m[3]), - guid: m[4], - name: m[5] - }); + const line = lines[i]; + if (!line.trim()) continue; + const numStr = slice(line, numI); + if (!/^\d+$/.test(numStr)) continue; // skip stray non-player lines + const num = Number(numStr); + const score = Number(slice(line, scoreI)); + const ping = Number(slice(line, pingI)); + if (!Number.isFinite(num) || !Number.isFinite(score) || !Number.isFinite(ping)) continue; + + const guid = guidI >= 0 ? slice(line, guidI) || null : null; + const name = stripColorCodes(slice(line, nameI)); + + out.push({ num, score, ping, guid, name }); } return out; } @@ -225,7 +292,7 @@ export function parsePlayerRoster(text: string): RconPlayer[] { score: Number(m[1]), ping: Number(m[2]), guid: null, - name: m[3] + name: stripColorCodes(m[3]) }); } return out; diff --git a/src/lib/ui/components/PlayerRoster.svelte b/src/lib/ui/components/PlayerRoster.svelte index 0944d69..2945eb9 100644 --- a/src/lib/ui/components/PlayerRoster.svelte +++ b/src/lib/ui/components/PlayerRoster.svelte @@ -45,7 +45,7 @@ {p.name || '—'} {p.score} {p.ping} - {#if !compact}{p.guid ? p.guid.slice(0, 8) + '…' : '—'}{/if} + {#if !compact}{p.guid ? (p.guid.length > 12 ? p.guid.slice(0, 10) + '…' : p.guid) : '—'}{/if} {/each} diff --git a/tests/unit/rcon.test.ts b/tests/unit/rcon.test.ts index 91a5b35..871ee60 100644 --- a/tests/unit/rcon.test.ts +++ b/tests/unit/rcon.test.ts @@ -118,6 +118,51 @@ describe('parsePlayerRoster', () => { expect(parsePlayerRoster(empty)).toEqual([]); expect(parsePlayerRoster('nonsense')).toEqual([]); }); + + it('parses Plutonium T5 9-column status with numeric GUID + color-coded name', () => { + // Verbatim shape from a real T5 status reply — 7-digit GUID (not hex), + // 4 trailing columns after name (lastmsg, address, qport, rate), + // and Plutonium color code (^7) in the name. + const body = [ + 'print', + 'map: zombie_cosmodrome', + 'num score ping guid name lastmsg address qport rate', + '--- ----- ---- ------- ---------------- ------- -------------------------- ----- -----', + ' 0 3040 36 2741626 JoshWright^7 0 68.235.46.100:28960 -14733 25000' + ].join('\n'); + const roster = parsePlayerRoster(body); + expect(roster).toHaveLength(1); + expect(roster[0]).toEqual({ + num: 0, + score: 3040, + ping: 36, + guid: '2741626', + name: 'JoshWright' // ^7 stripped + }); + }); + + it('preserves multi-word names in the 9-column format', () => { + const body = [ + 'print', + 'map: mp_kino', + 'num score ping guid name lastmsg address qport rate', + '--- ----- ---- ------- ---------------- ------- --------------------- ----- -----', + ' 1 500 48 8675309 Jenny Two Names 0 10.0.0.5:28960 -12345 25000' + ].join('\n'); + const roster = parsePlayerRoster(body); + expect(roster).toHaveLength(1); + expect(roster[0].name).toBe('Jenny Two Names'); + }); + + it('strips color codes from statusResponse-dialect names too', () => { + const body = [ + 'statusResponse', + '\\mapname\\mp_kino\\g_gametype\\zclassic', + '10 45 "^1Red^7Player"' + ].join('\n'); + const roster = parsePlayerRoster(body); + expect(roster[0].name).toBe('RedPlayer'); + }); }); describe('parseStatusHeader', () => {