Move player position parsing to shared util, fix player ID persistence

This commit is contained in:
Danielv123 committed 2025-08-10 00:28:48 +02:00
1 parent a36e7bddca
commit 093b71045b
6 files changed
+406 -141

No files matched your search

+56 -3
View File
@@ -25,7 +25,8 @@ import {
pixelsToRGBA,
parseRecipeTileBinary,
ParsedRecipeTile,
} from "./tile-utils";
} from "./utils/tile-utils";
import { parsePlayerPositionsBinary } from "./utils/player-utils";
const CHUNK_SIZE = 32;
const inflateAsync = promisify(zlib.inflate);
@@ -113,6 +114,8 @@ export class ControllerPlugin extends BaseControllerPlugin {
await this.loadExistingTagContent();
// Load existing recipe data for deduplication
await this.loadExistingRecipeContent();
// Load existing player sessions from disk to maintain ID consistency
await this.loadExistingPlayerSessions();
this.saveInterval = setInterval(() => {
this.saveTiles().catch(err => this.logger.error(`Error saving tiles: ${err}`));
@@ -1050,17 +1053,18 @@ export class ControllerPlugin extends BaseControllerPlugin {
const sessions = this.playerSessions.get(fileKey)!;
const activeSessions = this.activePlayerSessions.get(fileKey)!;
// Assign player ID if this player doesn't have one yet
// Assign player ID if this player doesn't have one yet, or reopen their session
let playerId: number;
if (!sessions.has(player_data.player_name)) {
playerId = this.nextPlayerIds.get(fileKey)!;
sessions.set(player_data.player_name, playerId);
this.nextPlayerIds.set(fileKey, playerId + 1);
this.logger.verbose(`Assigned new player ID ${playerId} to ${player_data.player_name} on ${fileKey}`);
} else {
playerId = sessions.get(player_data.player_name)!;
}
// Track active player session
// Reopen/track active player session (handles session reopening after controller restart)
activeSessions.add(player_data.player_name);
// Add the player ID to the data for saving (without modifying the original schema)
@@ -1104,4 +1108,53 @@ export class ControllerPlugin extends BaseControllerPlugin {
return { positions: null };
}
}
private async loadExistingPlayerSessions() {
try {
if (!await fs.pathExists(this.playerPositionsPath)) {
return;
}
const files = await fs.readdir(this.playerPositionsPath);
const positionFiles = files.filter(file => file.endsWith(".positions"));
for (const file of positionFiles) {
const fileKey = file.replace(".positions", "");
const filePath = path.join(this.playerPositionsPath, file);
if (!this.playerSessions.has(fileKey)) {
this.playerSessions.set(fileKey, new Map());
this.nextPlayerIds.set(fileKey, 1); // Start from 1, reserve 0 for unknown
// Start with no active sessions - they will be reopened when players send new positions
this.activePlayerSessions.set(fileKey, new Set());
}
const sessions = this.playerSessions.get(fileKey)!;
try {
const data = await fs.readFile(filePath);
// Use shared parsing function
const parsed = parsePlayerPositionsBinary(data);
// Restore the player name -> ID mappings from SessionStart records
for (const [playerName, playerId] of parsed.playerSessions) {
sessions.set(playerName, playerId);
}
// Set next player ID to be higher than any existing ID
if (parsed.maxPlayerId > 0) {
this.nextPlayerIds.set(fileKey, parsed.maxPlayerId + 1);
}
// eslint-disable-next-line max-len
this.logger.info(`Loaded ${sessions.size} player sessions for ${fileKey}, next ID will be ${this.nextPlayerIds.get(fileKey)}`);
} catch (readErr) {
this.logger.error(`Failed to read player positions file ${file}: ${readErr}`);
}
}
} catch (err) {
this.logger.error(`Error loading existing player sessions: ${err}`);
}
}
}
+3 -1
View File
@@ -6,7 +6,9 @@
"include": [
"web/**/*",
"messages.ts",
"tile-utils.ts"
"utils/tile-utils.ts",
"utils/player-utils.ts",
"utils/parsing-utils.ts"
],
"exclude": [
"dist/**/*"
+41
View File
@@ -0,0 +1,41 @@
// Environment detection
export const isNode = typeof Buffer !== "undefined" && typeof process !== "undefined" && process.versions?.node;
// Cross-platform type for binary data
export type BinaryData = Buffer | Uint8Array;
// Cross-platform helper functions for reading integers
export function readUInt8(data: BinaryData, offset: number): number {
if (isNode && data instanceof Buffer) {
return data.readUInt8(offset);
}
return (data as Uint8Array)[offset];
}
export function readUInt16LE(data: BinaryData, offset: number): number {
if (isNode && data instanceof Buffer) {
return data.readUInt16LE(offset);
}
const bytes = data as Uint8Array;
return bytes[offset] | (bytes[offset + 1] << 8);
}
export function readUInt16BE(data: BinaryData, offset: number): number {
if (isNode && data instanceof Buffer) {
return data.readUInt16BE(offset);
}
const bytes = data as Uint8Array;
return (bytes[offset] << 8) | bytes[offset + 1];
}
export function readUInt32BE(data: BinaryData, offset: number): number {
if (isNode && data instanceof Buffer) {
return data.readUInt32BE(offset);
}
const bytes = data as Uint8Array;
return (bytes[offset] << 24) | (bytes[offset + 1] << 16) | (bytes[offset + 2] << 8) | bytes[offset + 3];
}
export function sliceData(data: BinaryData, start: number, end: number): BinaryData {
return data.slice(start, end);
}
+295
View File
@@ -0,0 +1,295 @@
import { BinaryData, sliceData, readUInt8, readUInt16BE, readUInt32BE, isNode } from "./parsing-utils";
/**
* Parsed player position file data
*/
export interface ParsedPlayerPositions {
/** Player name -> player ID mappings from SessionStart records */
playerSessions: Map<string, number>;
/** Maximum player ID found in the file */
maxPlayerId: number;
/** All position records in the file */
positions: PlayerPositionRecord[];
}
export interface PlayerPositionRecord {
type: number;
playerId: number;
// Position record fields
tSec?: number;
sec?: number;
xTiles?: number;
yTiles?: number;
// Session record fields
tMs?: number;
playerName?: string;
}
export interface ParsedPlayerPos {
name: string;
x: number;
y: number;
sec: number;
}
/**
* Parse binary player position file data
* @param buf Binary player position data
* @returns Parsed player position data with sessions and records
*/
export function parsePlayerPositionsBinary(buf: BinaryData): ParsedPlayerPositions {
const playerSessions = new Map<string, number>();
const positions: PlayerPositionRecord[] = [];
let maxPlayerId = 0;
let offset = 0;
while (offset < buf.length) {
if (offset + 1 > buf.length) { break; }
const type = readUInt8(buf, offset);
offset += 1;
if (type === 0) {
// Position record
const result = parsePositionRecord(buf, offset);
if (!result.success) { break; }
const record: PlayerPositionRecord = {
type: 0,
playerId: result.playerId,
tSec: result.tSec,
sec: result.sec,
xTiles: result.xTiles,
yTiles: result.yTiles,
};
positions.push(record);
if (result.playerId > maxPlayerId) {
maxPlayerId = result.playerId;
}
offset = result.newOffset;
} else if (type === 1) {
// SessionStart record
const result = parseSessionStartRecord(buf, offset);
if (!result.success) { break; }
const record: PlayerPositionRecord = {
type: 1,
playerId: result.playerId,
tMs: result.tMs,
playerName: result.playerName,
};
positions.push(record);
playerSessions.set(result.playerName, result.playerId);
if (result.playerId > maxPlayerId) {
maxPlayerId = result.playerId;
}
offset = result.newOffset;
} else if (type === 2) {
// SessionEnd record
const result = parseSessionEndRecord(buf, offset);
if (!result.success) { break; }
const record: PlayerPositionRecord = {
type: 2,
playerId: result.playerId,
tMs: result.tMs,
};
positions.push(record);
if (result.playerId > maxPlayerId) {
maxPlayerId = result.playerId;
}
offset = result.newOffset;
} else {
// Unknown record type, can't continue parsing safely
break;
}
}
return { playerSessions, maxPlayerId, positions };
}
/**
* Parse position record from player position file
* @param buf The binary player position data
* @param offset Current offset in the data
* @returns Processing result with position data and new offset
*/
function parsePositionRecord(buf: BinaryData, offset: number): {
success: boolean;
newOffset: number;
playerId: number;
tSec: number;
sec: number;
xTiles: number;
yTiles: number;
} {
// Position record: 4 + 4 + 3 + 3 + 2 = 16 bytes
if (offset + 16 > buf.length) {
return { success: false, newOffset: offset, playerId: 0, tSec: 0, sec: 0, xTiles: 0, yTiles: 0 };
}
const tSec = readUInt32BE(buf, offset);
offset += 4;
const sec = readUInt32BE(buf, offset);
offset += 4;
// Read x_tiles (3 bytes, signed 24-bit)
const xBytes = (readUInt8(buf, offset) << 16) | (readUInt8(buf, offset + 1) << 8) | readUInt8(buf, offset + 2);
const xTiles = xBytes > 0x7FFFFF ? xBytes - 0x1000000 : xBytes; // Convert to signed
offset += 3;
// Read y_tiles (3 bytes, signed 24-bit)
const yBytes = (readUInt8(buf, offset) << 16) | (readUInt8(buf, offset + 1) << 8) | readUInt8(buf, offset + 2);
const yTiles = yBytes > 0x7FFFFF ? yBytes - 0x1000000 : yBytes; // Convert to signed
offset += 3;
const playerId = readUInt16BE(buf, offset);
offset += 2;
return { success: true, newOffset: offset, playerId, tSec, sec, xTiles, yTiles };
}
/**
* Parse SessionStart record from player position file
* @param buf The binary player position data
* @param offset Current offset in the data
* @returns Processing result with session data and new offset
*/
function parseSessionStartRecord(
buf: BinaryData,
offset: number
): { success: boolean; newOffset: number; playerId: number; tMs: number; playerName: string } {
// SessionStart record: 4 + 2 + 1 + n = 7 + n bytes minimum
if (offset + 7 > buf.length) {
return { success: false, newOffset: offset, playerId: 0, tMs: 0, playerName: "" };
}
const tMs = readUInt32BE(buf, offset);
offset += 4;
const playerId = readUInt16BE(buf, offset);
offset += 2;
const nameLen = readUInt8(buf, offset);
offset += 1;
if (offset + nameLen > buf.length) {
return { success: false, newOffset: offset, playerId, tMs, playerName: "" };
}
const nameBytes = sliceData(buf, offset, offset + nameLen);
offset += nameLen;
// Convert to string using cross-platform method
let playerName: string;
if (isNode && nameBytes instanceof Buffer) {
playerName = nameBytes.toString("utf-8");
} else {
playerName = new TextDecoder().decode(nameBytes as Uint8Array);
}
return { success: true, newOffset: offset, playerId, tMs, playerName };
}
/**
* Parse SessionEnd record from player position file
* @param buf The binary player position data
* @param offset Current offset in the data
* @returns Processing result with session data and new offset
*/
function parseSessionEndRecord(
buf: BinaryData,
offset: number
): { success: boolean; newOffset: number; playerId: number; tMs: number } {
// SessionEnd record: 4 + 2 = 6 bytes
if (offset + 6 > buf.length) {
return { success: false, newOffset: offset, playerId: 0, tMs: 0 };
}
const tMs = readUInt32BE(buf, offset);
offset += 4;
const playerId = readUInt16BE(buf, offset);
offset += 2;
return { success: true, newOffset: offset, playerId, tMs };
}
/**
* Parse and deduplicate player positions from binary data
* @param buf Binary player position data
* @returns Map of player ID to deduplicated position timeline
*/
export function parseAndDeduplicatePlayerPositions(buf: BinaryData): Map<number, ParsedPlayerPos[]> {
// Use the utility function to parse the binary data
const parsed: ParsedPlayerPositions = parsePlayerPositionsBinary(buf);
// Build player timelines from the parsed data
const playerTimelines = new Map<number, ParsedPlayerPos[]>();
// Process all records in order
for (const record of parsed.positions) {
if (record.type === 0
&& record.sec !== undefined
&& record.xTiles !== undefined
&& record.yTiles !== undefined
) {
// Position record - convert tile coordinates to world coordinates
const name = [...parsed.playerSessions.entries()].find(([, id]) => id === record.playerId)?.[0];
if (name) {
if (!playerTimelines.has(record.playerId)) {
playerTimelines.set(record.playerId, []);
}
// Convert from tiles to world coordinates (tiles are 32x32 world coordinates)
const x = record.xTiles * 32;
const y = record.yTiles * 32;
playerTimelines.get(record.playerId)!.push({
name,
x,
y,
sec: record.sec,
});
}
}
}
// Deduplicate consecutive identical positions for each player
const deduplicatedTimelines = new Map<number, ParsedPlayerPos[]>();
for (const [playerId, timeline] of playerTimelines) {
if (timeline.length === 0) { continue; }
const deduplicated: ParsedPlayerPos[] = [];
let lastPos = timeline[0];
deduplicated.push(lastPos);
for (let i = 1; i < timeline.length; i++) {
const currentPos = timeline[i];
// Check if position changed (with small tolerance for floating point)
const deltaX = Math.abs(currentPos.x - lastPos.x);
const deltaY = Math.abs(currentPos.y - lastPos.y);
if (deltaX > 4 || deltaY > 4) {
// Position changed significantly, keep this point
deduplicated.push(currentPos);
lastPos = currentPos;
}
// If position is the same, skip this point (deduplication)
}
// Always keep the last position to ensure timeline ends correctly
if (timeline.length > 1 && deduplicated[deduplicated.length - 1] !== timeline[timeline.length - 1]) {
deduplicated.push(timeline[timeline.length - 1]);
}
deduplicatedTimelines.set(playerId, deduplicated);
}
return deduplicatedTimelines;
}
@@ -1,47 +1,6 @@
import { BinaryData, sliceData, readUInt8, readUInt16LE, readUInt16BE, readUInt32BE, isNode } from "./parsing-utils";
import * as zlib from "zlib";
// Environment detection
const isNode = typeof Buffer !== "undefined" && typeof process !== "undefined" && process.versions?.node;
// Cross-platform type for binary data
type BinaryData = Buffer | Uint8Array;
// Cross-platform helper functions for reading integers
function readUInt8(data: BinaryData, offset: number): number {
if (isNode && data instanceof Buffer) {
return data.readUInt8(offset);
}
return (data as Uint8Array)[offset];
}
function readUInt16LE(data: BinaryData, offset: number): number {
if (isNode && data instanceof Buffer) {
return data.readUInt16LE(offset);
}
const bytes = data as Uint8Array;
return bytes[offset] | (bytes[offset + 1] << 8);
}
function readUInt16BE(data: BinaryData, offset: number): number {
if (isNode && data instanceof Buffer) {
return data.readUInt16BE(offset);
}
const bytes = data as Uint8Array;
return (bytes[offset] << 8) | bytes[offset + 1];
}
function readUInt32BE(data: BinaryData, offset: number): number {
if (isNode && data instanceof Buffer) {
return data.readUInt32BE(offset);
}
const bytes = data as Uint8Array;
return (bytes[offset] << 24) | (bytes[offset + 1] << 16) | (bytes[offset + 2] << 8) | bytes[offset + 3];
}
function sliceData(data: BinaryData, start: number, end: number): BinaryData {
return data.slice(start, end);
}
// Cross-platform decompression
async function inflateData(data: BinaryData): Promise<Buffer | Uint8Array> {
if (isNode) {
+10 -95
View File
@@ -24,7 +24,8 @@ import {
renderTileIncremental,
ParsedTileData,
parseRecipeTileBinary,
} from "../tile-utils";
} from "../utils/tile-utils";
import { parseAndDeduplicatePlayerPositions, ParsedPlayerPos } from "../utils/player-utils";
const { Text } = Typography;
@@ -63,7 +64,6 @@ interface TileState {
imageData: ImageData;
}
interface ParsedPlayerPos { name: string; x: number; y: number; sec: number; }
const CHUNK_SIZE = 32; // 32x32 pixels per chunk
const TILE_SIZE = 256; // 256x256 pixels per tile (8x8 chunks)
@@ -590,106 +590,21 @@ export default function CanvasMinimapPage() {
if (!resp.positions) { return null; }
const binStr = atob(resp.positions);
const buf = new Uint8Array(binStr.length);
for (let i = 0; i < binStr.length; i++) { buf[i] = binStr.charCodeAt(i); }
for (let i = 0; i < binStr.length; i++) {
buf[i] = binStr.charCodeAt(i);
}
playerPathBufRef.current = buf;
// Parse and deduplicate positions immediately after loading
parseAndDeduplicatePlayerPositions(buf);
// Use the utility function to parse and deduplicate player positions
const deduplicatedTimelines = parseAndDeduplicatePlayerPositions(buf);
// Store deduplicated data in cache
deduplicatedPlayerPositions.current = deduplicatedTimelines;
return buf;
} catch (_e) { return null; }
};
const parseAndDeduplicatePlayerPositions = (buf: Uint8Array) => {
const idToName = new Map<number, string>();
const playerTimelines = new Map<number, ParsedPlayerPos[]>();
const readCoord = (arr: Uint8Array, offset: number): number => {
// Read a signed 24-bit coordinate in big-endian format
const raw = (arr[offset] << 16) | (arr[offset + 1] << 8) | arr[offset + 2];
// Sign-extend from 24 to 32 bits
const signed = (raw & 0x800000) ? (raw | 0xFF000000) : raw;
return signed;
};
let o = 0;
while (o < buf.length) {
const type = buf[o]; o += 1;
if (type === 0) {
if (o + 16 > buf.length) { break; }
o += 4; // t_sec epoch not used
const sec = (buf[o] << 24) | (buf[o + 1] << 16) | (buf[o + 2] << 8) | buf[o + 3];
o += 4;
const x = readCoord(buf, o); o += 3;
const y = readCoord(buf, o); o += 3;
const id = (buf[o] << 8) | buf[o + 1]; o += 2;
if (idToName.has(id)) {
const name = idToName.get(id)!;
if (!playerTimelines.has(id)) {
playerTimelines.set(id, []);
}
playerTimelines.get(id)!.push({ name, x, y, sec });
}
} else if (type === 1) {
if (o + 6 > buf.length) { break; }
o += 4; // t_ms
const id = (buf[o] << 8) | buf[o + 1]; o += 2;
const len = buf[o]; o += 1;
if (o + len > buf.length) { break; }
const name = new TextDecoder().decode(buf.subarray(o, o + len));
o += len;
idToName.set(id, name);
} else if (type === 2) {
if (o + 6 > buf.length) { break; }
o += 4; // t_ms
const id = (buf[o] << 8) | buf[o + 1]; o += 2;
// Player disconnect - mark end of timeline for this player
if (playerTimelines.has(id)) {
// Add a disconnect marker or handle as needed
}
} else {
break;
}
}
// Deduplicate consecutive identical positions for each player
const deduplicatedTimelines = new Map<number, ParsedPlayerPos[]>();
for (const [playerId, timeline] of playerTimelines) {
if (timeline.length === 0) { continue; }
const deduplicated: ParsedPlayerPos[] = [];
let lastPos = timeline[0];
deduplicated.push(lastPos);
for (let i = 1; i < timeline.length; i++) {
const currentPos = timeline[i];
// Check if position changed (with small tolerance for floating point)
const deltaX = Math.abs(currentPos.x - lastPos.x);
const deltaY = Math.abs(currentPos.y - lastPos.y);
if (deltaX > 4 || deltaY > 4) {
// Position changed significantly, keep this point
deduplicated.push(currentPos);
lastPos = currentPos;
}
// If position is the same, skip this point (deduplication)
}
// Always keep the last position to ensure timeline ends correctly
if (timeline.length > 1 && deduplicated[deduplicated.length - 1] !== timeline[timeline.length - 1]) {
deduplicated.push(timeline[timeline.length - 1]);
}
deduplicatedTimelines.set(playerId, deduplicated);
}
// Store deduplicated data in cache
deduplicatedPlayerPositions.current = deduplicatedTimelines;
};
const loadPlayerTimestamps = async (
allTicks: Set<number>
): Promise<number> => {