Initial commit: CraftDock as of 2026-11-01
The previous GitHub history was lost with the account (the local .git was missing its refs and most objects), so this repository starts from the existing code. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
640e52c0c9
99 files changed
+12115
No files matched your search
@@ -0,0 +1,184 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { createBedrockAdapter } from '../../src/adapters/bedrockAdapter.js';
|
||||
import { NotSupportedError } from '../../src/adapters/serverAdapter.js';
|
||||
|
||||
function make(sendImpl) {
|
||||
const stdin = { send: vi.fn(sendImpl || (async () => 'ok')) };
|
||||
return { stdin, adapter: createBedrockAdapter(stdin) };
|
||||
}
|
||||
|
||||
describe('BedrockAdapter', () => {
|
||||
it('whitelistAdd maps to "allowlist add"', async () => {
|
||||
const { stdin, adapter } = make();
|
||||
await adapter.whitelistAdd('steve');
|
||||
expect(stdin.send).toHaveBeenCalledWith('allowlist add "steve"');
|
||||
});
|
||||
|
||||
it('whitelistRemove maps to "allowlist remove"', async () => {
|
||||
const { stdin, adapter } = make();
|
||||
await adapter.whitelistRemove('steve');
|
||||
expect(stdin.send).toHaveBeenCalledWith('allowlist remove "steve"');
|
||||
});
|
||||
|
||||
it('ban throws NotSupportedError', async () => {
|
||||
const { adapter } = make();
|
||||
await expect(adapter.ban('x')).rejects.toBeInstanceOf(NotSupportedError);
|
||||
});
|
||||
|
||||
it('does not advertise ban capability', () => {
|
||||
const { adapter } = make();
|
||||
expect(adapter.capabilities.has('ban')).toBe(false);
|
||||
});
|
||||
|
||||
it('does not expose getSeed (Bedrock has no seed command; read from level.dat)', () => {
|
||||
const { adapter } = make();
|
||||
expect(adapter.getSeed).toBeUndefined();
|
||||
});
|
||||
|
||||
it('getPlayerPosition parses querytarget response when it is clean JSON', async () => {
|
||||
const { adapter } = make(async (cmd) => {
|
||||
if (cmd.startsWith('querytarget')) {
|
||||
return '[{"position":{"x":15.0,"y":70.0,"z":-30.0},"dimension":1}]';
|
||||
}
|
||||
return '';
|
||||
});
|
||||
const pos = await adapter.getPlayerPosition('alex');
|
||||
expect(pos).toEqual({ x: 15, y: 70, z: -30, dimension: 'nether' });
|
||||
});
|
||||
|
||||
it('getPlayerPosition parses querytarget response when it has a log/text prefix', async () => {
|
||||
const { adapter } = make(async (cmd) => {
|
||||
if (cmd.startsWith('querytarget')) {
|
||||
return '[2026-07-18 23:08:38:396 INFO] Target data: [\n' +
|
||||
' {\n' +
|
||||
' "dimension" : 0,\n' +
|
||||
' "position" : {\n' +
|
||||
' "x" : 12.5,\n' +
|
||||
' "y" : 64.0,\n' +
|
||||
' "z" : -25.5\n' +
|
||||
' }\n' +
|
||||
' }\n' +
|
||||
']';
|
||||
}
|
||||
return '';
|
||||
});
|
||||
const pos = await adapter.getPlayerPosition('alex');
|
||||
expect(pos).toEqual({ x: 12.5, y: 64, z: -25.5, dimension: 'overworld' });
|
||||
});
|
||||
|
||||
it('getPlayerPosition returns null when querytarget fails, and does not fall back to tp', async () => {
|
||||
const { stdin, adapter } = make(async (cmd) => {
|
||||
if (cmd.startsWith('querytarget')) {
|
||||
return 'Unknown command or failure';
|
||||
}
|
||||
return 'Teleported alex to 12.5, 64, -25.5';
|
||||
});
|
||||
const pos = await adapter.getPlayerPosition('alex');
|
||||
expect(pos).toBeNull();
|
||||
expect(stdin.send).not.toHaveBeenCalledWith(expect.stringContaining('tp '));
|
||||
});
|
||||
|
||||
it('whitelistOn maps to "allowlist on"', async () => {
|
||||
const { stdin, adapter } = make();
|
||||
await adapter.whitelistOn();
|
||||
expect(stdin.send).toHaveBeenCalledWith('allowlist on');
|
||||
});
|
||||
|
||||
it('whitelistOff maps to "allowlist off"', async () => {
|
||||
const { stdin, adapter } = make();
|
||||
await adapter.whitelistOff();
|
||||
expect(stdin.send).toHaveBeenCalledWith('allowlist off');
|
||||
});
|
||||
|
||||
it('whitelistList parses "allowlist list" output into array', async () => {
|
||||
const { adapter } = make(async () => 'There are 2 allowlisted players: steve, alex');
|
||||
const list = await adapter.whitelistList();
|
||||
expect(list).toEqual(['steve', 'alex']);
|
||||
});
|
||||
|
||||
it('whitelistList returns empty array when no players', async () => {
|
||||
const { adapter } = make(async () => 'There are 0 allowlisted players:');
|
||||
const list = await adapter.whitelistList();
|
||||
expect(list).toEqual([]);
|
||||
});
|
||||
|
||||
it('sendCommand sends command unmodified if no slash', async () => {
|
||||
const { stdin, adapter } = make();
|
||||
await adapter.sendCommand('gamerule showcoordinates true');
|
||||
expect(stdin.send).toHaveBeenCalledWith('gamerule showcoordinates true');
|
||||
});
|
||||
|
||||
it('sendCommand strips leading slash from command', async () => {
|
||||
const { stdin, adapter } = make();
|
||||
await adapter.sendCommand('/gamerule showcoordinates true');
|
||||
expect(stdin.send).toHaveBeenCalledWith('gamerule showcoordinates true');
|
||||
});
|
||||
|
||||
it('exposes whitelistOn/Off/List capabilities', () => {
|
||||
const { adapter } = make();
|
||||
expect(adapter.capabilities.has('whitelistOn')).toBe(true);
|
||||
expect(adapter.capabilities.has('whitelistOff')).toBe(true);
|
||||
expect(adapter.capabilities.has('whitelistList')).toBe(true);
|
||||
});
|
||||
|
||||
it('saveHold sends "save hold" then polls "save query" until ready', async () => {
|
||||
const { stdin, adapter } = make(async (cmd) =>
|
||||
cmd === 'save query' ? 'Data saved. Files are now ready to be copied.' : 'ok');
|
||||
const ok = await adapter.saveHold();
|
||||
expect(ok).toBe(true);
|
||||
expect(stdin.send).toHaveBeenCalledWith('save hold');
|
||||
expect(stdin.send).toHaveBeenCalledWith('save query');
|
||||
});
|
||||
|
||||
it('saveResume sends "save resume"', async () => {
|
||||
const { stdin, adapter } = make();
|
||||
await adapter.saveResume();
|
||||
expect(stdin.send).toHaveBeenCalledWith('save resume');
|
||||
});
|
||||
|
||||
it('queryUniqueId extracts the uniqueId from querytarget output', async () => {
|
||||
const { adapter } = make(async (cmd) =>
|
||||
cmd.startsWith('querytarget')
|
||||
? '[{"dimension":0,"position":{"x":1,"y":2,"z":3},"uniqueId":"-8589934591"}]'
|
||||
: 'ok');
|
||||
expect(await adapter.queryUniqueId('alex')).toBe('-8589934591');
|
||||
});
|
||||
|
||||
it('queryUniqueId extracts decimal uniqueId from noisy log output', async () => {
|
||||
const { adapter } = make(async (cmd) =>
|
||||
cmd.startsWith('querytarget')
|
||||
? '[2026-07-22 22:43:05:123 INFO] Target data: [{"dimension":0,"position":{"x":100,"y":64,"z":-200},"uniqueId":2535407895138987}]'
|
||||
: 'ok');
|
||||
expect(await adapter.queryUniqueId('Lucasrguerra')).toBe('2535407895138987');
|
||||
});
|
||||
|
||||
it('queryUniqueId parses the real multi-line 1.26 output (UUID uniqueId, id present)', async () => {
|
||||
// Captured verbatim from a live bedrock 1.26.33.2 server
|
||||
const real = [
|
||||
'[2026-07-23 02:40:43:441 INFO] Target data: [',
|
||||
' {',
|
||||
' "dimension" : 0,',
|
||||
' "id" : -64424509439,',
|
||||
' "position" : {',
|
||||
' "x" : 79.52253723144531,',
|
||||
' "y" : 98.62001037597656,',
|
||||
' "z" : 14.37582015991211',
|
||||
' },',
|
||||
' "uniqueId" : "657cb1d4-99dd-3123-b27f-d0c27df79710",',
|
||||
' "yRot" : -82.85218048095703',
|
||||
' }',
|
||||
']',
|
||||
].join('\n');
|
||||
const { adapter } = make(async (cmd) => (cmd.startsWith('querytarget') ? real : 'ok'));
|
||||
expect(await adapter.queryUniqueId('Lucasrguerra')).toBe('657cb1d4-99dd-3123-b27f-d0c27df79710');
|
||||
expect(await adapter.getPlayerPosition('Lucasrguerra')).toEqual({
|
||||
x: 79.52253723144531, y: 98.62001037597656, z: 14.37582015991211, dimension: 'overworld',
|
||||
});
|
||||
});
|
||||
|
||||
it('queryUniqueId returns null when querytarget fails', async () => {
|
||||
const { adapter } = make(async () => 'Unknown command');
|
||||
expect(await adapter.queryUniqueId('alex')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import fsp from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import pkg from 'leveldb-zlib';
|
||||
import nbt from 'prismarine-nbt';
|
||||
import { createBedrockPlayerFile, parsePlayerBuffer } from '../../src/adapters/playerFile/bedrockPlayerFile.js';
|
||||
|
||||
const { LevelDB } = pkg;
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const FIXTURE = path.join(here, '../fixtures/bedrock-player.nbt');
|
||||
const UUID = '1f633c09-f1f1-481c-b811-1cfa15b21973';
|
||||
// Real-world shape: querytarget's uniqueId is the player's MsaId, and the DB
|
||||
// holds a mapping record player_<MsaId> → { MsaId, SelfSignedId, ServerId }.
|
||||
const MSA_ID = '657cb1d4-99dd-3123-b27f-d0c27df79710';
|
||||
const SELF_SIGNED_ID = 'a257319c-50e9-3aad-aef3-8fc3e0d4e4c2';
|
||||
|
||||
function mappingRecord() {
|
||||
const str = (v) => ({ type: 'string', value: v });
|
||||
return nbt.writeUncompressed({
|
||||
type: 'compound',
|
||||
name: '',
|
||||
value: {
|
||||
MsaId: str(MSA_ID),
|
||||
SelfSignedId: str(SELF_SIGNED_ID),
|
||||
ServerId: str('player_server_' + UUID),
|
||||
},
|
||||
}, 'little');
|
||||
}
|
||||
|
||||
let tmp, config;
|
||||
|
||||
beforeAll(async () => {
|
||||
const buf = fs.readFileSync(FIXTURE);
|
||||
tmp = await fsp.mkdtemp(path.join(os.tmpdir(), 'cd-bpf-'));
|
||||
const dbPath = path.join(tmp, 'worlds', 'Bedrock level', 'db');
|
||||
await fsp.mkdir(dbPath, { recursive: true });
|
||||
// Build a real (Mojang-format) LevelDB holding the captured player NBT.
|
||||
const db = new LevelDB(dbPath, { createIfMissing: true });
|
||||
await db.open();
|
||||
await db.put(Buffer.from('player_server_' + UUID, 'latin1'), buf);
|
||||
await db.put(Buffer.from('player_' + MSA_ID, 'latin1'), mappingRecord());
|
||||
await db.put(Buffer.from('player_' + SELF_SIGNED_ID, 'latin1'), mappingRecord());
|
||||
await db.close();
|
||||
config = { mcDataPath: tmp, mcWorldName: 'worlds' };
|
||||
});
|
||||
|
||||
afterAll(async () => { await fsp.rm(tmp, { recursive: true, force: true }); });
|
||||
|
||||
describe('bedrockPlayerFile', () => {
|
||||
it('parsePlayerBuffer decodes the captured real-world fixture', async () => {
|
||||
const d = await parsePlayerBuffer(fs.readFileSync(FIXTURE));
|
||||
expect(d.position).not.toBeNull();
|
||||
expect(d.health.max).toBe(20);
|
||||
expect(typeof d.food).toBe('number');
|
||||
expect(Array.isArray(d.inventory)).toBe(true);
|
||||
expect(d.uniqueId).toBe('-8589934591');
|
||||
});
|
||||
|
||||
it('readPlayer returns normalized data for an existing uuid', async () => {
|
||||
const adapter = createBedrockPlayerFile(config);
|
||||
const d = await adapter.readPlayer(UUID);
|
||||
expect(d.uniqueId).toBe('-8589934591');
|
||||
expect(d.position).not.toBeNull();
|
||||
});
|
||||
|
||||
it('readPlayer returns null for a missing uuid', async () => {
|
||||
const adapter = createBedrockPlayerFile(config);
|
||||
expect(await adapter.readPlayer('00000000-0000-0000-0000-000000000000')).toBeNull();
|
||||
});
|
||||
|
||||
it('findByUniqueId locates the player by UniqueID (int64 or UUID string format)', async () => {
|
||||
const adapter = createBedrockPlayerFile(config);
|
||||
const hit = await adapter.findByUniqueId('-8589934591');
|
||||
expect(hit).not.toBeNull();
|
||||
expect(hit.uuid).toBe(UUID);
|
||||
|
||||
// Modern Bedrock servers output uniqueId in UUID hex string format via querytarget:
|
||||
// e.g. "657cb1d4-99dd-3123-b27f-d0c27df79710" or corresponding int64 (-8589934591)
|
||||
const hit2 = await adapter.findByUniqueId('00000000-0000-0000-ffff-fffe00000001');
|
||||
expect(hit2).not.toBeNull();
|
||||
expect(hit2.uuid).toBe(UUID);
|
||||
});
|
||||
|
||||
it('findByUniqueId resolves via the player_<uuid> ServerId mapping record', async () => {
|
||||
const adapter = createBedrockPlayerFile(config);
|
||||
// querytarget uniqueId (= MsaId) — the case seen against a live server
|
||||
const hit = await adapter.findByUniqueId(MSA_ID);
|
||||
expect(hit).not.toBeNull();
|
||||
expect(hit.uuid).toBe(UUID);
|
||||
// SelfSignedId mapping record also resolves
|
||||
const hit2 = await adapter.findByUniqueId(SELF_SIGNED_ID);
|
||||
expect(hit2.uuid).toBe(UUID);
|
||||
});
|
||||
|
||||
it('listServerUuids enumerates player_server_ keys', async () => {
|
||||
const adapter = createBedrockPlayerFile(config);
|
||||
expect(await adapter.listServerUuids()).toContain(UUID);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { createAdapter, isBedrock } from '../../src/adapters/index.js';
|
||||
|
||||
const deps = {
|
||||
rconService: { send: async () => 'r' },
|
||||
stdinService: { send: async () => 's' },
|
||||
};
|
||||
|
||||
describe('adapter factory', () => {
|
||||
it('isBedrock detects BEDROCK type case-insensitively', () => {
|
||||
expect(isBedrock('BEDROCK')).toBe(true);
|
||||
expect(isBedrock('bedrock')).toBe(true);
|
||||
expect(isBedrock('PAPER')).toBe(false);
|
||||
expect(isBedrock(null)).toBe(false);
|
||||
});
|
||||
|
||||
it('creates a java adapter for PAPER', () => {
|
||||
const a = createAdapter('PAPER', deps);
|
||||
expect(a.capabilities.has('ban')).toBe(true);
|
||||
});
|
||||
|
||||
it('creates a bedrock adapter for BEDROCK', () => {
|
||||
const a = createAdapter('BEDROCK', deps);
|
||||
expect(a.capabilities.has('ban')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { createJavaAdapter } from '../../src/adapters/javaAdapter.js';
|
||||
|
||||
function make(sendImpl) {
|
||||
const rcon = { send: vi.fn(sendImpl || (async () => 'ok')) };
|
||||
return { rcon, adapter: createJavaAdapter(rcon) };
|
||||
}
|
||||
|
||||
describe('JavaAdapter', () => {
|
||||
it('whitelistAdd issues "whitelist add"', async () => {
|
||||
const { rcon, adapter } = make();
|
||||
await adapter.whitelistAdd('steve');
|
||||
expect(rcon.send).toHaveBeenCalledWith('whitelist add steve');
|
||||
});
|
||||
|
||||
it('ban issues "ban" with reason', async () => {
|
||||
const { rcon, adapter } = make();
|
||||
await adapter.ban('griefer', 'no griefing');
|
||||
expect(rcon.send).toHaveBeenCalledWith('ban griefer no griefing');
|
||||
});
|
||||
|
||||
it('give issues "give" with item and count', async () => {
|
||||
const { rcon, adapter } = make();
|
||||
await adapter.give('steve', 'minecraft:diamond', 5);
|
||||
expect(rcon.send).toHaveBeenCalledWith('give steve minecraft:diamond 5');
|
||||
});
|
||||
|
||||
it('listPlayers parses rcon output', async () => {
|
||||
const { adapter } = make(async () => 'There are 1 of a max of 20 players online: steve');
|
||||
expect(await adapter.listPlayers()).toEqual({ online: 1, max: 20, players: ['steve'] });
|
||||
});
|
||||
|
||||
it('exposes JAVA capabilities', () => {
|
||||
const { adapter } = make();
|
||||
expect(adapter.capabilities.has('ban')).toBe(true);
|
||||
});
|
||||
|
||||
it('getSeed parses seed correctly', async () => {
|
||||
const { adapter } = make(async () => 'Seed: [123456789]');
|
||||
const seed = await adapter.getSeed();
|
||||
expect(seed).toBe('123456789');
|
||||
});
|
||||
|
||||
it('getPlayerPosition parses coordinates and dimension correctly', async () => {
|
||||
let callCount = 0;
|
||||
const { rcon, adapter } = make(async (cmd) => {
|
||||
callCount++;
|
||||
if (cmd.startsWith('tp')) {
|
||||
return 'Teleported steve to 10.5, 64, -20.3';
|
||||
}
|
||||
if (cmd.startsWith('data get')) {
|
||||
return 'steve has the following entity data: "minecraft:overworld"';
|
||||
}
|
||||
return '';
|
||||
});
|
||||
const pos = await adapter.getPlayerPosition('steve');
|
||||
expect(pos).toEqual({ x: 10.5, y: 64, z: -20.3, dimension: 'overworld' });
|
||||
});
|
||||
|
||||
it('whitelistOn maps to "whitelist on"', async () => {
|
||||
const { rcon, adapter } = make();
|
||||
await adapter.whitelistOn();
|
||||
expect(rcon.send).toHaveBeenCalledWith('whitelist on');
|
||||
});
|
||||
|
||||
it('whitelistOff maps to "whitelist off"', async () => {
|
||||
const { rcon, adapter } = make();
|
||||
await adapter.whitelistOff();
|
||||
expect(rcon.send).toHaveBeenCalledWith('whitelist off');
|
||||
});
|
||||
|
||||
it('whitelistList parses "whitelist list" output into array', async () => {
|
||||
const { adapter } = make(async () => 'There are 3 whitelisted players: steve, alex, notch');
|
||||
const list = await adapter.whitelistList();
|
||||
expect(list).toEqual(['steve', 'alex', 'notch']);
|
||||
});
|
||||
|
||||
it('whitelistList returns empty array when no players', async () => {
|
||||
const { adapter } = make(async () => 'There are 0 whitelisted players:');
|
||||
const list = await adapter.whitelistList();
|
||||
expect(list).toEqual([]);
|
||||
});
|
||||
|
||||
it('sendCommand sends command unmodified if no slash', async () => {
|
||||
const { rcon, adapter } = make();
|
||||
await adapter.sendCommand('gamerule showcoordinates true');
|
||||
expect(rcon.send).toHaveBeenCalledWith('gamerule showcoordinates true');
|
||||
});
|
||||
|
||||
it('sendCommand strips leading slash from command', async () => {
|
||||
const { rcon, adapter } = make();
|
||||
await adapter.sendCommand('/gamerule showcoordinates true');
|
||||
expect(rcon.send).toHaveBeenCalledWith('gamerule showcoordinates true');
|
||||
});
|
||||
|
||||
it('exposes whitelistOn/Off/List capabilities', () => {
|
||||
const { adapter } = make();
|
||||
expect(adapter.capabilities.has('whitelistOn')).toBe(true);
|
||||
expect(adapter.capabilities.has('whitelistOff')).toBe(true);
|
||||
expect(adapter.capabilities.has('whitelistList')).toBe(true);
|
||||
});
|
||||
|
||||
it('forceSave issues "save-all flush"', async () => {
|
||||
const { rcon, adapter } = make();
|
||||
await adapter.forceSave();
|
||||
expect(rcon.send).toHaveBeenCalledWith('save-all flush');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import fsp from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import zlib from 'node:zlib';
|
||||
import nbt from 'prismarine-nbt';
|
||||
import { createJavaPlayerFile } from '../../src/adapters/playerFile/javaPlayerFile.js';
|
||||
|
||||
const UUID = '11111111-2222-3333-4444-555555555555';
|
||||
let tmp, config;
|
||||
|
||||
beforeAll(async () => {
|
||||
const doc = {
|
||||
type: 'compound', name: '', value: {
|
||||
Pos: { type: 'list', value: { type: 'double', value: [10.5, 64, -20.3] } },
|
||||
Dimension: { type: 'string', value: 'minecraft:the_end' },
|
||||
Health: { type: 'float', value: 18 },
|
||||
foodLevel: { type: 'int', value: 15 },
|
||||
Inventory: {
|
||||
type: 'list', value: {
|
||||
type: 'compound', value: [{
|
||||
Slot: { type: 'byte', value: 0 },
|
||||
id: { type: 'string', value: 'minecraft:dirt' },
|
||||
Count: { type: 'byte', value: 64 },
|
||||
}],
|
||||
},
|
||||
},
|
||||
playerGameType: { type: 'int', value: 0 },
|
||||
XpLevel: { type: 'int', value: 3 },
|
||||
},
|
||||
};
|
||||
const dat = zlib.gzipSync(nbt.writeUncompressed(doc, 'big'));
|
||||
tmp = await fsp.mkdtemp(path.join(os.tmpdir(), 'cd-jpf-'));
|
||||
const pdir = path.join(tmp, 'world', 'playerdata');
|
||||
await fsp.mkdir(pdir, { recursive: true });
|
||||
await fsp.writeFile(path.join(pdir, `${UUID}.dat`), dat);
|
||||
config = { mcDataPath: tmp, mcWorldName: 'world' };
|
||||
});
|
||||
|
||||
afterAll(async () => { if (tmp) await fsp.rm(tmp, { recursive: true, force: true }); });
|
||||
|
||||
describe('javaPlayerFile', () => {
|
||||
it('reads and normalizes a playerdata .dat', async () => {
|
||||
const d = await createJavaPlayerFile(config).readPlayer(UUID);
|
||||
expect(d.position).toEqual({ x: 10.5, y: 64, z: -20.3 });
|
||||
expect(d.dimension).toBe('end');
|
||||
expect(d.health).toEqual({ current: 18, max: 20 });
|
||||
expect(d.food).toBe(15);
|
||||
expect(d.inventory).toEqual([{ slot: 0, name: 'minecraft:dirt', count: 64 }]);
|
||||
expect(d.gamemode).toBe('survival');
|
||||
});
|
||||
|
||||
it('returns null when the player has no .dat', async () => {
|
||||
const d = await createJavaPlayerFile(config).readPlayer('00000000-0000-0000-0000-000000000000');
|
||||
expect(d).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { normalizeBedrock, normalizeJava, int64ToString } from '../../src/adapters/playerFile/nbtPlayer.js';
|
||||
|
||||
describe('int64ToString', () => {
|
||||
it('combines [high, low] int32 pair like prismarine-nbt longs', () => {
|
||||
expect(int64ToString([-2, 1])).toBe('-8589934591');
|
||||
expect(int64ToString([0, 5])).toBe('5');
|
||||
});
|
||||
it('passes through number/bigint', () => {
|
||||
expect(int64ToString(42)).toBe('42');
|
||||
expect(int64ToString(7n)).toBe('7');
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeBedrock', () => {
|
||||
it('extracts position, dimension, health, hunger, inventory, uniqueId', () => {
|
||||
const d = normalizeBedrock({
|
||||
Pos: [1.5, 2, 3], DimensionId: 1,
|
||||
Attributes: [
|
||||
{ Name: 'minecraft:health', Current: 9.5, Max: 20 },
|
||||
{ Name: 'minecraft:player.hunger', Current: 17 },
|
||||
],
|
||||
Inventory: [
|
||||
{ Slot: 0, Name: 'minecraft:iron_pickaxe', Count: 1 },
|
||||
{ Slot: 1, Name: '', Count: 0 },
|
||||
],
|
||||
PlayerGameMode: 1, PlayerLevel: 10, UniqueID: [-2, 1],
|
||||
});
|
||||
expect(d.position).toEqual({ x: 1.5, y: 2, z: 3 });
|
||||
expect(d.dimension).toBe('nether');
|
||||
expect(d.health).toEqual({ current: 9.5, max: 20 });
|
||||
expect(d.food).toBe(17);
|
||||
expect(d.inventory).toEqual([{ slot: 0, name: 'minecraft:iron_pickaxe', count: 1 }]);
|
||||
expect(d.gamemode).toBe('creative');
|
||||
expect(d.xp).toEqual({ level: 10 });
|
||||
expect(d.uniqueId).toBe('-8589934591');
|
||||
});
|
||||
|
||||
it('extracts armor (head/chest/legs/feet) and offhand from their own NBT lists', () => {
|
||||
const d = normalizeBedrock({
|
||||
Armor: [
|
||||
{ Name: 'minecraft:iron_helmet', Count: 1 },
|
||||
{ Name: 'minecraft:iron_chestplate', Count: 1 },
|
||||
{ Name: '', Count: 0 },
|
||||
{ Name: 'minecraft:leather_boots', Count: 1 },
|
||||
{ Name: '', Count: 0 }, // 5th body slot on some versions — ignored
|
||||
],
|
||||
Offhand: [{ Name: 'minecraft:shield', Count: 1 }],
|
||||
});
|
||||
expect(d.armor).toEqual({
|
||||
head: { name: 'minecraft:iron_helmet', count: 1 },
|
||||
chest: { name: 'minecraft:iron_chestplate', count: 1 },
|
||||
legs: null,
|
||||
feet: { name: 'minecraft:leather_boots', count: 1 },
|
||||
});
|
||||
expect(d.offhand).toEqual({ name: 'minecraft:shield', count: 1 });
|
||||
});
|
||||
|
||||
it('yields empty armor/offhand when the lists are absent or empty-named', () => {
|
||||
const d = normalizeBedrock({});
|
||||
expect(d.armor).toEqual({ head: null, chest: null, legs: null, feet: null });
|
||||
expect(d.offhand).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeJava', () => {
|
||||
it('extracts fields from a java playerdata shape', () => {
|
||||
const d = normalizeJava({
|
||||
Pos: [10.5, 64, -20.3], Dimension: 'minecraft:the_end',
|
||||
Health: 18.0, foodLevel: 15,
|
||||
Inventory: [{ Slot: 0, id: 'minecraft:dirt', Count: 64 }],
|
||||
playerGameType: 0, XpLevel: 3,
|
||||
});
|
||||
expect(d.position).toEqual({ x: 10.5, y: 64, z: -20.3 });
|
||||
expect(d.dimension).toBe('end');
|
||||
expect(d.health).toEqual({ current: 18, max: 20 });
|
||||
expect(d.food).toBe(15);
|
||||
expect(d.inventory).toEqual([{ slot: 0, name: 'minecraft:dirt', count: 64 }]);
|
||||
expect(d.gamemode).toBe('survival');
|
||||
expect(d.xp).toEqual({ level: 3 });
|
||||
});
|
||||
|
||||
it('splits armor (slots 100-103) and offhand (-106) out of the java Inventory list', () => {
|
||||
const d = normalizeJava({
|
||||
Inventory: [
|
||||
{ Slot: 0, id: 'minecraft:dirt', Count: 64 },
|
||||
{ Slot: 100, id: 'minecraft:iron_boots', Count: 1 },
|
||||
{ Slot: 101, id: 'minecraft:iron_leggings', Count: 1 },
|
||||
{ Slot: 102, id: 'minecraft:iron_chestplate', Count: 1 },
|
||||
{ Slot: 103, id: 'minecraft:iron_helmet', Count: 1 },
|
||||
{ Slot: -106, id: 'minecraft:shield', Count: 1 },
|
||||
],
|
||||
});
|
||||
// armor/offhand are NOT part of the regular inventory grid
|
||||
expect(d.inventory).toEqual([{ slot: 0, name: 'minecraft:dirt', count: 64 }]);
|
||||
expect(d.armor).toEqual({
|
||||
head: { name: 'minecraft:iron_helmet', count: 1 },
|
||||
chest: { name: 'minecraft:iron_chestplate', count: 1 },
|
||||
legs: { name: 'minecraft:iron_leggings', count: 1 },
|
||||
feet: { name: 'minecraft:iron_boots', count: 1 },
|
||||
});
|
||||
expect(d.offhand).toEqual({ name: 'minecraft:shield', count: 1 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { createPlayerFileAdapter } from '../../src/adapters/playerFile/index.js';
|
||||
|
||||
const config = { mcDataPath: '/data', mcWorldName: 'worlds' };
|
||||
|
||||
describe('createPlayerFileAdapter', () => {
|
||||
it('returns the bedrock adapter (with findByUniqueId) for bedrock', () => {
|
||||
const a = createPlayerFileAdapter('bedrock', config);
|
||||
expect(typeof a.readPlayer).toBe('function');
|
||||
expect(typeof a.findByUniqueId).toBe('function');
|
||||
});
|
||||
|
||||
it('returns the java adapter for java', () => {
|
||||
const a = createPlayerFileAdapter('java', config);
|
||||
expect(typeof a.readPlayer).toBe('function');
|
||||
expect(a.findByUniqueId).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parsePlayerList, parseWhitelistList, CAPABILITIES } from '../../src/adapters/serverAdapter.js';
|
||||
|
||||
describe('parsePlayerList', () => {
|
||||
it('parses Java list output', () => {
|
||||
const r = parsePlayerList(
|
||||
'There are 2 of a max of 20 players online: steve, alex'
|
||||
);
|
||||
expect(r).toEqual({ online: 2, max: 20, players: ['steve', 'alex'] });
|
||||
});
|
||||
|
||||
it('parses Bedrock list output', () => {
|
||||
const r = parsePlayerList('There are 1/10 players online:\nnotch');
|
||||
expect(r).toEqual({ online: 1, max: 10, players: ['notch'] });
|
||||
});
|
||||
|
||||
it('handles empty server', () => {
|
||||
const r = parsePlayerList('There are 0 of a max of 20 players online:');
|
||||
expect(r).toEqual({ online: 0, max: 20, players: [] });
|
||||
});
|
||||
});
|
||||
|
||||
describe('CAPABILITIES', () => {
|
||||
it('java supports ban, bedrock does not', () => {
|
||||
expect(CAPABILITIES.JAVA.has('ban')).toBe(true);
|
||||
expect(CAPABILITIES.BEDROCK.has('ban')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseWhitelistList', () => {
|
||||
it('parses Java style text whitelist list', () => {
|
||||
const res = parseWhitelistList('There are 2 whitelisted players: steve, alex');
|
||||
expect(res).toEqual(['steve', 'alex']);
|
||||
});
|
||||
|
||||
it('parses Bedrock style JSON allowlist list', () => {
|
||||
const res = parseWhitelistList(
|
||||
'41:55:938 INFO] ###* {"command":"allowlist", "result":[{"ignoresPlayerLimit":false,"name":"Lucasrguerra","xuid":"2535407895138987"}]} *###'
|
||||
);
|
||||
expect(res).toEqual(['Lucasrguerra']);
|
||||
});
|
||||
|
||||
it('returns empty for an empty Bedrock allowlist (result: null), not garbage', () => {
|
||||
// This is the exact line that produced ghost "names" from log fragments.
|
||||
const res = parseWhitelistList(
|
||||
'[2026-07-18 22:53:21:185 INFO] ###* {"command":"allowlist","result":null} *###'
|
||||
);
|
||||
expect(res).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns empty for a Bedrock allowlist with an empty array', () => {
|
||||
expect(parseWhitelistList('###* {"command":"allowlist","result":[]} *###')).toEqual([]);
|
||||
});
|
||||
|
||||
it('never scrapes names out of an unrelated log line with a colon', () => {
|
||||
expect(parseWhitelistList('[2026-07-18 22:53:23:695 ERROR] Syntax error: Unexpected token')).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns empty (not [""]) for the Java empty-whitelist message', () => {
|
||||
expect(parseWhitelistList('There are no whitelisted players')).toEqual([]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user