diff --git a/plugins/minimap/controller.ts b/plugins/minimap/controller.ts index 1cc95d89..575f65be 100644 --- a/plugins/minimap/controller.ts +++ b/plugins/minimap/controller.ts @@ -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}`); + } + } } diff --git a/plugins/minimap/tsconfig.browser.json b/plugins/minimap/tsconfig.browser.json index 6d6bf586..96d1c770 100644 --- a/plugins/minimap/tsconfig.browser.json +++ b/plugins/minimap/tsconfig.browser.json @@ -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/**/*" diff --git a/plugins/minimap/utils/parsing-utils.ts b/plugins/minimap/utils/parsing-utils.ts new file mode 100644 index 00000000..ca604744 --- /dev/null +++ b/plugins/minimap/utils/parsing-utils.ts @@ -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); +} diff --git a/plugins/minimap/utils/player-utils.ts b/plugins/minimap/utils/player-utils.ts new file mode 100644 index 00000000..d99f25dd --- /dev/null +++ b/plugins/minimap/utils/player-utils.ts @@ -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; + /** 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(); + 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 { + // 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(); + + // 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(); + + 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; +} diff --git a/plugins/minimap/tile-utils.ts b/plugins/minimap/utils/tile-utils.ts similarity index 94% rename from plugins/minimap/tile-utils.ts rename to plugins/minimap/utils/tile-utils.ts index 7e6f22e5..b41f5b93 100644 --- a/plugins/minimap/tile-utils.ts +++ b/plugins/minimap/utils/tile-utils.ts @@ -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 { if (isNode) { diff --git a/plugins/minimap/web/CanvasMinimapPage.tsx b/plugins/minimap/web/CanvasMinimapPage.tsx index 117b4c17..5f7a1698 100644 --- a/plugins/minimap/web/CanvasMinimapPage.tsx +++ b/plugins/minimap/web/CanvasMinimapPage.tsx @@ -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(); - const playerTimelines = new Map(); - - 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(); - - 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 ): Promise => {