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;