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
|
||||
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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user