diff --git a/plugins/minimap/controller.ts b/plugins/minimap/controller.ts index 6c6e1351..054ce620 100644 --- a/plugins/minimap/controller.ts +++ b/plugins/minimap/controller.ts @@ -8,8 +8,10 @@ import { ChartTagDataEvent, ChartTagData, RecipeDataEvent, - RecipeData, GetRawRecipeTileRequest, + PlayerPositionEvent, + PlayerData, + GetPlayerPathRequest, } from "./messages"; import * as fs from "fs-extra"; import * as path from "path"; @@ -39,9 +41,18 @@ export class ControllerPlugin extends BaseControllerPlugin { private tilesPath: string = ""; private chartTagsPath: string = ""; private recipeTilesPath: string = ""; + private playerPositionsPath: string = ""; private chunkSavingQueue = new Map>(); private chartTagQueues = new Map(); private recipeSavingQueue = new Map(); + private playerPositionQueues = new Map(); + // Player session tracking per surface file + // fileKey -> playerName -> playerId + private playerSessions = new Map>(); + // fileKey -> nextId + private nextPlayerIds = new Map(); + // fileKey -> Set of active player names + private activePlayerSessions = new Map>(); // Keep per-tile dictionary of recipe -> uint16 index as well as next free index private recipeDictionaries = new Map>(); private nextRecipeIndex = new Map(); @@ -58,6 +69,7 @@ export class ControllerPlugin extends BaseControllerPlugin { private savingChartTags = new Set(); private saveInterval: NodeJS.Timeout | null = null; private saveRecipeTilesInProgress: boolean = false; + private savePlayerPositionsInProgress: boolean = false; async init() { this.tilesPath = path.resolve( @@ -72,9 +84,14 @@ export class ControllerPlugin extends BaseControllerPlugin { this.controller.config.get("controller.database_directory"), "minimap_recipe_tiles" ); + this.playerPositionsPath = path.resolve( + this.controller.config.get("controller.database_directory"), + "minimap_player_positions" + ); await fs.ensureDir(this.tilesPath); await fs.ensureDir(this.chartTagsPath); await fs.ensureDir(this.recipeTilesPath); + await fs.ensureDir(this.playerPositionsPath); this.controller.handle(TileDataEvent, this.handleTileDataEvent.bind(this)); this.controller.handle(ChartTagDataEvent, this.handleChartTagDataEvent.bind(this)); @@ -82,6 +99,8 @@ export class ControllerPlugin extends BaseControllerPlugin { this.controller.handle(GetChartTagsRequest, this.handleGetChartTagsRequest.bind(this)); this.controller.handle(RecipeDataEvent, this.handleRecipeDataEvent.bind(this)); this.controller.handle(GetRawRecipeTileRequest, this.handleGetRawRecipeTileRequest.bind(this)); + this.controller.handle(PlayerPositionEvent, this.handlePlayerPositionEvent.bind(this)); + this.controller.handle(GetPlayerPathRequest, this.handleGetPlayerPathRequest.bind(this)); // Set up HTTP routes for serving tiles this.setupTileRoutes(); @@ -95,6 +114,7 @@ export class ControllerPlugin extends BaseControllerPlugin { this.saveTiles().catch(err => this.logger.error(`Error saving tiles: ${err}`)); this.saveChartTags().catch(err => this.logger.error(`Error saving chart tags: ${err}`)); this.saveRecipeTiles().catch(err => this.logger.error(`Error saving recipe tiles: ${err}`)); + this.savePlayerPositions().catch(err => this.logger.error(`Error saving player positions: ${err}`)); }, 5000); } @@ -108,6 +128,7 @@ export class ControllerPlugin extends BaseControllerPlugin { await this.saveTiles(); await this.saveChartTags(); await this.saveRecipeTiles(); + await this.savePlayerPositions(); } // Compare two pixel arrays and return changes @@ -857,6 +878,81 @@ export class ControllerPlugin extends BaseControllerPlugin { } } + async savePlayerPositions() { + if (this.savePlayerPositionsInProgress) { + return; + } + + if (this.playerPositionQueues.size === 0) { + return; + } + + this.savePlayerPositionsInProgress = true; + const queue = this.playerPositionQueues; + this.playerPositionQueues = new Map(); + + try { + const promises: Promise[] = []; + for (const [fileKey, positions] of queue) { + const promise = (async () => { + const filePath = path.join(this.playerPositionsPath, `${fileKey}.positions`); + + // Convert position data to binary format + const buffers: Buffer[] = []; + + for (const position of positions) { + // Position record (type 0): 1 + 4 + 4 + 3 + 3 + 2 = 17 bytes + const buffer = Buffer.alloc(17); + let offset = 0; + + // Type (1 byte): 0 = Position + buffer.writeUInt8(0, offset); + offset += 1; + + // t_sec (4 bytes): Current timestamp in seconds since epoch + buffer.writeUInt32BE(Math.floor(Date.now() / 1000), offset); + offset += 4; + + // sec (4 bytes): Game seconds (already calculated in Lua) + buffer.writeUInt32BE(position.sec, offset); + offset += 4; + + // x_tiles (3 bytes): X coordinate as 24-bit signed integer + const x_int24 = Math.round(position.x) & 0xFFFFFF; + buffer.writeUInt8((x_int24 >> 16) & 0xFF, offset); + buffer.writeUInt8((x_int24 >> 8) & 0xFF, offset + 1); + buffer.writeUInt8(x_int24 & 0xFF, offset + 2); + offset += 3; + + // y_tiles (3 bytes): Y coordinate as 24-bit signed integer + const y_int24 = Math.round(position.y) & 0xFFFFFF; + buffer.writeUInt8((y_int24 >> 16) & 0xFF, offset); + buffer.writeUInt8((y_int24 >> 8) & 0xFF, offset + 1); + buffer.writeUInt8(y_int24 & 0xFF, offset + 2); + offset += 3; + + // player_id (2 bytes): Use the assigned player ID + const playerId = (position as any)._playerId || 0; + buffer.writeUInt16BE(playerId & 0xFFFF, offset); + + buffers.push(buffer); + } + + if (buffers.length > 0) { + const concatenated = Buffer.concat(buffers); + await fs.appendFile(filePath, concatenated); + } + })(); + promises.push(promise); + } + await Promise.all(promises); + } catch (err) { + this.logger.error(`Error saving player positions: ${err}`); + } finally { + this.savePlayerPositionsInProgress = false; + } + } + async handleRecipeDataEvent(event: RecipeDataEvent) { try { const { instance_id, recipe_data } = event; @@ -932,4 +1028,50 @@ export class ControllerPlugin extends BaseControllerPlugin { return { recipe_tile: null }; } } + + async handlePlayerPositionEvent(event: PlayerPositionEvent) { + try { + const { instance_id, player_data } = event; + + // Create file key based on instance and surface + const fileKey = `${instance_id}_${player_data.surface}`; + + // Initialize queue if it doesn't exist + if (!this.playerPositionQueues.has(fileKey)) { + this.playerPositionQueues.set(fileKey, []); + } + + // Queue for batch saving + this.playerPositionQueues.get(fileKey)!.push(player_data); + + // Send live update to connected web clients + const updateEvent = new PlayerPositionEvent(instance_id, player_data); + + // Broadcast to web clients + this.controller.sendTo(new lib.Address(lib.Address.broadcast, lib.Address.control), updateEvent); + + } catch (err) { + this.logger.error(`Error processing player position data: ${err}`); + } + } + + async handleGetPlayerPathRequest(request: GetPlayerPathRequest) { + const { instance_id, surface } = request; + + const fileName = `${instance_id}_${surface}.positions`; + const filePath = path.join(this.playerPositionsPath, fileName); + + if (!await fs.pathExists(filePath)) { + return { positions: null }; + } + + try { + const data = await fs.readFile(filePath); + const base64Data = data.toString("base64"); + return { positions: base64Data }; + } catch (err) { + this.logger.error(`Error reading player positions file: ${err}`); + return { positions: null }; + } + } } diff --git a/plugins/minimap/docs/player_position_tracking.md b/plugins/minimap/docs/player_position_tracking.md new file mode 100644 index 00000000..f6562a73 --- /dev/null +++ b/plugins/minimap/docs/player_position_tracking.md @@ -0,0 +1,111 @@ +# Player Position Tracking + +## Overview +Tracks player positions across the map, streams live movements to web clients, and stores compressed historical data for timeline playback. + +## Architecture + +### Event Flow +```mermaid +sequenceDiagram + participant Lua as Factorio Lua (minimap.lua) + participant Host as Host/Instance plugin (instance.ts) + participant Ctl as Controller plugin (controller.ts) + participant UI as Web clients + + Lua->>Host: ipc-minimap:player_position (JSON) + Host->>Ctl: PlayerPositionEvent + Ctl-->>UI: PlayerPositionEvent (broadcast) + UI->>Ctl: GetPlayerPathRequest (HTTP / cluster bus) + Ctl-->>UI: compressed path data (.positions) +``` + +### Messages +| Message | Direction | Purpose | +| ------------------------------- | --------------------------- | ----------------------- | +| `PlayerPositionEvent` | instance → controller → web | Live position update | +| `GetPlayerPathRequest/Response` | web → controller | Fetches historical path | + +## Storage Format + +### Coordinate Encoding +- **Map size**: 2m × 2m (2 000 000 × 2 000 000 tiles) +- **Resolution**: 1 tile +- **Encoding**: 24-bit signed integers per coordinate (±8,388,608 tiles) +- **Storage**: 3 bytes per coordinate (x_tiles, y_tiles) (24 bits) + +### Binary Record Format (append-only) + +| Bytes | Field | Description | +| -------------------- | -------------------- | ---------------------------------------------- | +| 1 | `type` | `0=Position`, `1=SessionStart`, `2=SessionEnd` | +| **Position (0)** | | | +| 4 | `t_sec` (uint32) | Seconds since epoch | +| 4 | `sec` (uint32) | Seconds since map started (ticks / 60⌋) | +| 3 | `x_tiles` (int24) | X coordinate in tiles | +| 3 | `y_tiles` (int24) | Y coordinate in tiles | +| 2 | `player_id` (uint16) | Player dictionary ID | +| **SessionStart (1)** | | | +| 4 | `t_ms` (uint32) | Session start time | +| 2 | `player_id` (uint16) | Player dictionary ID | +| 1 | `name_len` (uint8) | UTF-8 byte length of player name | +| n | `name` bytes | Player name string | +| **SessionEnd (2)** | | | +| 4 | `t_ms` (uint32) | Session end time | +| 2 | `player_id` (uint16) | Player dictionary ID | + +### File Organization +- Filename: `_.positions` +- Location: `database_directory/minimap_player_positions/` +- Format: Append-only binary records + +### Compression Strategy +1. **Dictionary compression**: Player names stored once per session +2. **Temporal compression**: Modified Ramer-Douglas-Peucker algorithm + - ε = 15cm maximum deviation + - Min Δt = 5s when stationary + - Linear interpolation for playback +3. **Coordinate packing**: 24-bit integers instead of 32-bit floats + +## Streaming Protocol + +### Live Updates +- **Trigger**: Position change ≥1 tile OR 250ms timeout + +### Message Format (WebSocket) +```typescript +interface PlayerPositionEvent { + playerId: string; + instanceId: string; + surface: string; + x: number; // tiles + y: number; // tiles + sec: number; // seconds since map started (⌊tick / 60⌋) +} +``` + +## Playback System + +### Live Mode +- Web UI maintains per-player tween buffer (last 2 points) +- Canvas interpolation every animation frame +- Smooth movement without server round-trips + +### Timeline Mode +- When opening the timeline, the client downloads the compressed path data for the player +- The client then interpolates the path for display + +## Implementation Notes + +### Saving Process +``` +1. Queue position updates in positionBuffer +2. Every 250ms, process queued positions: + - Check movement threshold + - Broadcast live updates + - Buffer for compression +3. After session end: + - Run RDP downsampling + - Write compressed records + - Update session metadata +``` diff --git a/plugins/minimap/index.ts b/plugins/minimap/index.ts index 615fdf93..c73abed2 100644 --- a/plugins/minimap/index.ts +++ b/plugins/minimap/index.ts @@ -3,8 +3,10 @@ import * as lib from "@clusterio/lib"; import { TileDataEvent, ChartTagDataEvent, + PlayerPositionEvent, GetRawTileRequest, GetChartTagsRequest, + GetPlayerPathRequest, RecipeDataEvent, GetRawRecipeTileRequest, } from "./messages"; @@ -28,9 +30,11 @@ export const plugin: lib.PluginDeclaration = { messages: [ TileDataEvent, ChartTagDataEvent, + PlayerPositionEvent, RecipeDataEvent, GetRawTileRequest, GetChartTagsRequest, + GetPlayerPathRequest, GetRawRecipeTileRequest, ], routes: ["/minimap"], diff --git a/plugins/minimap/instance.ts b/plugins/minimap/instance.ts index 990afc63..77fcdf73 100644 --- a/plugins/minimap/instance.ts +++ b/plugins/minimap/instance.ts @@ -1,6 +1,15 @@ import * as lib from "@clusterio/lib"; import { BaseInstancePlugin } from "@clusterio/host"; -import { TileDataEvent, ChartData, ChartTagDataEvent, ChartTagData, RecipeDataEvent, RecipeData } from "./messages"; +import { + TileDataEvent, + ChartData, + ChartTagDataEvent, + ChartTagData, + RecipeDataEvent, + RecipeData, + PlayerPositionEvent, + PlayerData, +} from "./messages"; interface TileDataIpc { type: "chart"; @@ -32,6 +41,14 @@ interface RecipeDataIpc { icon?: { type?: string, name?: string, quality?: string } } +interface PlayerPositionIpc { + player_name: string; + surface: string; + x: number; + y: number; + sec: number; +} + export class InstancePlugin extends BaseInstancePlugin { pendingTileUpdates = new Map>(); @@ -60,6 +77,13 @@ export class InstancePlugin extends BaseInstancePlugin { `Error handling recipe data from Lua:\n${err}` )); }); + + // Listen for player position data from Lua module + this.instance.server.on("ipc-minimap:player_position", (data: PlayerPositionIpc) => { + this.handlePlayerPositionFromLua(data).catch(err => this.logger.error( + `Error handling player position data from Lua:\n${err}` + )); + }); } async handleTileDataFromLua(data: TileDataIpc) { @@ -139,6 +163,33 @@ export class InstancePlugin extends BaseInstancePlugin { } } + async handlePlayerPositionFromLua(data: PlayerPositionIpc) { + try { + if (!data) { + this.logger.error("Invalid player position data received"); + return; + } + + const playerData: PlayerData = { + player_name: data.player_name, + surface: data.surface, + x: data.x, + y: data.y, + sec: data.sec, + }; + + const event = new PlayerPositionEvent( + this.instance.config.get("instance.id"), + playerData + ); + this.logger.info(`MM: got Lua pos for ${data.player_name} ` + + `(${data.x},${data.y}) – forwarding`); + this.instance.sendTo("controller", event); + } catch (err: unknown) { + this.logger.error(`Failed to process player position data from Lua: ${err}`); + } + } + async onStart() { // Initialize the Lua module when the instance starts try { diff --git a/plugins/minimap/messages.ts b/plugins/minimap/messages.ts index 03ce3d0c..85c0fbd1 100644 --- a/plugins/minimap/messages.ts +++ b/plugins/minimap/messages.ts @@ -46,6 +46,53 @@ export const RecipeDataSchema = Type.Object({ export type RecipeData = Static; +export const PlayerDataSchema = Type.Object({ + player_name: Type.String(), + surface: Type.String(), + x: Type.Number(), // tiles + y: Type.Number(), // tiles + sec: Type.Number(), // seconds since map started (⌊tick / 60⌋) +}); + +export type PlayerData = Static; + +/** + * PlayerPositionEvent: Used for both Factorio Instance -> Controller and Controller -> Web Clients + * + * Contains real-time player position data for live tracking and movement visualization. + * When sent from instance to controller: triggers storage in compressed binary format. + * When sent from controller to web clients: enables real-time player position updates. + * + * Data flow: + * - Lua module -> Instance plugin -> Controller plugin (for persistence) + * - Controller plugin -> Web UI plugin -> Canvas renderer (for live updates) + */ +export class PlayerPositionEvent { + declare ["constructor"]: typeof PlayerPositionEvent; + static type = "event" as const; + static src = ["instance", "controller"] as const; + static dst = ["controller", "control"] as const; + static plugin = "minimap" as const; + static permission = null; + + constructor( + public instance_id: number, + public player_data: PlayerData, + ) { } + + static jsonSchema = Type.Object({ + "instance_id": Type.Number(), + "player_data": PlayerDataSchema, + }); + + static fromJSON(json: Static) { + return new this( + json.instance_id, + json.player_data + ); + } +} + /** * TileDataEvent: Used for both Factorio Instance -> Controller and Controller -> Web Clients * @@ -144,9 +191,9 @@ export class RecipeDataEvent { static permission = null; constructor( - public instance_id: number, - public recipe_data: RecipeData, - ) {} + public instance_id: number, + public recipe_data: RecipeData, + ) { } static jsonSchema = Type.Object({ "instance_id": Type.Number(), @@ -257,13 +304,13 @@ export class GetRawRecipeTileRequest { static permission = "minimap.view"; constructor( - public instance_id: number, - public surface: string, - public force: string, - public tile_x: number, - public tile_y: number, - public tick?: number, - ) {} + public instance_id: number, + public surface: string, + public force: string, + public tile_x: number, + public tile_y: number, + public tick?: number, + ) { } static jsonSchema = Type.Object({ "instance_id": Type.Number(), @@ -289,3 +336,42 @@ export class GetRawRecipeTileRequest { "recipe_tile": Type.Union([Type.String(), Type.Null()]), })); } + +/** + * GetPlayerPathRequest: Request compressed historical player path data from controller storage + * + * Returns binary compressed player position data for timeline playback. + * Used to load player movement history when opening timeline or specific player tracking. + */ +export class GetPlayerPathRequest { + declare ["constructor"]: typeof GetPlayerPathRequest; + static type = "request" as const; + static src = "control" as const; + static dst = "controller" as const; + static plugin = "minimap" as const; + static permission = "minimap.view"; + + constructor( + public instance_id: number, + public player_name: string, + public surface: string, + ) { } + + static jsonSchema = Type.Object({ + "instance_id": Type.Number(), + "player_name": Type.String(), + "surface": Type.String(), + }); + + static fromJSON(json: Static) { + return new this( + json.instance_id, + json.player_name, + json.surface, + ); + } + + static Response = lib.plainJson(Type.Object({ + "positions": Type.Optional(Type.String()), + })); +} diff --git a/plugins/minimap/module/minimap.lua b/plugins/minimap/module/minimap.lua index f95dde32..2a3095c7 100644 --- a/plugins/minimap/module/minimap.lua +++ b/plugins/minimap/module/minimap.lua @@ -36,6 +36,105 @@ local function send_recipe_data(recipe_data) clusterio_api.send_json("minimap:recipe_data", recipe_data) end +-- Send player position data to plugin +local function send_player_position_data(player_data) + clusterio_api.send_json("minimap:player_position", player_data) +end + +-- Check if a position has moved significantly (≥1 tile) +local function has_moved_significantly(old_pos, new_pos) + if not old_pos then + return true + end + local dx = math.abs(new_pos.x - old_pos.x) + local dy = math.abs(new_pos.y - old_pos.y) + return dx >= 1 or dy >= 1 +end + +-- Process and send player position updates +local function process_player_positions() + if not storage.minimap or not storage.minimap.enabled then + return + end + + for _, player in pairs(game.connected_players) do + if player.valid and player.character and player.character.valid then + local surface = player.character.surface + local position = player.character.position + local player_key = player.index + + -- Initialize player tracking data if needed + if not storage.minimap.player_positions then + storage.minimap.player_positions = {} + end + + local last_position = storage.minimap.player_positions[player_key] + local current_sec = math.floor(game.tick / 60) + + -- Check if position changed significantly or timeout elapsed + local should_update = false + if has_moved_significantly(last_position and last_position.position or nil, position) then + should_update = true + elseif last_position and (current_sec - last_position.last_update_sec) >= 5 then + -- Send update every 5 seconds even if not moving (for timeout) + should_update = true + elseif not last_position then + -- First position for this player + should_update = true + end + + if should_update then + local player_data = { + player_name = player.name, + surface = surface.name, + x = position.x, + y = position.y, + sec = current_sec, + } + + send_player_position_data(player_data) + + -- Update tracking data + storage.minimap.player_positions[player_key] = { + position = { x = position.x, y = position.y }, + last_update_sec = current_sec, + } + end + end + end +end + +-- Handle player session start +local function on_player_joined_game(event) + if not storage.minimap or not storage.minimap.enabled then + return + end + + local player = game.get_player(event.player_index) + if not player or not player.valid then + return + end + + -- Clear any existing position data for this player to ensure fresh tracking + if storage.minimap.player_positions then + storage.minimap.player_positions[player.index] = nil + end + + -- The next position update will be sent when the player moves or in process_player_positions +end + +-- Handle player session end +local function on_player_left_game(event) + if not storage.minimap or not storage.minimap.enabled then + return + end + + -- Clean up position tracking data + if storage.minimap.player_positions then + storage.minimap.player_positions[event.player_index] = nil + end +end + -- Helper for creating and sending recipe start/stop events local function update_entity_recipe(entity) if not entity or not entity.valid then @@ -327,7 +426,8 @@ local function init() storage.minimap = { enabled = true, chunk_update_queue = {}, - recipe_cache = {} + recipe_cache = {}, + player_positions = {} } end @@ -419,6 +519,11 @@ local function on_tick(event) local chunk_position = table.remove(storage.minimap.chunk_update_queue, 1) dump_chunk_chart(chunk_position) end + + -- Process player position updates every 250ms (15 ticks at 60 UPS) + if game.tick % 15 == 0 then + process_player_positions() + end end -- Export functions @@ -443,5 +548,7 @@ minimap.events[defines.events.on_chart_tag_modified] = on_chart_tag_modified minimap.events[defines.events.on_chart_tag_removed] = on_chart_tag_removed minimap.events[defines.events.on_entity_settings_pasted] = on_entity_settings_pasted minimap.events[defines.events.on_gui_closed] = on_gui_closed +minimap.events[defines.events.on_player_joined_game] = on_player_joined_game +minimap.events[defines.events.on_player_left_game] = on_player_left_game return minimap diff --git a/plugins/minimap/web/CanvasMinimapPage.tsx b/plugins/minimap/web/CanvasMinimapPage.tsx index 95b3d49f..33383810 100644 --- a/plugins/minimap/web/CanvasMinimapPage.tsx +++ b/plugins/minimap/web/CanvasMinimapPage.tsx @@ -5,11 +5,14 @@ import { GetRawTileRequest, GetChartTagsRequest, GetRawRecipeTileRequest, + GetPlayerPathRequest, TileDataEvent, ChartTagData, ChartTagDataEvent, SignalID, RecipeDataEvent, + PlayerPositionEvent, + PlayerData, } from "../messages"; import * as zlib from "zlib"; import { @@ -60,6 +63,8 @@ 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) const CHUNKS_PER_TILE = 8; @@ -124,6 +129,10 @@ export default function CanvasMinimapPage() { const [rawChartTags, setRawChartTags] = useState([]); const [mergedChartTags, setMergedChartTags] = useState([]); + // Player position states + const [showPlayerPositions, setShowPlayerPositions] = useState(true); + const [playerPositions, setPlayerPositions] = useState>(new Map()); + // Display state for zoom level (throttled updates to avoid excessive re-renders) const [displayZoom, setDisplayZoom] = useState(1); @@ -150,10 +159,14 @@ export default function CanvasMinimapPage() { const tileStateCache = useRef>(new Map()); const chunkCache = useRef>(new Map()); const loadingTiles = useRef>>(new Map()); + const playerPathBufRef = useRef(null); // Cache for recipes currently active const recipeCache = useRef>(new Map()); + // Cache for deduplicated player positions + const deduplicatedPlayerPositions = useRef>(new Map()); + // Animation frame ref const animationFrameRef = useRef(); @@ -170,8 +183,10 @@ export default function CanvasMinimapPage() { availableTicks: [] as number[], showChartTags: true, showRecipes: true, + showPlayerPositions: true, mergedChartTags: [] as MergedChartTag[], chartTagTimestamps: 0, + playerPositions: new Map(playerPositions), }); const control = useContext(ControlContext); @@ -361,8 +376,10 @@ export default function CanvasMinimapPage() { availableTicks, showChartTags, showRecipes, + showPlayerPositions, mergedChartTags, chartTagTimestamps, + playerPositions: new Map(currentStateRef.current.playerPositions), }; }, [ selectedInstance, @@ -372,9 +389,11 @@ export default function CanvasMinimapPage() { currentTick, availableTicks, showChartTags, + showRecipes, + showPlayerPositions, mergedChartTags, chartTagTimestamps, - showRecipes, + playerPositions, ]); // Define pixel-perfect zoom levels to eliminate seaming issues @@ -559,6 +578,139 @@ export default function CanvasMinimapPage() { return recipeTicksAdded; }; + const fetchPlayerPathBuffer = async (): Promise => { + if (!selectedInstance) { return null; } + if (playerPathBufRef.current) { return playerPathBufRef.current; } + try { + const resp = await control.send(new GetPlayerPathRequest( + selectedInstance, + "*", + selectedSurface, + )); + if (!resp.positions) { return null; } + const binStr = atob(resp.positions); + const buf = new Uint8Array(binStr.length); + for (let i=0; i { + 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 => { + if (!selectedInstance) { return 0; } + let added = 0; + const buf = await fetchPlayerPathBuffer(); + if (buf) { + // Extract ticks from deduplicated positions instead of raw buffer + for (const timeline of deduplicatedPlayerPositions.current.values()) { + for (const pos of timeline) { + const tick = pos.sec * 60; + if (!allTicks.has(tick)) { + allTicks.add(tick); + added += 1; + } + } + } + } + return added; + }; + // Timelapse functions const loadAvailableTicks = async () => { if (!selectedInstance) { @@ -582,6 +734,9 @@ export default function CanvasMinimapPage() { // Load recipe timestamps await loadRecipeTimestamps(leftTile, topTile, rightTile, bottomTile, allTicks); + // Load player position timestamps + await loadPlayerTimestamps(allTicks); + const sortedTicks = Array.from(allTicks).sort((a, b) => a - b); setAvailableTicks(sortedTicks); @@ -612,6 +767,7 @@ export default function CanvasMinimapPage() { chunkCache.current.clear(); loadingTiles.current.clear(); recipeCache.current.clear(); + setPlayerPositions(new Map()); }; const stepToTick = async (tickIndex: number) => { @@ -694,6 +850,9 @@ export default function CanvasMinimapPage() { // Wait for all tile updates to complete await Promise.all(updatePromises); + // update player positions for this tick + await updatePlayerPositionsForTick(newTick); + setCurrentTick(newTick); } }; @@ -1493,6 +1652,67 @@ export default function CanvasMinimapPage() { } ctx.restore(); } + + // Render player positions + if (currentState.showPlayerPositions) { + ctx.save(); + ctx.textAlign = "center"; + ctx.textBaseline = "bottom"; + ctx.font = `${Math.max(10, 12 / scale)}px Arial`; + + for (const [playerKey, playerData] of currentState.playerPositions) { + // Convert world coordinates to screen coordinates + const screenX = Math.round((playerData.x - worldLeft) * scale); + const screenY = Math.round((playerData.y - worldTop) * scale); + + // Skip if player is outside visible area + if (screenX < -50 || screenX > width + 50 || screenY < -50 || screenY > height + 50) { + continue; + } + + // Draw player dot + const playerSize = Math.min(4, Math.max(2, 6 * scale)); + ctx.fillStyle = "#FF6B6B"; // Red color for players + ctx.beginPath(); + ctx.arc(screenX, screenY, playerSize, 0, 2 * Math.PI); + ctx.fill(); + + // Draw white border + ctx.strokeStyle = "#FFFFFF"; + ctx.lineWidth = 2; + ctx.stroke(); + + // Draw player name if zoom level is high enough + if (scale > 0.75 && playerData.player_name) { + const fontSize = Math.max(10, 12 / scale); + ctx.font = `${fontSize}px Arial`; + + // Position text above the player dot + const textX = screenX; + const textY = screenY - playerSize - 4; // 4px padding above dot + + // Draw text background + const textMetrics = ctx.measureText(playerData.player_name); + const textWidth = textMetrics.width; + const textHeight = fontSize; + const textPadding = 2; + + ctx.fillStyle = "rgba(0, 0, 0, 0.7)"; + ctx.fillRect( + textX - textWidth / 2 - textPadding, + textY - textHeight - textPadding, + textWidth + textPadding * 2, + textHeight + textPadding * 2 + ); + + // Draw text + ctx.fillStyle = "#FFFFFF"; + ctx.fillText(playerData.player_name, textX, textY); + } + } + + ctx.restore(); + } }; // Handle canvas resize (one-time setup) @@ -1567,9 +1787,21 @@ export default function CanvasMinimapPage() { chunkCache.current.clear(); loadingTiles.current.clear(); recipeCache.current.clear(); + playerPathBufRef.current = null; + deduplicatedPlayerPositions.current.clear(); + setPlayerPositions(new Map()); } }, [selectedInstance]); + // Clear player cache when surface changes + useEffect(() => { + if (selectedSurface) { + playerPathBufRef.current = null; + deduplicatedPlayerPositions.current.clear(); + setPlayerPositions(new Map()); + } + }, [selectedSurface]); + // Reload ticks when instance, surface, force changes or timelapse mode toggles useEffect(() => { if (isTimelapseMode && selectedInstance && selectedSurface && selectedForce) { @@ -1592,6 +1824,7 @@ export default function CanvasMinimapPage() { } else { // Clear chart tags if no valid selection setRawChartTags([]); + setPlayerPositions(new Map()); } }, [selectedInstance, selectedSurface, selectedForce]); @@ -1706,13 +1939,36 @@ export default function CanvasMinimapPage() { } }; + const handlePlayerPositionUpdate = (event: PlayerPositionEvent) => { + const currentState = currentStateRef.current; + // Ignore live updates during timelapse playback – the timeline renderer + // will supply historical positions instead. + if (currentState.isTimelapseMode) { return; } + + // Only process updates for the currently selected instance/surface + if (event.instance_id === currentState.selectedInstance + && event.player_data.surface === currentState.selectedSurface) { + setPlayerPositions(prevPositions => { + const newPositions = new Map(prevPositions); + const playerKey = `${event.player_data.player_name}_${event.instance_id}`; + newPositions.set(playerKey, event.player_data); + + // Immediately expose to render loop + currentStateRef.current.playerPositions = newPositions; + return newPositions; + }); + } + }; + // Subscribe to update events through the plugin plugin.onTileUpdate(handleChunkUpdate); plugin.onChartTagUpdate(handleChartTagUpdate); + plugin.onPlayerPositionUpdate(handlePlayerPositionUpdate); return () => { plugin.offTileUpdate(handleChunkUpdate); plugin.offChartTagUpdate(handleChartTagUpdate); + plugin.offPlayerPositionUpdate(handlePlayerPositionUpdate); }; }, [updateChunk, control]); @@ -1829,6 +2085,57 @@ export default function CanvasMinimapPage() { } }, [currentTick, isTimelapseMode, showRecipes]); + const updatePlayerPositionsForTick = async (tick: number) => { + const buf = await fetchPlayerPathBuffer(); + if (!buf) { setPlayerPositions(new Map()); return; } + const secTarget = Math.floor(tick / 60); + const parsed = parsePositionsAtTick(buf, secTarget); + const map = new Map(); + for (const p of parsed) { + map.set(`${p.name}_${selectedInstance ?? 0}`, { + player_name: p.name, + surface: selectedSurface, + x: p.x, + y: p.y, + sec: p.sec, + }); + } + setPlayerPositions(map); + currentStateRef.current.playerPositions = map; + }; + + // Function to parse positions at a specific tick using deduplicated cache + const parsePositionsAtTick = (buf: Uint8Array, targetSec: number): ParsedPlayerPos[] => { + // Use deduplicated cached data instead of parsing from scratch + const result: ParsedPlayerPos[] = []; + + for (const timeline of deduplicatedPlayerPositions.current.values()) { + // Find the most recent position for this player at or before targetSec + let mostRecentPos: ParsedPlayerPos | null = null; + + for (const pos of timeline) { + if (pos.sec <= targetSec) { + mostRecentPos = pos; + } else { + break; // Timeline is sorted by sec, so we can break early + } + } + + if (mostRecentPos) { + result.push(mostRecentPos); + } + } + + return result; + }; + + // Load player positions when timelapse tick changes + useEffect(() => { + if (isTimelapseMode && currentTick !== null) { + updatePlayerPositionsForTick(currentTick); + } + }, [isTimelapseMode, currentTick]); + return (
@@ -1905,6 +2212,13 @@ export default function CanvasMinimapPage() { {recipeCache.current.size} active )} + + Players: + + {showPlayerPositions && ( + {playerPositions.size} online + )} + } diff --git a/plugins/minimap/web/index.tsx b/plugins/minimap/web/index.tsx index 40bada25..d6f35b8d 100644 --- a/plugins/minimap/web/index.tsx +++ b/plugins/minimap/web/index.tsx @@ -1,12 +1,13 @@ import React from "react"; import { BaseWebPlugin } from "@clusterio/web_ui"; import CanvasMinimapPage from "./CanvasMinimapPage"; -import { TileDataEvent, ChartTagDataEvent, RecipeDataEvent } from "../messages"; +import { TileDataEvent, ChartTagDataEvent, RecipeDataEvent, PlayerPositionEvent } from "../messages"; export class WebPlugin extends BaseWebPlugin { tileUpdateCallbacks: Array<(event: TileDataEvent) => void> = []; chartTagUpdateCallbacks: Array<(event: ChartTagDataEvent) => void> = []; recipeUpdateCallbacks: Array<(event: RecipeDataEvent) => void> = []; + playerPositionUpdateCallbacks: Array<(event: PlayerPositionEvent) => void> = []; async init() { this.pages = [ @@ -22,6 +23,7 @@ export class WebPlugin extends BaseWebPlugin { this.control.handle(TileDataEvent, this.handleTileDataEvent.bind(this)); this.control.handle(ChartTagDataEvent, this.handleChartTagDataEvent.bind(this)); this.control.handle(RecipeDataEvent, this.handleRecipeDataEvent.bind(this)); + this.control.handle(PlayerPositionEvent, this.handlePlayerPositionEvent.bind(this)); } async handleTileDataEvent(event: TileDataEvent) { @@ -32,7 +34,6 @@ export class WebPlugin extends BaseWebPlugin { } async handleChartTagDataEvent(event: ChartTagDataEvent) { - // Notify all registered callbacks for (let callback of this.chartTagUpdateCallbacks) { callback(event); } @@ -44,6 +45,12 @@ export class WebPlugin extends BaseWebPlugin { } } + async handlePlayerPositionEvent(event: PlayerPositionEvent) { + for (let callback of this.playerPositionUpdateCallbacks) { + callback(event); + } + } + onTileUpdate(callback: (event: TileDataEvent) => void) { this.tileUpdateCallbacks.push(callback); } @@ -79,4 +86,16 @@ export class WebPlugin extends BaseWebPlugin { } this.recipeUpdateCallbacks.splice(index, 1); } + + onPlayerPositionUpdate(callback: (event: PlayerPositionEvent) => void) { + this.playerPositionUpdateCallbacks.push(callback); + } + + offPlayerPositionUpdate(callback: (event: PlayerPositionEvent) => void) { + const index = this.playerPositionUpdateCallbacks.lastIndexOf(callback); + if (index === -1) { + throw new Error("callback is not registered"); + } + this.playerPositionUpdateCallbacks.splice(index, 1); + } }