Roster parser: header-driven fixed-width, handle Plutonium T5's 9 columns

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 <noreply@anthropic.com>
This commit is contained in:
2026-09-12 11:37:25 -04:00
co-authored by Claude Opus 4.7
parent d8ed0d2572
commit d21e8187ff
3 changed files with 130 additions and 18 deletions
+84 -17
View File
@@ -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> <name-with-spaces>
* statusResponse: score ping "<name>"
* `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;
+1 -1
View File
@@ -45,7 +45,7 @@
<td class="col-name">{p.name || '—'}</td>
<td class="col-score mono">{p.score}</td>
<td class="col-ping mono {pingClass(p.ping)}">{p.ping}</td>
{#if !compact}<td class="col-guid mono">{p.guid ? p.guid.slice(0, 8) + '…' : '—'}</td>{/if}
{#if !compact}<td class="col-guid mono">{p.guid ? (p.guid.length > 12 ? p.guid.slice(0, 10) + '…' : p.guid) : '—'}</td>{/if}
</tr>
{/each}
</tbody>
+45
View File
@@ -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', () => {