import { describe, expect, it } from 'vitest'; import { parseInfoLine, parsePlayerCount, parsePlayerRoster, parseStatusHeader, formatRawReply } from '../../src/lib/server/integrations/rcon.js'; describe('parsePlayerCount', () => { it('counts rows after --- --- separator (status dialect)', () => { const body = [ 'print', 'map: mp_kino', 'num score ping guid name', '--- ----- ---- -------- ----', ' 0 12 45 abc123def456 joe', ' 1 0 72 xyz789abc123 jill', ' 2 999 30 111222333444 kade' ].join('\n'); expect(parsePlayerCount(body)).toBe(3); }); it('returns 0 when the scoreboard has no rows', () => { const body = [ 'print', 'map: mp_ascension', 'num score ping guid name', '--- ----- ---- -------- ----', '' ].join('\n'); expect(parsePlayerCount(body)).toBe(0); }); it('counts non-blank lines after the info line (statusResponse dialect)', () => { const body = [ 'statusResponse', '\\mapname\\mp_kino\\g_gametype\\zom\\sv_maxclients\\4', '10 45 "joe"', '0 72 "jill"' ].join('\n'); expect(parsePlayerCount(body)).toBe(2); }); it('returns null on malformed payloads with no separator or info line', () => { expect(parsePlayerCount('nonsense body\nno structure here')).toBeNull(); }); }); describe('parseInfoLine', () => { it('splits \\key\\value pairs from an info string', () => { const body = [ 'statusResponse', '\\mapname\\mp_kino\\g_gametype\\zom\\sv_maxclients\\4' ].join('\n'); const info = parseInfoLine(body); expect(info.mapname).toBe('mp_kino'); expect(info.g_gametype).toBe('zom'); expect(info.sv_maxclients).toBe('4'); }); it('returns empty object when no info line is present', () => { expect(parseInfoLine('nothing here')).toEqual({}); }); }); describe('parsePlayerRoster', () => { it('extracts num/score/ping/guid/name from print dialect', () => { const body = [ 'print', 'map: mp_kino', 'num score ping guid name', '--- ----- ---- -------- ----', ' 0 12 45 aaaaaaaabbbbbbbbccccccccdddddddd joe', ' 1 0 72 11112222333344445555666677778888 jill h.', ' 2 999 128 abcdef0123456789abcdef0123456789 Toaster' ].join('\n'); const roster = parsePlayerRoster(body); expect(roster).toHaveLength(3); expect(roster[0]).toEqual({ num: 0, score: 12, ping: 45, guid: 'aaaaaaaabbbbbbbbccccccccdddddddd', name: 'joe' }); // Names with spaces stay intact expect(roster[1].name).toBe('jill h.'); expect(roster[2].ping).toBe(128); }); it('extracts score/ping/quoted-name from statusResponse dialect', () => { const body = [ 'statusResponse', '\\mapname\\mp_kino\\g_gametype\\zclassic', '10 45 "joe"', '0 72 "jill h."' ].join('\n'); const roster = parsePlayerRoster(body); expect(roster).toHaveLength(2); expect(roster[0]).toEqual({ num: null, score: 10, ping: 45, guid: null, name: 'joe' }); expect(roster[1].name).toBe('jill h.'); }); it('returns empty array when no rows are present', () => { const empty = [ 'print', 'map: mp_ascension', 'num score ping guid name', '--- ----- ---- -------- ----' ].join('\n'); 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('parses T6 status with bot column and right-aligned hexadecimal GUIDs', () => { const body = [ 'map: zm_transit', 'num score bot ping guid name lastmsg address qport rate', '--- ----- --- ---- -------------------------------- ---------------- ------- --------------------- ------ -----', ' 0 940 0 91 6C4C20 EyelessGreem 50 192.0.2.137:4976 40123 20000', ' 1 150 0 151 6C45DD _frxn.3 50 198.51.100.1:4976 8140 20000' ].join('\n'); expect(parsePlayerRoster(body)).toEqual([ { num: 0, score: 940, ping: 91, guid: '6C4C20', name: 'EyelessGreem' }, { num: 1, score: 150, ping: 151, guid: '6C45DD', name: '_frxn.3' } ]); }); 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', () => { it('extracts key:value header lines from a Plutonium status body', () => { const body = [ 'print', 'map: mp_kino', 'num score ping guid name', '--- ----- ---- -------- ----', ' 0 12 45 abc123def456 joe' ].join('\n'); const h = parseStatusHeader(body); expect(h.map).toBe('mp_kino'); // Header keys stop at the scoreboard — guids/names must not leak in. expect(h.abc123def456).toBeUndefined(); }); it('handles builds that include gametype or other dvars in the header', () => { const body = [ 'print', 'map: mp_ascension', 'gametype: zclassic', 'sv_maxclients: 4', '--- --- ---', '' ].join('\n'); const h = parseStatusHeader(body); expect(h.map).toBe('mp_ascension'); expect(h.gametype).toBe('zclassic'); expect(h.sv_maxclients).toBe('4'); }); it('returns empty object for a statusResponse (no colon header)', () => { const body = [ 'statusResponse', '\\mapname\\mp_kino\\g_gametype\\zclassic', '10 45 "joe"' ].join('\n'); expect(parseStatusHeader(body)).toEqual({}); }); }); describe('formatRawReply', () => { it('renders a small buffer as hex + printable ASCII with an offset', () => { const buf = Buffer.from([0xff, 0xff, 0xff, 0xff, 0x70, 0x72, 0x69, 0x6e, 0x74, 0x0a]); const out = formatRawReply(buf); expect(out).toContain('0000'); expect(out).toContain('ff ff ff ff 70 72 69 6e 74 0a'); // Non-printable prefix bytes render as `.`; the printable tail spells "print". expect(out).toContain('....print.'); }); it('splits into 16-byte rows and truncates past the limit', () => { const buf = Buffer.alloc(600, 0x41); // "A" repeated const out = formatRawReply(buf); const lines = out.split('\n'); expect(lines.length).toBeGreaterThan(1); expect(lines[lines.length - 1]).toMatch(/more bytes/); }); });