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:
@@ -180,35 +180,102 @@ export interface RconPlayer {
|
|||||||
num: number | null; // client slot — print dialect only
|
num: number | null; // client slot — print dialect only
|
||||||
score: number;
|
score: number;
|
||||||
ping: number;
|
ping: number;
|
||||||
guid: string | null; // 32-hex — print dialect only
|
guid: string | null; // format varies by engine (hex, decimal, steam ID)
|
||||||
name: string;
|
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`
|
* Extract every player row from a `status` (print) or `getstatus`
|
||||||
* (statusResponse) response. Returns [] when the payload has none.
|
* (statusResponse) response. Returns [] when the payload has none.
|
||||||
* Two dialects, same output shape:
|
|
||||||
*
|
*
|
||||||
* print: num score ping <32-hex-guid> <name-with-spaces>
|
* `status` (print dialect) — parsed as fixed-width based on the header
|
||||||
* statusResponse: score ping "<name>"
|
* 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[] {
|
export function parsePlayerRoster(text: string): RconPlayer[] {
|
||||||
const lines = text.split(/\r?\n/);
|
const lines = text.split(/\r?\n/);
|
||||||
const sep = lines.findIndex((l) => /^-+\s+-+/.test(l.trim()));
|
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[] = [];
|
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++) {
|
for (let i = sep + 1; i < lines.length; i++) {
|
||||||
const m = re.exec(lines[i]);
|
const line = lines[i];
|
||||||
if (!m) continue;
|
if (!line.trim()) continue;
|
||||||
out.push({
|
const numStr = slice(line, numI);
|
||||||
num: Number(m[1]),
|
if (!/^\d+$/.test(numStr)) continue; // skip stray non-player lines
|
||||||
score: Number(m[2]),
|
const num = Number(numStr);
|
||||||
ping: Number(m[3]),
|
const score = Number(slice(line, scoreI));
|
||||||
guid: m[4],
|
const ping = Number(slice(line, pingI));
|
||||||
name: m[5]
|
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;
|
return out;
|
||||||
}
|
}
|
||||||
@@ -225,7 +292,7 @@ export function parsePlayerRoster(text: string): RconPlayer[] {
|
|||||||
score: Number(m[1]),
|
score: Number(m[1]),
|
||||||
ping: Number(m[2]),
|
ping: Number(m[2]),
|
||||||
guid: null,
|
guid: null,
|
||||||
name: m[3]
|
name: stripColorCodes(m[3])
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
|
|||||||
@@ -45,7 +45,7 @@
|
|||||||
<td class="col-name">{p.name || '—'}</td>
|
<td class="col-name">{p.name || '—'}</td>
|
||||||
<td class="col-score mono">{p.score}</td>
|
<td class="col-score mono">{p.score}</td>
|
||||||
<td class="col-ping mono {pingClass(p.ping)}">{p.ping}</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>
|
</tr>
|
||||||
{/each}
|
{/each}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -118,6 +118,51 @@ describe('parsePlayerRoster', () => {
|
|||||||
expect(parsePlayerRoster(empty)).toEqual([]);
|
expect(parsePlayerRoster(empty)).toEqual([]);
|
||||||
expect(parsePlayerRoster('nonsense')).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', () => {
|
describe('parseStatusHeader', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user