Compare commits

...
Author SHA1 Message Date
Danielv123 5ddf3fe34f fix(minimap): restore nested previous_recipe name access
Furnace previous_recipe ids are nested under prev.name.name in this context. Restore the original access path and leave an inline maintainer note to prevent repeat regressions.
2026-02-21 19:49:59 +01:00
Danielv123 17480aafff fix(minimap): clarify dual filter API pattern in data source
Dual filter API methods (setInstanceSurfaceFilters and setInstanceSurfaceFilter) both exist for backward compatibility with different plugin implementations. Added comment explaining why both are checked in fallback pattern.
2026-02-21 17:16:06 +01:00
Danielv123 387a9425ce refactor(minimap): deduplicate tile parsing and parallelize file cleanup 2026-02-21 17:11:33 +01:00
Danielv123 550ea22724 fix(minimap): improve error handling and performance in tile parsing
- Add warning logging in renderChunkToPixels catch block before fallback
- Switch maxTick filtering from skip/continue to early break (append-only optimization)
- Use existsSync for directory existence check in loadExistingTagContent
2026-02-21 17:02:50 +01:00
Danielv123 9375c462af fix(minimap): use existing player_position channel for join start event
Replace unsupported minimap:player_session_start IPC with existing minimap:player_position channel to emit immediate session start on join.
2026-02-21 16:49:03 +01:00
Danielv123 e28b4121e3 fix(minimap): use physical position and emit session start on join
- Use player.physical_surface/physical_position for accurate tracking when in vehicles/remotes
- Emit session start record immediately on on_player_joined_game
- Make storage.minimap initialization consistent (init() ensures all fields exist)
2026-02-21 16:46:32 +01:00
Danielv123 a1e8d009ff fix(minimap): restore chart tag interval semantics after task 3
Task 3 inadvertently changed chart tag data schema from start_tick/end_tick
interval model to single tick field. This restores the original interval
semantics for chart tags while preserving legitimate Task 3 fixes.

Reverted:
- create_chart_tag_data(tick) -> create_tag_data(start_tick, end_tick)
- Single-emission in on_chart_tag_modified -> dual-emission (end old, start new)
- All chart tag event handlers use interval model again

Preserved from Task 3:
- prev.name.name fix (both locations)
- Hot-loop local aliases (math_floor, bit32_rshift, table_insert)
- IIFE removal for recipe icon lookup
- Recipe data single tick field (within Task 3 scope)
2026-02-21 16:43:31 +01:00
Danielv123 7c6dd1ff66 fix(minimap): fix prev.name bug and lua code quality improvements
- Fix prev.name.name bug: previous_recipe.name is RecipeID, not nested
- Add hot-loop local references (math_floor, bit32_rshift, table_insert)
- Remove IIFE pattern for icon lookup, use inline logic
- Rename create_tag_data to create_chart_tag_data for consistency
- Use recipe nilness + single tick field instead of start_tick/end_tick split
2026-02-21 16:40:31 +01:00
Danielv123 bb74b615a7 refactor(minimap): tighten typed parse helper returns for player records 2026-02-21 16:34:40 +01:00
Danielv123 c3136532b8 refactor(minimap): use discriminated union for PlayerPositionRecord and extract readSignedInt24 2026-02-21 16:31:20 +01:00
Danielv123 245ceb14fa security(minimap): fix path traversal in controller file loading 2026-02-21 16:25:11 +01:00
Danielv123 5ff7d7ee42 minimap: fix debounced zoom cleanup 2026-02-09 19:19:48 +01:00
Danielv123 35575a93a3 minimap: remove redundant instance RCON init 2026-02-09 19:13:24 +01:00
Danielv123 57b50048bc minimap: address small review feedback 2026-02-09 18:54:55 +01:00
Danielv123 923316b44d Order player session records by receive time 2026-02-05 22:45:20 +01:00
Danielv123 81fcea8398 Revert keyToLabels label parsing 2026-02-05 22:38:17 +01:00
Danielv123 3f8ef85987 Harden minimap tile storage paths 2026-02-05 22:38:13 +01:00
Danielv123 78fc90d7a0 Align minimap plugin package metadata 2026-02-05 22:37:56 +01:00
Danielv123 ebbc14ba71 Use type-only import for InputPartialVersion props 2026-02-05 22:37:56 +01:00
Danielv123 3be1d637c8 Validate tile paths 2026-02-05 21:55:05 +01:00
Danielv123 3d9c682d7c Fix surfaces with underscore in the name 2026-02-03 21:51:53 +01:00
Danielv123 b0fd90d273 Extend minimap components to allow external use 2026-01-29 23:18:26 +01:00
Danielv123 73ab8fc4f3 Player session end tracking 2026-01-29 21:51:19 +01:00
Danielv123 2eedddf17f Don't break version selector 2026-01-28 09:06:52 +01:00
Danielv123 9061d2aa92 Stable label creation, forgot imports of messages 2026-01-28 00:15:41 +01:00
Danielv123 3932ac2cae Merge branch 'plugin-minimap' of https://github.com/clusterio/clusterio into plugin-minimap 2026-01-28 00:08:10 +01:00
Danielv123 107201e6e5 Add antd icons to minimap 2026-01-28 00:07:42 +01:00
Danielv123 a7ab63ba3c Add antd icons to minimap 2026-01-28 00:02:50 +01:00
Danielv123 ec0c068d44 Add button to delete minimap data 2026-01-27 23:49:25 +01:00
Danielv123 264a32431e Fix react errors in factorio version selector 2026-01-27 23:12:15 +01:00
Danielv123 01f6397983 Fix circular imort breaking web UI 2026-01-27 20:37:01 +01:00
Danielv123 979d961618 Merge remote-tracking branch 'origin/master' into plugin-minimap 2026-01-27 17:45:23 +01:00
Danielv123 38d5fdfdba Use filtered subscriptions to save data 2025-08-10 00:28:48 +02:00
Danielv123 ea144863f9 Fix incorrect type in player path request response 2025-08-10 00:28:48 +02:00
Danielv123 48437a5f6c Use SubscriptionController 2025-08-10 00:28:48 +02:00
Danielv123 70ef8a60ad Fix lint, use of sync method in web UI 2025-08-10 00:28:48 +02:00
Danielv123 c4d4cb3afd Remove unused filter 2025-08-10 00:28:48 +02:00
Danielv123 093b71045b Move player position parsing to shared util, fix player ID persistence 2025-08-10 00:28:48 +02:00
Danielv123 a36e7bddca Fix player ID assignments 2025-08-10 00:28:48 +02:00
Danielv123 3737659cc3 Remove extraneous logging 2025-08-10 00:28:48 +02:00
Danielv123 5ba6056848 Don't load Lua in 1.1 2025-08-10 00:28:48 +02:00
Danielv123 89b6d5aa13 Attempt to fix tests 2025-08-10 00:28:48 +02:00
Danielv123 630a0d3898 Clean up UI, fix focus issues 2025-08-10 00:28:48 +02:00
Danielv123 047d3c22f5 Add player position tracking 2025-08-10 00:28:48 +02:00
Danielv123 57f89de746 Fix pixel parsing offset error 2025-08-10 00:28:48 +02:00
Danielv123 14f69cda11 Fix pixel change record bounds check
From cursor review: Insufficient bounds checking
for pixel change records (type 2) in renderTileToPixels,
extractAvailableTicks, and parseTileData. The code
checks for 6 bytes of header data but reads 10 bytes
(tick, pixelCount, newColor, oldColor), potentially
leading to reading out of bounds. The check should
be offset + 10 > tileData.length.
2025-08-10 00:28:48 +02:00
Danielv123 2bce506d96 Lint and refactor 2025-08-10 00:28:48 +02:00
Danielv123 ae7a1744ad Further compress tile pixel delta data
7 bytes header + 6 bytes per pixel -> 11 byte header + 2 bytes per pixel
2025-08-10 00:28:48 +02:00
Danielv123 ab8321ff4b Document tile data storage 2025-08-10 00:28:48 +02:00
Danielv123 c5c26bfad1 Use binary storage for recipe overlay for 8x space reduction 2025-08-10 00:28:48 +02:00
Danielv123 1c06611cca Timeline support for recipe overlay 2025-08-10 00:28:48 +02:00
Danielv123 7db716eceb Fix recipe data deduplication 2025-08-10 00:28:48 +02:00
Danielv123 3b7fd164af Implement recipe overlay 2025-08-10 00:28:48 +02:00
Danielv123 cf2efb1380 Fix deleting and modifying tags, tags on timeline 2025-08-10 00:28:48 +02:00
Danielv123 fc466ec799 Add chart tag support
Also fixes react state management issues, moving a few things over to useRef and showing all instances instead of using a custom solution
2025-08-10 00:28:48 +02:00
Danielv123 f5aeaff256 Optimize canvas to render independent of react 2025-08-10 00:28:48 +02:00
Danielv123 6ce4d0d20b F and G as timeline navigation shortcuts 2025-08-10 00:28:48 +02:00
Danielv123 0785798b7d Incremental rendering of timeline scrubbing 2025-08-10 00:28:48 +02:00
Danielv123 9dc5f8ff37 Working timelapse rendering functionality 2025-08-10 00:28:48 +02:00
Danielv123 be21f22707 Add timeline selection components and track tick on changes 2025-08-10 00:28:48 +02:00
Danielv123 0d294479b3 Use same parsing utilities on backend and frontend 2025-08-10 00:28:48 +02:00
Danielv123 626188dd16 Calculate and store deltas for changes 2025-08-10 00:28:48 +02:00
Danielv123 a516b6b129 Implement new tile storage format with tentative timelapse support 2025-08-10 00:28:48 +02:00
Danielv123 2dcc92c96b Document new data flow and tile storage format 2025-08-10 00:28:48 +02:00
Danielv123 015fd2c30b Add readme 2025-08-10 00:28:48 +02:00
Danielv123 3f06bd0993 Smooth keyboard movement 2025-08-10 00:28:48 +02:00
Danielv123 cfc1b7687e Allow click and drag to pan 2025-08-10 00:28:48 +02:00
Danielv123 285254e3bc Fix issues with black seams from incorrect scaling math 2025-08-10 00:28:48 +02:00
Danielv123 b70c7bd84f Fix negative coordinates when drawing updated chunks 2025-08-10 00:28:48 +02:00
Danielv123 3bf7a84de4 Switch to custom canvas based map renderer 2025-08-10 00:28:48 +02:00
Danielv123 063e622721 Render map tiles as pixelated 2025-08-10 00:28:48 +02:00
Danielv123 5b9f650946 Use encode_string and support multiple instances 2025-08-10 00:28:48 +02:00
Danielv123 c62eae0009 Improve webUI loading speed with 512x512 tiles, still corrupted looking 2025-08-10 00:28:48 +02:00
Danielv123 e9a4627118 Cleanup, web interface now shows images (but corrupted) 2025-08-10 00:28:48 +02:00
Danielv123 6cbd3229f6 Use new chunk charting API 2025-08-10 00:28:48 +02:00
Danielv123 f7658e1118 Create minimap core plugin based on gridworld plugin 2025-08-10 00:28:48 +02:00
Danielv123 f31fb535df Add tests for subscription filters 2025-08-09 23:57:13 +02:00
Danielv123 75b2dac371 Add filtered subscriptions 2025-08-09 23:44:01 +02:00
31 changed files with 7815 additions and 54 deletions

No files matched your search

+1 -1
View File
@@ -1,5 +1,5 @@
import { Static, Type } from "@sinclair/typebox";
import { FullVersionSchema, integerFullVersion, isFullVersion } from "../data";
import { FullVersionSchema, integerFullVersion, isFullVersion } from "../data/version";
const ARCHIVE_URL = "https://factorio.com/download/archive";
+3
View File
@@ -275,6 +275,9 @@ function keyToLabels(key: string) {
if (key !== "") {
for (let pair of key.split(",")) {
let [name, value] = pair.split("=", 2);
if (!name || value === undefined) {
continue;
}
labels.set(name, value
.slice(1, -1)
.replace(/\\"/g, "\"")
-1
View File
@@ -195,7 +195,6 @@ export class SubscriptionController {
throw new Error(`Event ${event.eventName} is not a registered as subscribable`);
}
if (event.subscribe === false) {
// Unsubscribe logic
const subscriber = eventData.subscriptions.find(sub => sub.dst.addressedTo(src));
if (!subscriber) {
return false;
@@ -2,7 +2,7 @@ import React, { useContext, useEffect, useState } from "react";
import { TreeSelect } from "antd";
import * as lib from "@clusterio/lib";
import { InputComponentProps } from "../BaseWebPlugin";
import type { InputComponentProps } from "../BaseWebPlugin";
import { useAccount } from "../model/account";
import ControlContext from "./ControlContext";
@@ -18,15 +18,16 @@ export default function InputPartialVersion (
const [versions, setVersions] = useState<readonly lib.PartialVersion[]>(lib.ApiVersions);
const control = useContext(ControlContext);
const account = useAccount();
const hasPermission = account.hasPermission("core.external.get_factorio_versions");
useEffect(() => {
(async () => {
if (account.hasPermission("core.external.get_factorio_versions")) {
if (hasPermission) {
const res = await control.factorioVersions.get(5 * 60 * 1000);
setVersions(res.map(v => v.version));
}
})();
}, [control]);
}, [control, hasPermission]);
// Split the versions into groups based on major minor
const groups = new Map<lib.PartialVersion, lib.PartialVersion[]>();
@@ -42,18 +43,20 @@ export default function InputPartialVersion (
}
// Construct the tree to be disabled
const tree = [...groups.entries()].map(([majorMinor, patchVersions]) => (
{
const tree = [...groups.entries()].map(([majorMinor, patchVersions]) => {
const groupKey = `group-${majorMinor}`;
return {
title: majorMinor,
value: majorMinor,
key: majorMinor,
key: groupKey,
children: patchVersions.map((v) => ({
title: v,
value: v,
key: v,
})),
}
));
};
});
return <TreeSelect
showSearch
@@ -2,7 +2,7 @@ import React, { useContext, useEffect, useState } from "react";
import { TreeSelect } from "antd";
import * as lib from "@clusterio/lib";
import { InputComponentProps } from "../BaseWebPlugin";
import type { InputComponentProps } from "../BaseWebPlugin";
import { useAccount } from "../model/account";
import ControlContext from "./ControlContext";
@@ -18,15 +18,16 @@ export default function InputTargetVersion (
const [versions, setVersions] = useState<readonly lib.TargetVersion[]>(lib.ApiVersions);
const control = useContext(ControlContext);
const account = useAccount();
const hasPermission = account.hasPermission("core.external.get_factorio_versions");
useEffect(() => {
(async () => {
if (account.hasPermission("core.external.get_factorio_versions")) {
if (hasPermission) {
const res = await control.factorioVersions.get(5 * 60 * 1000);
setVersions(res.map(v => v.version));
}
})();
}, [control]);
}, [control, hasPermission]);
// Split the versions into groups based on major minor
const groups = new Map<lib.TargetVersion, lib.TargetVersion[]>();
@@ -42,18 +43,20 @@ export default function InputTargetVersion (
}
// Construct the tree to be disabled
const tree = [...groups.entries()].map(([majorMinor, patchVersions]) => (
{
const tree = [...groups.entries()].map(([majorMinor, patchVersions]) => {
const groupKey = `group-${majorMinor}`;
return {
title: majorMinor,
value: majorMinor,
key: majorMinor,
key: groupKey,
children: patchVersions.map((v) => ({
title: v,
value: v,
key: v,
})),
}
));
};
});
// Add "latest" option for TargetVersion
tree.unshift({ title: "latest", value: "latest", key: "latest", children: [] });
+3
View File
@@ -0,0 +1,3 @@
node_modules/
*.ts
!*.d.ts
+40
View File
@@ -0,0 +1,40 @@
# Clusterio Minimap Plugin
A Clusterio plugin that provides interactive minimaps of Factorio instances with real-time tile and entity data visualization.
## Features
- Live updating terrain map in web UI
- Recipe overlay showing active crafting machines
- Timelapse mode for viewing historical changes
- Chart tag visualization and management
- Per instance, force and surface support
- Works when instances are offline
## Documentation
- **[Tile Data Storage](./docs/tile_data_storage.md)** - Binary format for terrain visualization data
- **[Recipe Overlay](./docs/minimap_recipe_overlay.md)** - Compact storage for crafting machine recipes
## Architecture Overview
The plugin consists of three main data streams:
### Terrain Data
- Factorio sends RGB565 data for 32×32 chunks
- Controller queues chunks and generates incremental pixel changes
- Data stored in append-only `.bin` files for efficient timelapse replay
- Web UI renders live updates and historical data
### Recipe Data
- Tracks which recipe each crafting machine is running
- Compact binary format with per-tile dictionaries (~8x smaller than JSON)
- Live streaming of recipe changes with timeline support
### Chart Tags
- User-created map markers with text and icons
- JSONL storage format for simplicity
- Real-time synchronization across all connected clients
## Usage
Access the minimap through the Clusterio web interface. Select an instance to view its terrain, toggle recipe overlays, enable chart tags, or switch to timelapse mode to review historical changes.
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,88 @@
# Crafting-Recipe Overlay for Minimap & Timelapse
## Overview
Records which recipe every crafting machine is running, streams live changes to web clients, and replays them frame-perfectly in timelapse mode. The solution:
* Works for all forces & surfaces
* Scales to millions of events using compact binary storage
* Allows cheap frustum-culling using 256×256 map tiles
* Uses append-only storage so historical data is never rewritten
## 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:recipe_data (JSON)
Host->>Ctl: RecipeDataEvent
Ctl-->>UI: RecipeDataEvent (broadcast)
UI->>Ctl: GetRawRecipeTileRequest (HTTP / cluster bus)
Ctl-->>UI: Raw recipe tile (.recipes)
```
### Messages
| Message | Direction | Purpose |
| ---------------------------------- | --------------------------- | --------------------------- |
| `RecipeDataEvent` | instance → controller → web | Single recipe change (live) |
| `GetRawRecipeTileRequest/Response` | web → controller | Fetches binary recipe tile |
## Storage Format
### Tile Organization
Uses same 256×256 world-unit tiles as terrain data.
Filename: `<instance>_<surface>_<force>_<tileX>_<tileY>.recipes`
Stored in: `database_directory/minimap_recipe_tiles/`
### Binary Record Format (append-only)
All numbers **big-endian**.
| Bytes | Field | Description |
| ------------------- | ---------------- | --------------------------------------------- |
| 1 | `type` | `0=DictEntry`, `1=SetRecipe`, `2=ClearRecipe` |
| **DictEntry (0)** | |
| 2 | `index` (uint16) | Dictionary ID (0-65535) |
| 1 | `len` (uint8) | UTF-8 byte length |
| n | name bytes | Recipe name string |
| **SetRecipe (1)** | |
| 4 | `sec` (uint32) | ⌊tick / 60⌋ timestamp |
| 1 | `px` (uint8) | X position in tile (0-255) |
| 1 | `py` (uint8) | Y position in tile (0-255) |
| 2 | `index` (uint16) | Dictionary ID |
| **ClearRecipe (2)** | |
| 4 | `sec` (uint32) | ⌊tick / 60⌋ timestamp |
| 1 | `px` (uint8) | X position in tile (0-255) |
| 1 | `py` (uint8) | Y position in tile (0-255) |
Dictionary entries are written only when a previously unseen recipe name is required for that tile.
### Space Efficiency
- ~8x more compact than JSON format
- Dictionary compression eliminates recipe name repetition
- Per-tile dictionaries keep indices small (uint16 sufficient)
## Playback
### Live Mode
- `RecipeDataEvent` broadcasts like `TileDataEvent`
- Web UI maintains `Map<posKey, recipeName>` for instant icon updates
- Icons render using Factorio sprite metadata
### Timelapse Mode
- Downloads `.recipes` files via `GetRawRecipeTileRequest`
- Parses binary format using shared `parseRecipeTileBinary()` utility
- Applies Set/Clear records up to selected timeline tick
- Integrates with existing terrain timelapse system
## Scalability
| Metric | Estimate |
| ------------------------------ | ----------------------------- |
| Assemblers per large factory | ~50,000 |
| Avg recipe changes per machine | ~5 lifetime |
| Bytes per Set/Clear record | 8 bytes |
| Total storage per instance | ~2 MB |
| Max recipes per tile | <1,000 (well under 65K limit) |
@@ -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: `<instance>_<surface>.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
```
+118
View File
@@ -0,0 +1,118 @@
# Minimap Tile Data Storage Format
## Overview
The minimap plugin stores terrain visualization data in a compact binary format optimized for both live updates and historical timelapse playback. The system uses append-only tiles that capture pixel changes over time.
## Architecture
### Data Flow
```mermaid
sequenceDiagram
participant L as Factorio Lua
participant H as Host Plugin
participant C as Controller
participant W as Web UI
L->>H: chart_data (32x32 chunk)
H->>C: TileDataEvent
C->>W: TileDataEvent (broadcast)
W->>C: GetRawTileRequest
C->>W: tile data (.bin)
```
### Storage Organization
- **Tile size**: 256×256 pixels (8×8 chunks of 32×32 each)
- **Filename**: `<instance>_<surface>_<force>_<tileX>_<tileY>.bin`
- **Location**: `database_directory/minimap_tiles/`
- **Format**: Append-only binary records
## Binary Format
### Color Encoding
Map colors use **RGB565** format (Factorio's internal representation):
- 5 bits red, 6 bits green, 5 bits blue
- 16 bits (2 bytes) per pixel
### Record Types
All timestamps use `⌊tick / 60⌋` (seconds) as uint32.
| Bytes | Field | Description |
| ----- | ------ | --------------------------- |
| 1 | `type` | `1=Chunk`, `2=PixelChanges` |
#### Chunk Record (Type 1)
Initial exploration data for a 32×32 area.
| Bytes | Field | Description |
| ----- | ----------------- | -------------------------------- |
| 4 | `tick` (uint32) | ⌊tick / 60⌋ timestamp |
| 1 | `coords` (uint8) | `(chunkX << 4) \| chunkY` |
| 2 | `length` (uint16) | Compressed data length |
| n | `data` | Deflate-compressed RGB565 pixels |
- **Chunk coordinates**: 4-bit X and Y (0-7 within tile)
- **Data**: 32×32×2 bytes (2048 bytes uncompressed)
- **Compression**: Deflate (zlib)
#### Pixel Changes Record (Type 2)
Incremental updates for pixels sharing the same color transition.
| Bytes | Field | Description |
| ----- | ------------------- | --------------------------- |
| 4 | `tick` (uint32) | ⌊tick / 60⌋ timestamp |
| 2 | `count` (uint16) | Number of pixels changed |
| 2 | `new_color` (uint16)| New RGB565 value |
| 2 | `old_color` (uint16)| Previous RGB565 value |
**Per pixel coordinate (2 bytes each):**
| Bytes | Field | Description |
| ----- | ----------- | ---------------------------- |
| 1 | `x` (uint8) | X coordinate in tile (0-255) |
| 1 | `y` (uint8) | Y coordinate in tile (0-255) |
**Note**: Multiple Type 2 records may be generated for a single tick if pixels have different color transitions. Each record groups pixels that share the same `old_color` -> `new_color` transition.
## Processing Strategy
### Live Updates
1. **Exploration**: New chunks sent as Type 1 records
2. **Changes**: Individual pixel updates grouped by color transition as Type 2 records
3. **Optimization**: Pixels with identical color changes are batched together
### Storage Efficiency
- **Chunks**: ~100-500 bytes after compression (vs 2048 uncompressed)
- **Pixels**: 2 bytes per coordinate + 11-byte header per color group (vs 6 bytes per pixel in old format)
- **Timeline**: Old color values enable fast rewind without recalculation
- **Compression**: Grouping by color transitions provides significant space savings when many pixels change to the same colors
### Timelapse Playback
1. **Forward**: Apply chunks and pixel changes up to target tick
2. **Backward**: Revert pixel changes using old_color values
3. **Chunks**: Revert to black when rewinding past exploration time
## Implementation Notes
### Saving Process
```
1. Queue chunk data in chunkSavingQueue
2. Every 5 seconds, process queued chunks:
- Load existing tile (if exists)
- Compare new chunk pixels with current tile state
- Group pixel differences by color transition
- Generate compressed pixel change records
- Append new data to tile file (never modify existing)
```
### Web Client Rendering
```
1. Request tile data with optional maxTick parameter
2. Parse binary records sequentially
3. Apply chunks and pixel changes up to target time
4. Convert RGB565 to RGBA for Canvas rendering
5. Overlay live broadcasts for real-time updates
```
### Cross-Platform Compatibility
- **Node.js**: Uses Buffer with native endian operations
- **Browser**: Uses Uint8Array with manual bit manipulation
- **Shared utilities**: `tile-utils.ts` handles platform differences
+52
View File
@@ -0,0 +1,52 @@
import * as lib from "@clusterio/lib";
import {
TileDataEvent,
ChartTagDataEvent,
PlayerPositionEvent,
PlayerSessionEndEvent,
GetRawTileRequest,
GetChartTagsRequest,
GetPlayerPathRequest,
RecipeDataEvent,
GetRawRecipeTileRequest,
ClearMinimapSurfaceDataRequest,
ClearMinimapDataRequest,
} from "./messages";
// Define permissions
lib.definePermission({
name: "minimap.view",
title: "View Minimap",
description: "View the interactive minimap of Factorio instances.",
grantByDefault: true,
});
lib.definePermission({
name: "minimap.manage",
title: "Manage Minimap Data",
description: "Delete cached minimap data through the web interface.",
});
export const plugin: lib.PluginDeclaration = {
name: "minimap",
title: "Minimap",
description: "Provides interactive minimaps of Factorio instances with real-time tile and entity data.",
instanceEntrypoint: "dist/node/instance",
controllerEntrypoint: "dist/node/controller",
webEntrypoint: "./web",
messages: [
TileDataEvent,
ChartTagDataEvent,
PlayerPositionEvent,
PlayerSessionEndEvent,
RecipeDataEvent,
GetRawTileRequest,
GetChartTagsRequest,
GetPlayerPathRequest,
GetRawRecipeTileRequest,
ClearMinimapSurfaceDataRequest,
ClearMinimapDataRequest,
],
routes: ["/minimap"],
};
+227
View File
@@ -0,0 +1,227 @@
import * as lib from "@clusterio/lib";
import { BaseInstancePlugin } from "@clusterio/host";
import {
TileDataEvent,
ChartData,
ChartTagDataEvent,
ChartTagData,
RecipeDataEvent,
RecipeData,
PlayerPositionEvent,
PlayerData,
PlayerSessionEndEvent,
} from "./messages";
interface TileDataIpc {
type: "chart";
data: ChartData[];
position: [number, number];
tick: number;
}
interface ChartTagDataIpc {
tag_number: number;
start_tick: number | null;
end_tick: number | null;
force: string;
surface: string;
position: [number, number];
text: string;
icon: string | null;
last_user: string | null;
}
interface RecipeDataIpc {
tag_number?: number
start_tick: number | null
end_tick: number | null
surface: string
force: string
position: [number, number]
recipe: string | null
icon?: { type?: string, name?: string, quality?: string }
}
interface PlayerPositionIpc {
player_name: string;
surface: string;
x: number;
y: number;
sec: number;
}
interface PlayerSessionEndIpc {
player_name: string;
surface: string;
}
export class InstancePlugin extends BaseInstancePlugin {
pendingTileUpdates = new Map<string, Set<[number, number, [number, number, number, number]]>>();
async init() {
if (!this.instance.config.get("factorio.enable_script_commands")) {
throw new Error("minimap plugin requires script commands to be enabled.");
}
// Listen for tile data from the Lua module
this.instance.server.on("ipc-minimap:tile_data", (data: TileDataIpc) => {
this.handleTileDataFromLua(data).catch(err => this.logger.error(
`Error handling tile data from Lua:\n${err}`
));
});
// Listen for chart tag data from the Lua module
this.instance.server.on("ipc-minimap:chart_tag_data", (data: ChartTagDataIpc) => {
this.handleChartTagDataFromLua(data).catch(err => this.logger.error(
`Error handling chart tag data from Lua:\n${err}`
));
});
// Listen for recipe data from Lua module
this.instance.server.on("ipc-minimap:recipe_data", (data: RecipeDataIpc) => {
this.handleRecipeDataFromLua(data).catch(err => this.logger.error(
`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}`
));
});
// Listen for player session end data from Lua module
this.instance.server.on("ipc-minimap:player_session_end", (data: PlayerSessionEndIpc) => {
this.handlePlayerSessionEndFromLua(data).catch(err => this.logger.error(
`Error handling player session end data from Lua:\n${err}`
));
});
}
async handleTileDataFromLua(data: TileDataIpc) {
try {
const { type, data: rawData, position, tick } = data;
if (type === "chart") {
// Handle new chart data format
const chartData = rawData as ChartData[];
if (!chartData || !Array.isArray(chartData)) {
this.logger.error("Invalid chart data received");
return;
}
// Send chart data to controller
const TileDataEvents = chartData.map(chart => new TileDataEvent(
this.instance.config.get("instance.id"),
chart.surface,
chart.force,
position[0],
position[1],
tick,
chart
));
TileDataEvents.map(event => this.instance.sendTo("controller", event));
}
} catch (err) {
this.logger.error(`Failed to process tile data from Lua: ${err}`);
}
}
async handleChartTagDataFromLua(data: ChartTagDataIpc) {
try {
// Validate the chart tag data
if (!data || typeof data.tag_number !== "number") {
this.logger.error("Invalid chart tag data received");
return;
}
// Create chart tag data event
const chartTagEvent = new ChartTagDataEvent(
this.instance.config.get("instance.id"),
data as ChartTagData
);
// Send to controller
this.instance.sendTo("controller", chartTagEvent);
} catch (err) {
this.logger.error(`Failed to process chart tag data from Lua: ${err}`);
}
}
async handleRecipeDataFromLua(data: RecipeDataIpc) {
try {
if (!data) {
this.logger.error("Invalid recipe data received");
return;
}
const recipeData: RecipeData = {
start_tick: data.start_tick ?? undefined,
end_tick: data.end_tick ?? undefined,
surface: data.surface,
force: data.force,
position: data.position,
recipe: data.recipe ?? undefined,
icon: data.icon ?? undefined,
};
const event = new RecipeDataEvent(
this.instance.config.get("instance.id"),
recipeData
);
this.instance.sendTo("controller", event);
} catch (err: unknown) {
this.logger.error(`Failed to process recipe data from Lua: ${err}`);
}
}
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.instance.sendTo("controller", event);
} catch (err: unknown) {
this.logger.error(`Failed to process player position data from Lua: ${err}`);
}
}
async handlePlayerSessionEndFromLua(data: PlayerSessionEndIpc) {
try {
if (!data) {
this.logger.error("Invalid player session end data received");
return;
}
const event = new PlayerSessionEndEvent(
this.instance.config.get("instance.id"),
{
player_name: data.player_name,
surface: data.surface,
}
);
this.instance.sendTo("controller", event);
} catch (err: unknown) {
this.logger.error(`Failed to process player session end data from Lua: ${err}`);
}
}
}
+464
View File
@@ -0,0 +1,464 @@
import { Type, Static } from "@sinclair/typebox";
import * as lib from "@clusterio/lib";
// Define the chart data structure
export const ChartDataSchema = Type.Object({
surface: Type.String(),
force: Type.String(),
chart_data: Type.String(),
});
export type ChartData = Static<typeof ChartDataSchema>;
// Define the SignalID structure (based on Factorio API)
export const SignalIDSchema = Type.Object({
type: Type.Optional(Type.String()), // SignalIDType, nil when reading for "item" type
name: Type.Optional(Type.String()), // Name of the prototype
quality: Type.Optional(Type.String()), // Quality name, defaults to "normal"
});
export type SignalID = Static<typeof SignalIDSchema>;
// Define the chart tag data structure
export const ChartTagDataSchema = Type.Object({
tag_number: Type.Number(),
start_tick: Type.Optional(Type.Number()),
end_tick: Type.Optional(Type.Number()),
force: Type.String(),
surface: Type.String(),
position: Type.Tuple([Type.Number(), Type.Number()]),
text: Type.String(),
icon: Type.Optional(SignalIDSchema),
last_user: Type.Optional(Type.String()),
});
export type ChartTagData = Static<typeof ChartTagDataSchema>;
export const RecipeDataSchema = Type.Object({
start_tick: Type.Optional(Type.Number()),
end_tick: Type.Optional(Type.Number()),
surface: Type.String(),
force: Type.String(),
position: Type.Tuple([Type.Number(), Type.Number()]),
recipe: Type.Optional(Type.String()),
icon: Type.Optional(SignalIDSchema),
});
export type RecipeData = Static<typeof RecipeDataSchema>;
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<typeof PlayerDataSchema>;
export const PlayerSessionEndDataSchema = Type.Object({
player_name: Type.String(),
surface: Type.String(),
});
export type PlayerSessionEndData = Static<typeof PlayerSessionEndDataSchema>;
/**
* 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<typeof PlayerPositionEvent.jsonSchema>) {
return new this(
json.instance_id,
json.player_data
);
}
}
/**
* PlayerSessionEndEvent: Instance -> Controller
*
* Notifies controller that a player session has ended (disconnect).
*/
export class PlayerSessionEndEvent {
declare ["constructor"]: typeof PlayerSessionEndEvent;
static type = "event" as const;
static src = ["instance"] as const;
static dst = ["controller"] as const;
static plugin = "minimap" as const;
static permission = null;
constructor(
public instance_id: number,
public session_data: PlayerSessionEndData,
) { }
static jsonSchema = Type.Object({
"instance_id": Type.Number(),
"session_data": PlayerSessionEndDataSchema,
});
static fromJSON(json: Static<typeof PlayerSessionEndEvent.jsonSchema>) {
return new this(
json.instance_id,
json.session_data
);
}
}
/**
* TileDataEvent: Used for both Factorio Instance -> Controller and Controller -> Web Clients
*
* Contains compressed RGB565 chart data from Factorio's internal mapping system.
* When sent from instance to controller: triggers storage in persistent tile files.
* When sent from controller to web clients: enables real-time minimap updates.
*
* Data flow:
* - Lua module -> Instance plugin -> Controller plugin (for persistence)
* - Controller plugin -> Web UI plugin -> Canvas renderer (for live updates)
*/
export class TileDataEvent {
declare ["constructor"]: typeof TileDataEvent;
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 surface: string,
public force: string,
public x: number,
public y: number,
public tick: number,
public chunk: ChartData,
) { }
static jsonSchema = Type.Object({
"instance_id": Type.Number(),
"surface": Type.String(),
"force": Type.String(),
"x": Type.Number(),
"y": Type.Number(),
"tick": Type.Number(),
"chunk": ChartDataSchema,
});
static fromJSON(json: Static<typeof TileDataEvent.jsonSchema>) {
return new this(
json.instance_id,
json.surface,
json.force,
json.x,
json.y,
json.tick,
json.chunk
);
}
}
/**
* ChartTagDataEvent: Used for both Factorio Instance -> Controller and Controller -> Web Clients
*
* Contains chart tag information from Factorio's map tags.
* When sent from instance to controller: triggers storage for tag history and timelapse.
* When sent from controller to web clients: enables real-time tag updates.
*
* Data flow:
* - Lua module -> Instance plugin -> Controller plugin (for persistence)
* - Controller plugin -> Web UI plugin -> Tag renderer (for live updates)
*/
export class ChartTagDataEvent {
declare ["constructor"]: typeof ChartTagDataEvent;
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 tag_data: ChartTagData,
) { }
static jsonSchema = Type.Object({
"instance_id": Type.Number(),
"tag_data": ChartTagDataSchema,
});
static fromJSON(json: Static<typeof ChartTagDataEvent.jsonSchema>) {
return new this(
json.instance_id,
json.tag_data
);
}
}
export class RecipeDataEvent {
declare ["constructor"]: typeof RecipeDataEvent;
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 recipe_data: RecipeData,
) { }
static jsonSchema = Type.Object({
"instance_id": Type.Number(),
"recipe_data": RecipeDataSchema,
});
static fromJSON(json: Static<typeof RecipeDataEvent.jsonSchema>) {
return new this(
json.instance_id,
json.recipe_data
);
}
}
/**
* GetRawTileRequest: Request raw tile data from controller storage
*
* Returns base64-encoded tile data that can be processed in timelapse mode
* or for initial tile loading when canvas minimap starts up.
*/
export class GetRawTileRequest {
declare ["constructor"]: typeof GetRawTileRequest;
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 surface: string,
public force: string,
public tile_x: number,
public tile_y: number,
public tick?: number,
) { }
static jsonSchema = Type.Object({
"instance_id": Type.Number(),
"surface": Type.String(),
"force": Type.String(),
"tile_x": Type.Number(),
"tile_y": Type.Number(),
"tick": Type.Optional(Type.Number()),
});
static fromJSON(json: Static<typeof GetRawTileRequest.jsonSchema>) {
return new this(
json.instance_id,
json.surface,
json.force,
json.tile_x,
json.tile_y,
json.tick
);
}
static Response = lib.plainJson(Type.Object({
"tile_data": Type.Union([Type.String(), Type.Null()]), // Base64 encoded tile file or null if not found
}));
}
export class ClearMinimapSurfaceDataRequest {
declare ["constructor"]: typeof ClearMinimapSurfaceDataRequest;
static type = "request" as const;
static src = "control" as const;
static dst = "controller" as const;
static plugin = "minimap" as const;
static permission = "minimap.manage";
constructor(
public instance_id: number,
public surface: string,
public force?: string,
) { }
static jsonSchema = Type.Object({
"instance_id": Type.Number(),
"surface": Type.String(),
"force": Type.Optional(Type.String()),
});
static fromJSON(json: Static<typeof ClearMinimapSurfaceDataRequest.jsonSchema>) {
return new this(
json.instance_id,
json.surface,
json.force
);
}
static Response = lib.plainJson(Type.Object({
"success": Type.Boolean(),
}));
}
export class ClearMinimapDataRequest {
declare ["constructor"]: typeof ClearMinimapDataRequest;
static type = "request" as const;
static src = "control" as const;
static dst = "controller" as const;
static plugin = "minimap" as const;
static permission = "minimap.manage";
static jsonSchema = Type.Object({});
static fromJSON(_: Static<typeof ClearMinimapDataRequest.jsonSchema>) {
return new this();
}
static Response = lib.plainJson(Type.Object({
"success": Type.Boolean(),
}));
}
/**
* GetChartTagsRequest: Request existing chart tags from controller storage
*
* Returns all saved chart tags for a specific instance/surface/force combination.
* Used to load existing chart tags when the web UI starts up or selection changes.
*/
export class GetChartTagsRequest {
declare ["constructor"]: typeof GetChartTagsRequest;
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 surface: string,
public force: string,
) { }
static jsonSchema = Type.Object({
"instance_id": Type.Number(),
"surface": Type.String(),
"force": Type.String(),
});
static fromJSON(json: Static<typeof GetChartTagsRequest.jsonSchema>) {
return new this(
json.instance_id,
json.surface,
json.force
);
}
static Response = lib.plainJson(Type.Object({
"chart_tags": Type.Array(ChartTagDataSchema),
}));
}
export class GetRawRecipeTileRequest {
declare ["constructor"]: typeof GetRawRecipeTileRequest;
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 surface: string,
public force: string,
public tile_x: number,
public tile_y: number,
public tick?: number,
) { }
static jsonSchema = Type.Object({
"instance_id": Type.Number(),
"surface": Type.String(),
"force": Type.String(),
"tile_x": Type.Number(),
"tile_y": Type.Number(),
"tick": Type.Optional(Type.Number()),
});
static fromJSON(json: Static<typeof GetRawRecipeTileRequest.jsonSchema>) {
return new this(
json.instance_id,
json.surface,
json.force,
json.tile_x,
json.tile_y,
json.tick
);
}
static Response = lib.plainJson(Type.Object({
"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 surface: string,
) { }
static jsonSchema = Type.Object({
"instance_id": Type.Number(),
"surface": Type.String(),
});
static fromJSON(json: Static<typeof GetPlayerPathRequest.jsonSchema>) {
return new this(
json.instance_id,
json.surface,
);
}
static Response = lib.plainJson(Type.Object({
"positions": Type.Union([Type.String(), Type.Null()]),
}));
}
+591
View File
@@ -0,0 +1,591 @@
local clusterio_api = require("modules/clusterio/api")
local compat = require("modules/clusterio/compat")
-- Hot-loop local references for performance
local math_floor = math.floor
local bit32_rshift = bit32.rshift
local table_insert = table.insert
local minimap = {}
-- Convert RGB565 to RGB888 values
local function rgb565_to_rgb888(rgb565_value)
local r = math_floor(bit32_rshift(rgb565_value, 11) * 255 / 31)
local g = math_floor(bit32_rshift(bit32.band(rgb565_value, 0x07E0), 5) * 255 / 63)
local b = math_floor(bit32.band(rgb565_value, 0x001F) * 255 / 31)
return r, g, b
end
-- Queue a chunk for update
local function queue_chunk_for_update(chunk_position)
if not storage.minimap or not storage.minimap.enabled then
return
end
-- If the chunk isn't already in the queue, add it
for _, queued_chunk in pairs(storage.minimap.chunk_update_queue) do
if queued_chunk.x == chunk_position.x and queued_chunk.y == chunk_position.y then
return
end
end
table.insert(storage.minimap.chunk_update_queue, chunk_position)
end
-- Send chart tag data to plugin
local function send_chart_tag_data(tag_data)
clusterio_api.send_json("minimap:chart_tag_data", tag_data)
end
-- Send recipe data to plugin
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
-- Send player session end data to plugin
local function send_player_session_end(session_data)
clusterio_api.send_json("minimap:player_session_end", session_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
-- Use physical controller position for accurate tracking when in vehicles/remotes
local surface = player.physical_surface
local position = player.physical_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 not last_position then
-- First position for this player
should_update = true
elseif has_moved_significantly(last_position.position, position) then
should_update = true
elseif (current_sec - last_position.last_update_sec) >= 5 then
-- Send update every 5 seconds even if not moving (for timeout)
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
-- Emit session start immediately when player joins using existing player_position channel
local surface = player.physical_surface or player.surface
local position = player.physical_position or player.position
local player_data = {
player_name = player.name,
surface = surface.name,
x = position.x,
y = position.y,
sec = math_floor(game.tick / 60),
}
send_player_position_data(player_data)
-- 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
end
-- Handle player session end
local function on_player_left_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
send_player_session_end({
player_name = player.name,
surface = player.surface.name,
})
-- 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
return
end
if not storage.minimap or not storage.minimap.enabled then
return
end
-- Only consider entities that can have recipes
local recipe = nil
if entity.type == "assembling-machine" then
local rec_obj = entity.get_recipe()
if rec_obj then
recipe = rec_obj.name
end
elseif entity.type == "furnace" then
local prev = entity.previous_recipe
if prev and prev.name then
-- Factorio furnaces expose previous_recipe with the id nested at previous_recipe.name.name.
recipe = prev.name.name
end
end
local cache = storage.minimap.recipe_cache
local unit_number = entity.unit_number
if not unit_number then
return -- Cannot track entities without persistent ID
end
local previous = cache[unit_number]
if previous and previous.recipe_name == recipe then
return -- No change
end
local position = entity.position
local data_common = {
position = { position.x, position.y },
surface = entity.surface.name,
force = entity.force.name,
}
-- Close previous recipe if it existed
if previous and previous.recipe_name then
local end_data = {
position = data_common.position,
surface = data_common.surface,
force = data_common.force,
tick = game.tick,
recipe = nil,
}
send_recipe_data(end_data)
end
-- Open new recipe interval if recipe now set
if recipe then
local icon_signal = nil
local recipe_proto = prototypes.recipe[recipe]
if recipe_proto and recipe_proto.main_product then
icon_signal = { type = recipe_proto.main_product.type, name = recipe_proto.main_product.name }
end
local start_data = {
position = data_common.position,
surface = data_common.surface,
force = data_common.force,
tick = game.tick,
recipe = recipe,
icon = icon_signal,
}
send_recipe_data(start_data)
cache[unit_number] = { recipe_name = recipe }
else
-- Recipe cleared
cache[unit_number] = nil
end
end
-- Create tag data structure
local function create_tag_data(tag, start_tick, end_tick)
return {
tag_number = tag.tag_number,
start_tick = start_tick,
end_tick = end_tick,
force = tag.force.name,
surface = tag.surface.name,
position = {tag.position.x, tag.position.y},
text = tag.text,
icon = tag.icon or nil,
last_user = tag.last_user and tag.last_user.name or nil
}
end
local function dump_chunk_chart(chunk_position)
local data = {}
local surfaces = game.surfaces
local forces = game.forces
for _, force in pairs(forces) do
for _, surface in pairs(surfaces) do
local chart_data = force.get_chunk_chart(surface, chunk_position)
if chart_data then
-- Use Factorio's built-in deflate compression and base64 encoding
local encoded_data = helpers.encode_string(chart_data)
table_insert(data, {surface = surface.name, force = force.name, chart_data = encoded_data})
end
end
end
-- Calculate world position from chunk position
local world_x = chunk_position.x * 32
local world_y = chunk_position.y * 32
-- Send tile data to the plugin
clusterio_api.send_json("minimap:tile_data", {
type = "chart",
position = {world_x, world_y},
tick = game.tick,
data = data,
})
end
-- Chart tag event handlers
local function on_chart_tag_added(event)
if not storage.minimap or not storage.minimap.enabled then
return
end
local tag = event.tag
if not tag or not tag.valid then
return
end
local start_tick = game.tick
local tag_data = create_tag_data(tag, start_tick, nil)
-- Send to plugin
send_chart_tag_data(tag_data)
end
local function on_chart_tag_modified(event)
if not storage.minimap or not storage.minimap.enabled then
return
end
local tag = event.tag
if not tag or not tag.valid then
return
end
-- End the old version at current tick
local end_tick = game.tick
local old_tag_data = create_tag_data(tag, nil, end_tick)
send_chart_tag_data(old_tag_data)
-- Create new tag entry starting at next tick
local start_tick = game.tick + 1
local tag_data = create_tag_data(tag, start_tick, nil)
-- Send to plugin
send_chart_tag_data(tag_data)
end
local function on_chart_tag_removed(event)
if not storage.minimap or not storage.minimap.enabled then
return
end
local tag = event.tag
if not tag or not tag.valid then
return
end
-- End the tag
local end_tick = game.tick
local tag_data = create_tag_data(tag, nil, end_tick)
send_chart_tag_data(tag_data)
end
-- Event handlers
local function on_chunk_charted(event)
if not storage.minimap or not storage.minimap.enabled then
return
end
-- Convert area to chunk position
local chunk_x = math_floor(event.area.left_top.x / 32)
local chunk_y = math_floor(event.area.left_top.y / 32)
queue_chunk_for_update({x = chunk_x, y = chunk_y})
end
local function on_entity_built(event)
if not storage.minimap or not storage.minimap.enabled then
return
end
if event.created_entity and event.created_entity.valid then
local entity = event.created_entity
local position = entity.position
local chunk_x = math_floor(position.x / 32)
local chunk_y = math_floor(position.y / 32)
queue_chunk_for_update({x = chunk_x, y = chunk_y})
-- Track recipe for newly built crafting entities
update_entity_recipe(entity)
end
end
local function on_entity_removed(event)
if not storage.minimap or not storage.minimap.enabled then
return
end
if event.entity and event.entity.position then
local entity = event.entity
local position = entity.position
local chunk_x = math_floor(position.x / 32)
local chunk_y = math_floor(position.y / 32)
queue_chunk_for_update({x = chunk_x, y = chunk_y})
-- End recipe interval for removed entity
update_entity_recipe(entity)
end
end
local function on_tile_changed(event)
if not storage.minimap or not storage.minimap.enabled then
return
end
-- Get unique chunks that were affected
local affected_chunks = {}
for _, tile in pairs(event.tiles) do
local chunk_x = math_floor(tile.position.x / 32)
local chunk_y = math_floor(tile.position.y / 32)
local chunk_key = chunk_x .. "," .. chunk_y
if not affected_chunks[chunk_key] then
affected_chunks[chunk_key] = {x = chunk_x, y = chunk_y}
end
end
-- Update each affected chunk
for _, chunk_pos in pairs(affected_chunks) do
queue_chunk_for_update(chunk_pos)
end
end
-- Handle recipe change via settings paste
local function on_entity_settings_pasted(event)
if not storage.minimap or not storage.minimap.enabled then
return
end
if event.destination and event.destination.valid then
update_entity_recipe(event.destination)
end
end
local function on_gui_closed(event)
if not storage.minimap or not storage.minimap.enabled then
return
end
-- We're only interested in entity GUIs
if event.gui_type ~= defines.gui_type.entity then
return
end
local entity = event.entity
if entity and entity.valid and (entity.type == "assembling-machine" or entity.type == "furnace") then
update_entity_recipe(entity)
end
end
-- Initialize the module
local function init()
if not storage.minimap then
storage.minimap = {
enabled = true,
chunk_update_queue = {},
recipe_cache = {},
player_positions = {}
}
end
if not storage.minimap.enabled then
return
end
-- Ensure all required fields exist
if not storage.minimap.chunk_update_queue then
storage.minimap.chunk_update_queue = {}
end
if not storage.minimap.recipe_cache then
storage.minimap.recipe_cache = {}
end
if not storage.minimap.player_positions then
storage.minimap.player_positions = {}
end
-- Clear chunk queue before rebuilding
storage.minimap.chunk_update_queue = {}
-- Queue all existing charted chunks for update
local chunks_queued = 0
for _, force in pairs(game.forces) do
for _, surface in pairs(game.surfaces) do
-- Iterate through all generated chunks on this surface
for chunk in surface.get_chunks() do
local chunk_position = {x = chunk.x, y = chunk.y}
-- Check if this chunk is charted for this force
if force.is_chunk_charted(surface, chunk_position) then
table_insert(storage.minimap.chunk_update_queue, chunk_position)
chunks_queued = chunks_queued + 1
end
end
-- Initialize existing chart tags
local existing_tags = force.find_chart_tags(surface)
for _, tag in pairs(existing_tags) do
local start_tick = game.tick
local tag_data = create_tag_data(tag, start_tick, nil)
-- Send to plugin
send_chart_tag_data(tag_data)
end
-- Initialize existing crafting recipes on this surface and force
local crafting_entities = surface.find_entities_filtered{
type = {
"assembling-machine",
"furnace",
}
}
for _, entity in pairs(crafting_entities) do
if entity.valid and entity.force == force then
local recipe
if entity.type == "assembling-machine" then
recipe = entity.get_recipe()
elseif entity.type == "furnace" then
local prev = entity.previous_recipe
if prev and prev.name then
recipe = { name = prev.name.name }
end
end
if recipe then
local recipe_proto = prototypes.recipe[recipe.name]
local icon_signal = nil
if recipe_proto and recipe_proto.main_product then
icon_signal = { type = recipe_proto.main_product.type, name = recipe_proto.main_product.name }
end
local data = {
position = { entity.position.x, entity.position.y },
surface = surface.name,
force = force.name,
tick = game.tick,
recipe = recipe.name,
icon = icon_signal,
}
send_recipe_data(data)
-- Cache for tracking
storage.minimap.recipe_cache[entity.unit_number] = { recipe_name = recipe.name }
end
end
end
end
end
end
local function on_tick(event)
if not storage.minimap or not storage.minimap.enabled then
return
end
-- Process the chunk update queue
if #storage.minimap.chunk_update_queue > 0 then
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
minimap.dump_chunk_chart = dump_chunk_chart
minimap.events = {}
-- Ensure this module only loads on Factorio 2.0 or later
if not compat.version_ge("2.0") then
log("[minimap] Minimap plugin disabled - requires Factorio 2.0 or later, current version: " .. (script.active_mods and script.active_mods.base or "unknown"))
else
minimap.events[clusterio_api.events.on_server_startup] = init
minimap.events[defines.events.on_tick] = on_tick
minimap.events[defines.events.on_chunk_charted] = on_chunk_charted
minimap.events[defines.events.on_built_entity] = on_entity_built
minimap.events[defines.events.on_robot_built_entity] = on_entity_built
minimap.events[defines.events.on_entity_died] = on_entity_removed
minimap.events[defines.events.on_player_mined_entity] = on_entity_removed
minimap.events[defines.events.on_robot_mined_entity] = on_entity_removed
minimap.events[defines.events.on_player_built_tile] = on_tile_changed
minimap.events[defines.events.on_robot_built_tile] = on_tile_changed
minimap.events[defines.events.on_player_mined_tile] = on_tile_changed
minimap.events[defines.events.on_robot_mined_tile] = on_tile_changed
minimap.events[defines.events.script_raised_set_tiles] = on_tile_changed
minimap.events[defines.events.on_chart_tag_added] = on_chart_tag_added
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
end
return minimap
+4
View File
@@ -0,0 +1,4 @@
{
"name": "minimap",
"load": ["minimap.lua"]
}
+62
View File
@@ -0,0 +1,62 @@
{
"name": "@clusterio/plugin-minimap",
"version": "2.0.0-alpha.22b",
"description": "Clusterio plugin for displaying interactive minimaps of Factorio instances",
"author": "Clusterio Team",
"homepage": "https://github.com/clusterio/clusterio#readme",
"license": "MIT",
"repository": "clusterio/clusterio",
"main": "dist/node/index.js",
"keywords": [
"clusterio",
"clusterio-plugin",
"factorio",
"minimap"
],
"scripts": {
"prepare": "tsc --build && webpack-cli --env production",
"watch": "tsc --build --watch",
"watch-web": "webpack-cli --env development --watch",
"test": "echo \"Error: run tests from root\" && exit 1"
},
"bugs": {
"url": "https://github.com/clusterio/clusterio/issues"
},
"engines": {
"node": ">=20"
},
"peerDependencies": {
"@clusterio/controller": "workspace:*",
"@clusterio/web_ui": "workspace:*",
"@clusterio/host": "workspace:*",
"@clusterio/lib": "workspace:*"
},
"devDependencies": {
"@clusterio/controller": "workspace:*",
"@clusterio/lib": "workspace:*",
"@clusterio/host": "workspace:*",
"@clusterio/web_ui": "workspace:*",
"@types/fs-extra": "catalog:",
"@types/leaflet": "^1.9.8",
"@types/node": "catalog:",
"@types/react": "catalog:",
"antd": "catalog:",
"leaflet": "^1.9.4",
"react": "catalog:",
"react-dom": "catalog:",
"react-leaflet": "^4.2.1",
"typescript": "catalog:",
"webpack": "catalog:",
"webpack-cli": "catalog:",
"webpack-merge": "catalog:"
},
"publishConfig": {
"access": "public"
},
"dependencies": {
"@ant-design/icons": "catalog:",
"@sinclair/typebox": "catalog:",
"fs-extra": "catalog:",
"sharp": "^0.33.5"
}
}
+16
View File
@@ -0,0 +1,16 @@
{
"extends": "../../tsconfig.browser.json",
"compilerOptions": {
"outDir": "dist/browser"
},
"include": [
"web/**/*",
"messages.ts",
"utils/tile-utils.ts",
"utils/player-utils.ts",
"utils/parsing-utils.ts"
],
"exclude": [
"dist/**/*"
]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.node.json" },
{ "path": "./tsconfig.browser.json" }
]
}
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.node.json",
"compilerOptions": {
"outDir": "dist/node"
},
"exclude": [
"web/**/*",
"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);
}
+315
View File
@@ -0,0 +1,315 @@
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 type PlayerPositionRecord =
| PlayerPositionRecordType0
| PlayerPositionRecordType1
| PlayerPositionRecordType2;
export interface PlayerPositionRecordType0 {
type: 0;
playerId: number;
tSec: number;
sec: number;
xTiles: number;
yTiles: number;
}
export interface PlayerPositionRecordType1 {
type: 1;
playerId: number;
tMs: number;
playerName: string;
}
export interface PlayerPositionRecordType2 {
type: 2;
playerId: number;
tMs: number;
}
export interface ParsedPlayerPos {
name: string;
x: number;
y: number;
sec: number;
}
/**
* Read a signed 24-bit big-endian integer from binary data
* @param buf The binary data buffer
* @param offset Offset in the buffer to read from
* @returns Signed 24-bit integer value
*/
function readSignedInt24(buf: BinaryData, offset: number): number {
const unsigned = (readUInt8(buf, offset) << 16) | (readUInt8(buf, offset + 1) << 8) | readUInt8(buf, offset + 2);
return unsigned > 0x7FFFFF ? unsigned - 0x1000000 : unsigned;
}
/**
* 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: PlayerPositionRecordType0 = {
type: 0,
...result.record,
};
positions.push(record);
if (result.record.playerId > maxPlayerId) {
maxPlayerId = result.record.playerId;
}
offset = result.newOffset;
} else if (type === 1) {
// SessionStart record
const result = parseSessionStartRecord(buf, offset);
if (!result.success) { break; }
const record: PlayerPositionRecordType1 = {
type: 1,
...result.record,
};
positions.push(record);
playerSessions.set(result.record.playerName, result.record.playerId);
if (result.record.playerId > maxPlayerId) {
maxPlayerId = result.record.playerId;
}
offset = result.newOffset;
} else if (type === 2) {
// SessionEnd record
const result = parseSessionEndRecord(buf, offset);
if (!result.success) { break; }
const record: PlayerPositionRecordType2 = {
type: 2,
...result.record,
};
positions.push(record);
if (result.record.playerId > maxPlayerId) {
maxPlayerId = result.record.playerId;
}
offset = result.newOffset;
} else {
// Unknown record type, can't continue parsing safely
break;
}
}
return { playerSessions, maxPlayerId, positions };
}
type PositionRecordParseResult =
| { success: false; newOffset: number }
| { success: true; newOffset: number; record: Omit<PlayerPositionRecordType0, "type"> };
/**
* 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): PositionRecordParseResult {
// Position record: 4 + 4 + 3 + 3 + 2 = 16 bytes
if (offset + 16 > buf.length) {
return { success: false, newOffset: offset };
}
const tSec = readUInt32BE(buf, offset);
offset += 4;
const sec = readUInt32BE(buf, offset);
offset += 4;
const xTiles = readSignedInt24(buf, offset);
offset += 3;
const yTiles = readSignedInt24(buf, offset);
offset += 3;
const playerId = readUInt16BE(buf, offset);
offset += 2;
return { success: true, newOffset: offset, record: { playerId, tSec, sec, xTiles, yTiles } };
}
type SessionStartRecordParseResult =
| { success: false; newOffset: number }
| { success: true; newOffset: number; record: Omit<PlayerPositionRecordType1, "type"> };
/**
* 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
): SessionStartRecordParseResult {
// SessionStart record: 4 + 2 + 1 + n = 7 + n bytes minimum
if (offset + 7 > buf.length) {
return { success: false, newOffset: offset };
}
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 };
}
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, record: { playerId, tMs, playerName } };
}
type SessionEndRecordParseResult =
| { success: false; newOffset: number }
| { success: true; newOffset: number; record: Omit<PlayerPositionRecordType2, "type"> };
/**
* 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
): SessionEndRecordParseResult {
// SessionEnd record: 4 + 2 = 6 bytes
if (offset + 6 > buf.length) {
return { success: false, newOffset: offset };
}
const tMs = readUInt32BE(buf, offset);
offset += 4;
const playerId = readUInt16BE(buf, offset);
offset += 2;
return { success: true, newOffset: offset, record: { 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[]>();
const playerNameById = new Map<number, string>();
for (const [name, id] of parsed.playerSessions) {
playerNameById.set(id, name);
}
// Process all records in order
for (const record of parsed.positions) {
if (record.type === 0) {
// Position record - convert tile coordinates to world coordinates
const name = playerNameById.get(record.playerId);
if (!name) {
continue;
}
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;
}
+871
View File
@@ -0,0 +1,871 @@
import { BinaryData, sliceData, readUInt8, readUInt16LE, readUInt16BE, readUInt32BE, isNode } from "./parsing-utils";
import * as zlib from "zlib";
// Cross-platform decompression
async function inflateData(data: BinaryData): Promise<Buffer | Uint8Array> {
if (isNode) {
// Node.js environment - use promisified inflate
const { promisify } = await import("util");
const inflateAsync = promisify(zlib.inflate);
return inflateAsync(data as Buffer);
}
// Browser environment - use promisified inflate
return new Promise((resolve, reject) => {
try {
const buffer = data instanceof Buffer ? data : Buffer.from(data);
zlib.inflate(buffer, (err, result) => {
if (err) {
reject(err);
} else {
resolve(result);
}
});
} catch (err) {
reject(err);
}
});
}
/**
* Convert RGB565 to RGB888 values using Factorio's exact method
* @param rgb565Value The RGB565 color value to convert
* @returns Tuple of RGB values [r, g, b]
*/
export function rgb565ToRgb888(rgb565Value: number): [number, number, number] {
// This matches ByteColor(uint16_t rgb5) constructor in Factorio source
const r = ((rgb565Value >> 11) & 0x1F) << 3; // 5 bits -> 8 bits
const g = ((rgb565Value >> 5) & 0x3F) << 2; // 6 bits -> 8 bits
const b = (rgb565Value & 0x1F) << 3; // 5 bits -> 8 bits
return [r, g, b];
}
/**
* Render chunk data to get RGB565 pixel array
* @param chunkData The compressed chunk data to render
* @returns RGB565 pixel array for 32x32 chunk
*/
export async function renderChunkToPixels(chunkData: BinaryData): Promise<Uint16Array> {
try {
const decompressed = await inflateData(chunkData);
const pixels = new Uint16Array(32 * 32);
// Ensure we don't read beyond available data
const maxPixels = Math.min(decompressed.length / 2, 32 * 32);
for (let i = 0; i < maxPixels; i++) {
pixels[i] = readUInt16LE(decompressed, i * 2);
}
// Fill remaining pixels with black (0) if we have less data than expected
for (let i = maxPixels; i < 32 * 32; i++) {
pixels[i] = 0;
}
return pixels;
} catch (decompressError) {
// Log warning before falling back to black pixels
console.warn(`Failed to decompress chunk data: ${decompressError}`);
// Return black pixels on error
return new Uint16Array(32 * 32);
}
}
/**
* Extract a 32x32 chunk area from a 256x256 tile
* @param tilePixels The full tile pixel data
* @param chunkX Chunk X coordinate within tile
* @param chunkY Chunk Y coordinate within tile
* @returns RGB565 pixel array for 32x32 chunk
*/
export function extractChunkFromTile(tilePixels: Uint16Array, chunkX: number, chunkY: number): Uint16Array {
const chunkPixels = new Uint16Array(32 * 32);
for (let y = 0; y < 32; y++) {
for (let x = 0; x < 32; x++) {
const tileX = chunkX * 32 + x;
const tileY = chunkY * 32 + y;
const tileIndex = tileY * 256 + tileX;
const chunkIndex = y * 32 + x;
chunkPixels[chunkIndex] = tilePixels[tileIndex];
}
}
return chunkPixels;
}
/**
* Render tile data to RGB565 pixel array (256x256)
* Supports rendering up to a specific tick for timelapse functionality
* @param tileData The binary tile data to render
* @param maxTick Optional maximum tick to render up to
* @returns RGB565 pixel array for 256x256 tile
*/
export async function renderTileToPixels(tileData: BinaryData, maxTick?: number): Promise<Uint16Array> {
const currentPixels = new Uint16Array(256 * 256); // 256x256 tile
let offset = 0;
while (offset < tileData.length) {
if (offset + 1 > tileData.length) {
break;
}
const type = readUInt8(tileData, offset);
offset += 1;
if (type === 1) { // Chunk
const chunkResult = await processChunkData(
tileData, offset, currentPixels, maxTick
);
if (!chunkResult.success) {
break;
}
offset = chunkResult.newOffset;
} else if (type === 2) { // Pixels
const pixelResult = processPixelData(tileData, offset, currentPixels, maxTick);
if (!pixelResult.success) {
break;
}
offset = pixelResult.newOffset;
} else {
break;
}
}
return currentPixels;
}
/**
* Process chunk data during tile rendering
* @param tileData The binary tile data being processed
* @param offset Current offset in the tile data
* @param currentPixels The pixel array to update
* @param maxTick Optional maximum tick to render up to
* @returns Processing result with success status and new offset
*/
async function processChunkData(
tileData: BinaryData,
offset: number,
currentPixels: Uint16Array,
maxTick?: number
): Promise<{ success: boolean; newOffset: number }> {
if (offset + 7 > tileData.length) {
return { success: false, newOffset: offset };
}
const tick = readUInt32BE(tileData, offset) * 60; // Convert back to actual tick
offset += 4;
// Break early if this record is beyond our target tick (append-only optimization)
if (maxTick !== undefined && tick > maxTick) {
return { success: false, newOffset: offset - 4 };
}
const chunkCoordsByte = readUInt8(tileData, offset);
offset += 1;
const length = readUInt16BE(tileData, offset);
offset += 2;
if (offset + length > tileData.length) {
return { success: false, newOffset: offset };
}
const chunkX = chunkCoordsByte >> 4;
const chunkY = chunkCoordsByte & 0x0F;
const chunkData = sliceData(tileData, offset, offset + length);
try {
const chunkPixels = await renderChunkToPixels(chunkData);
// Update current pixel state
updateTilePixelsWithChunk(currentPixels, chunkPixels, chunkX, chunkY);
} catch (decompressError) {
// Continue processing other chunks on error
}
return { success: true, newOffset: offset + length };
}
/**
* Process pixel data during tile rendering
* @param tileData The binary tile data being processed
* @param offset Current offset in the tile data
* @param currentPixels The pixel array to update
* @param maxTick Optional maximum tick to render up to
* @returns Processing result with success status and new offset
*/
function processPixelData(
tileData: BinaryData,
offset: number,
currentPixels: Uint16Array,
maxTick?: number
): { success: boolean; newOffset: number } {
if (offset + 10 > tileData.length) {
return { success: false, newOffset: offset };
}
const tick = readUInt32BE(tileData, offset) * 60; // Convert back to actual tick
offset += 4;
const pixelCount = readUInt16BE(tileData, offset);
offset += 2;
const newColor = readUInt16BE(tileData, offset);
offset += 2;
const oldColor = readUInt16BE(tileData, offset);
offset += 2;
const expectedDataLength = pixelCount * 2; // x,y per pixel
if (offset + expectedDataLength > tileData.length) {
return { success: false, newOffset: offset };
}
// Break early if this record is beyond our target tick (append-only optimization)
if (maxTick !== undefined && tick > maxTick) {
return { success: false, newOffset: offset - 4 };
}
for (let i = 0; i < pixelCount; i++) {
const pixelOffset = offset + i * 2;
const x = readUInt8(tileData, pixelOffset);
const y = readUInt8(tileData, pixelOffset + 1);
if (x < 256 && y < 256) {
currentPixels[y * 256 + x] = newColor;
}
}
return { success: true, newOffset: offset + expectedDataLength };
}
/**
* Update tile pixels with chunk data
* @param currentPixels The tile pixel array to update
* @param chunkPixels The chunk pixel data to apply
* @param chunkX Chunk X coordinate within tile
* @param chunkY Chunk Y coordinate within tile
*/
function updateTilePixelsWithChunk(
currentPixels: Uint16Array,
chunkPixels: Uint16Array,
chunkX: number,
chunkY: number
): void {
for (let y = 0; y < 32; y++) {
for (let x = 0; x < 32; x++) {
const chunkIndex = y * 32 + x;
const tileX = chunkX * 32 + x;
const tileY = chunkY * 32 + y;
const tileIndex = tileY * 256 + tileX;
currentPixels[tileIndex] = chunkPixels[chunkIndex];
}
}
}
/**
* Convert RGB565 pixel array to RGBA pixel data for canvas rendering
* Returns Uint8ClampedArray that can be used to create ImageData in browser
* @param pixels The RGB565 pixel array to convert
* @returns RGBA pixel data as Uint8ClampedArray
*/
export function pixelsToRGBA(pixels: Uint16Array): Uint8ClampedArray {
const imageData = new Uint8ClampedArray(256 * 256 * 4);
for (let i = 0; i < 256 * 256; i++) {
const rgb565Value = pixels[i];
const [r, g, b] = rgb565ToRgb888(rgb565Value);
const bufferIndex = i * 4;
imageData[bufferIndex] = r;
imageData[bufferIndex + 1] = g;
imageData[bufferIndex + 2] = b;
imageData[bufferIndex + 3] = 255; // Alpha
}
return imageData;
}
/**
* Convert RGB565 pixel array to ImageData for canvas rendering (browser only)
* @param pixels The RGB565 pixel array to convert
* @returns ImageData object for canvas rendering
*/
export function pixelsToImageData(pixels: Uint16Array): any {
if (typeof (globalThis as any).ImageData === "undefined") {
throw new Error("ImageData is not available in this environment. Use pixelsToRGBA() instead.");
}
const rgbaData = pixelsToRGBA(pixels);
return new (globalThis as any).ImageData(rgbaData, 256, 256);
}
/**
* Parse tile data and extract all available ticks for timelapse functionality
* @param tileData The binary tile data to parse
* @returns Array of available ticks sorted in ascending order
*/
export function extractAvailableTicks(tileData: BinaryData): number[] {
const ticks = new Set<number>();
let offset = 0;
while (offset < tileData.length) {
if (offset + 1 > tileData.length) {
break;
}
const type = readUInt8(tileData, offset);
offset += 1;
if (type === 1) { // Chunk
if (offset + 7 > tileData.length) {
break;
}
const tick = readUInt32BE(tileData, offset) * 60; // Convert back to actual tick
ticks.add(tick);
offset += 4;
const chunkCoordsByte = readUInt8(tileData, offset);
offset += 1;
const length = readUInt16BE(tileData, offset);
offset += 2;
if (offset + length > tileData.length) {
break;
}
offset += length; // Skip chunk data
} else if (type === 2) { // Pixels
if (offset + 10 > tileData.length) {
break;
}
const tick = readUInt32BE(tileData, offset) * 60; // Convert back to actual tick
ticks.add(tick);
offset += 4;
const pixelCount = readUInt16BE(tileData, offset);
offset += 2 + 2 + 2; // skip newColor oldColor too
const expectedDataLength = pixelCount * 2;
if (offset + expectedDataLength > tileData.length) {
break;
}
offset += expectedDataLength;
} else {
break;
}
}
return Array.from(ticks).sort((a, b) => a - b);
}
/**
* Interface for tracking pixel changes in timelapse data
*/
export interface PixelChange {
x: number;
y: number;
newColor: number;
oldColor: number;
}
/**
* Interface for chunk records in timelapse data
*/
export interface ChunkRecord {
tick: number;
chunkX: number;
chunkY: number;
data: BinaryData;
}
/**
* Interface for pixel change records in timelapse data
*/
export interface PixelChangeRecord {
tick: number;
changes: PixelChange[];
}
/**
* Interface for parsed tile data with all change records
*/
export interface ParsedTileData {
chunks: ChunkRecord[];
pixelChanges: PixelChangeRecord[];
allTicks: number[];
}
/**
* Parse tile data into structured change records for efficient timelapse navigation
* @param tileData The binary tile data to parse
* @returns Parsed tile data with chunks, pixel changes, and all ticks
*/
export function parseTileData(tileData: BinaryData): ParsedTileData {
const chunks: ChunkRecord[] = [];
const pixelChanges: PixelChangeRecord[] = [];
const ticks = new Set<number>();
let offset = 0;
while (offset < tileData.length) {
if (offset + 1 > tileData.length) {
break;
}
const type = readUInt8(tileData, offset);
offset += 1;
if (type === 1) { // Chunk
const result = parseChunkRecord(tileData, offset, chunks, ticks);
if (!result.success) {
break;
}
offset = result.newOffset;
} else if (type === 2) { // Pixels
const result = parsePixelChangeRecord(tileData, offset, pixelChanges, ticks);
if (!result.success) {
break;
}
offset = result.newOffset;
} else {
break;
}
}
return {
chunks,
pixelChanges,
allTicks: Array.from(ticks).sort((a, b) => a - b),
};
}
export interface ParsedChunkDataRecord {
tick: number;
chunkX: number;
chunkY: number;
data: BinaryData;
}
export interface ParsedPixelDataRecord {
tick: number;
pixelCount: number;
newColor: number;
oldColor: number;
pixelDataOffset: number;
}
export type ParseRecordResult<T> =
| { success: false; newOffset: number }
| { success: true; newOffset: number; record: T };
export function parseChunkDataRecord(
tileData: BinaryData,
offset: number
): ParseRecordResult<ParsedChunkDataRecord> {
if (offset + 7 > tileData.length) {
return { success: false, newOffset: offset };
}
const tick = readUInt32BE(tileData, offset);
offset += 4;
const chunkCoordsByte = readUInt8(tileData, offset);
offset += 1;
const length = readUInt16BE(tileData, offset);
offset += 2;
if (offset + length > tileData.length) {
return { success: false, newOffset: offset };
}
const chunkX = chunkCoordsByte >> 4;
const chunkY = chunkCoordsByte & 0x0F;
const data = sliceData(tileData, offset, offset + length);
return {
success: true,
newOffset: offset + length,
record: { tick, chunkX, chunkY, data },
};
}
export function parsePixelDataRecord(
tileData: BinaryData,
offset: number
): ParseRecordResult<ParsedPixelDataRecord> {
if (offset + 10 > tileData.length) {
return { success: false, newOffset: offset };
}
const tick = readUInt32BE(tileData, offset);
offset += 4;
const pixelCount = readUInt16BE(tileData, offset);
offset += 2;
const newColor = readUInt16BE(tileData, offset);
offset += 2;
const oldColor = readUInt16BE(tileData, offset);
offset += 2;
const pixelDataLength = pixelCount * 2;
if (offset + pixelDataLength > tileData.length) {
return { success: false, newOffset: offset };
}
return {
success: true,
newOffset: offset + pixelDataLength,
record: {
tick,
pixelCount,
newColor,
oldColor,
pixelDataOffset: offset,
},
};
}
/**
* Parse chunk record from tile data
* @param tileData The binary tile data being processed
* @param offset Current offset in the tile data
* @param chunks Array to add the parsed chunk to
* @param ticks Set to add the chunk tick to
* @returns Processing result with success status and new offset
*/
function parseChunkRecord(
tileData: BinaryData,
offset: number,
chunks: ChunkRecord[],
ticks: Set<number>
): { success: boolean; newOffset: number } {
const parsed = parseChunkDataRecord(tileData, offset);
if (!parsed.success) {
return parsed;
}
const tick = parsed.record.tick * 60; // Convert back to actual tick
ticks.add(tick);
chunks.push({
tick,
chunkX: parsed.record.chunkX,
chunkY: parsed.record.chunkY,
data: parsed.record.data,
});
return { success: true, newOffset: parsed.newOffset };
}
/**
* Parse pixel change record from tile data
* @param tileData The binary tile data being processed
* @param offset Current offset in the tile data
* @param pixelChanges Array to add the parsed pixel changes to
* @param ticks Set to add the pixel change tick to
* @returns Processing result with success status and new offset
*/
function parsePixelChangeRecord(
tileData: BinaryData,
offset: number,
pixelChanges: PixelChangeRecord[],
ticks: Set<number>
): { success: boolean; newOffset: number } {
const parsed = parsePixelDataRecord(tileData, offset);
if (!parsed.success) {
return parsed;
}
const tick = parsed.record.tick * 60; // Convert back to actual tick
ticks.add(tick);
const changes: PixelChange[] = [];
for (let i = 0; i < parsed.record.pixelCount; i++) {
const pixelOffset = parsed.record.pixelDataOffset + i * 2;
const x = readUInt8(tileData, pixelOffset);
const y = readUInt8(tileData, pixelOffset + 1);
changes.push({ x, y, newColor: parsed.record.newColor, oldColor: parsed.record.oldColor });
}
pixelChanges.push({ tick, changes });
return { success: true, newOffset: parsed.newOffset };
}
/**
* Apply chunk data to RGB565 pixel array
* @param pixels The tile pixel array to update
* @param chunkRecord The chunk record to apply
*/
export async function applyChunkToPixels(pixels: Uint16Array, chunkRecord: ChunkRecord): Promise<void> {
try {
const chunkPixels = await renderChunkToPixels(chunkRecord.data);
updateTilePixelsWithChunk(pixels, chunkPixels, chunkRecord.chunkX, chunkRecord.chunkY);
} catch (err) {
// Error applying chunk - continue without this chunk
}
}
/**
* Clear chunk area to black (for rewinding past chunk creation)
* @param pixels The tile pixel array to update
* @param chunkRecord The chunk record to clear
*/
export function clearChunkInPixels(pixels: Uint16Array, chunkRecord: ChunkRecord): void {
for (let y = 0; y < 32; y++) {
for (let x = 0; x < 32; x++) {
const tileX = chunkRecord.chunkX * 32 + x;
const tileY = chunkRecord.chunkY * 32 + y;
const tileIndex = tileY * 256 + tileX;
pixels[tileIndex] = 0; // Black
}
}
}
/**
* Apply pixel changes to RGB565 pixel array (forward direction)
* @param pixels The tile pixel array to update
* @param changes Array of pixel changes to apply
*/
export function applyPixelChanges(pixels: Uint16Array, changes: PixelChange[]): void {
for (const change of changes) {
const tileIndex = change.y * 256 + change.x;
pixels[tileIndex] = change.newColor;
}
}
/**
* Revert pixel changes from RGB565 pixel array (backward direction)
* @param pixels The tile pixel array to update
* @param changes Array of pixel changes to revert
*/
export function revertPixelChanges(pixels: Uint16Array, changes: PixelChange[]): void {
for (const change of changes) {
const tileIndex = change.y * 256 + change.x;
pixels[tileIndex] = change.oldColor;
}
}
/**
* Render tile data incrementally from one tick to another
* Much more efficient than re-rendering from scratch
* @param parsedData The parsed tile data
* @param currentPixels The current pixel state
* @param fromTick The starting tick
* @param toTick The target tick
*/
export async function renderTileIncremental(
parsedData: ParsedTileData,
currentPixels: Uint16Array,
fromTick: number,
toTick: number
): Promise<void> {
const { chunks, pixelChanges } = parsedData;
if (fromTick === toTick) {
return; // No changes needed
}
const forward = toTick > fromTick;
if (forward) {
await applyChangesForward(chunks, pixelChanges, currentPixels, fromTick, toTick);
} else {
await applyChangesBackward(chunks, pixelChanges, currentPixels, fromTick, toTick);
}
}
/**
* Apply changes in forward direction
* @param chunks Array of chunk records to process
* @param pixelChanges Array of pixel change records to process
* @param currentPixels The pixel array to update
* @param fromTick The starting tick
* @param toTick The target tick
*/
async function applyChangesForward(
chunks: ChunkRecord[],
pixelChanges: PixelChangeRecord[],
currentPixels: Uint16Array,
fromTick: number,
toTick: number
): Promise<void> {
// Apply chunks that occur in this range
for (const chunk of chunks) {
if (chunk.tick > fromTick && chunk.tick <= toTick) {
await applyChunkToPixels(currentPixels, chunk);
}
}
// Apply pixel changes that occur in this range
for (const record of pixelChanges) {
if (record.tick > fromTick && record.tick <= toTick) {
applyPixelChanges(currentPixels, record.changes);
}
}
}
/**
* Apply changes in backward direction
* @param chunks Array of chunk records to process
* @param pixelChanges Array of pixel change records to process
* @param currentPixels The pixel array to update
* @param fromTick The starting tick
* @param toTick The target tick
*/
async function applyChangesBackward(
chunks: ChunkRecord[],
pixelChanges: PixelChangeRecord[],
currentPixels: Uint16Array,
fromTick: number,
toTick: number
): Promise<void> {
// Revert pixel changes in reverse order
for (let i = pixelChanges.length - 1; i >= 0; i--) {
const record = pixelChanges[i];
if (record.tick <= fromTick && record.tick > toTick) {
revertPixelChanges(currentPixels, record.changes);
}
}
// Revert chunks (clear to black if we're going before their creation time)
for (let i = chunks.length - 1; i >= 0; i--) {
const chunk = chunks[i];
if (chunk.tick <= fromTick && chunk.tick > toTick) {
clearChunkInPixels(currentPixels, chunk);
}
}
}
/**
* Interface for parsed recipe tile data
*/
export interface ParsedRecipeTile {
ticks: number[]; // ticks present in this tile
activeRecipes: Map<string, string>; // posKey -> recipeName
dictionary: Map<number, string>; // id -> recipeName
}
/**
* Parse binary recipe tile data and return timeline ticks and active recipes at target tick
* @param tileX Tile X coordinate for world position calculation
* @param tileY Tile Y coordinate for world position calculation
* @param buf Binary recipe tile data
* @param targetTick Target tick for filtering (null = latest state)
* @returns Parsed recipe data with ticks and active recipes
*/
export function parseRecipeTileBinary(
tileX: number,
tileY: number,
buf: BinaryData,
targetTick: number | null
): ParsedRecipeTile {
const ticks: number[] = [];
const dict = new Map<number, string>();
const active = new Map<string, string>();
const targetSec = targetTick !== null ? Math.floor(targetTick / 60) : Infinity;
let offset = 0;
while (offset < buf.length) {
const type = readUInt8(buf, offset);
offset += 1;
if (type === 0) { // Dictionary entry
const result = parseDictionaryEntry(buf, offset, dict);
offset = result.newOffset;
} else if (type === 1 || type === 2) { // Set recipe (1) or Clear recipe (2)
const result = parseRecipeEntry(
buf, offset, type, tileX, tileY, ticks, active, targetSec, dict
);
offset = result.newOffset;
} else {
// Unknown recipe record type - stop parsing
break;
}
}
return { ticks, activeRecipes: active, dictionary: dict };
}
/**
* Parse dictionary entry from recipe tile
* @param buf The binary recipe tile data
* @param offset Current offset in the data
* @param dict Dictionary map to add the entry to
* @returns Processing result with new offset
*/
function parseDictionaryEntry(
buf: BinaryData,
offset: number,
dict: Map<number, string>
): { newOffset: number } {
const idx = readUInt16BE(buf, offset);
offset += 2;
const len = readUInt8(buf, offset);
offset += 1;
const nameBytes = sliceData(buf, offset, offset + len);
offset += len;
// Convert to string using cross-platform method
let recipeName: string;
if (isNode && nameBytes instanceof Buffer) {
recipeName = nameBytes.toString("utf-8");
} else {
recipeName = new TextDecoder().decode(nameBytes as Uint8Array);
}
dict.set(idx, recipeName);
return { newOffset: offset };
}
/**
* Parse recipe entry from recipe tile
* @param buf The binary recipe tile data
* @param offset Current offset in the data
* @param type Entry type (1 = set recipe, 2 = clear recipe)
* @param tileX Tile X coordinate for world position calculation
* @param tileY Tile Y coordinate for world position calculation
* @param ticks Array to add the entry tick to
* @param active Map of active recipes to update
* @param targetSec Target second for filtering
* @param dict Dictionary map for recipe name lookup
* @returns Processing result with new offset
*/
function parseRecipeEntry(
buf: BinaryData,
offset: number,
type: number,
tileX: number,
tileY: number,
ticks: number[],
active: Map<string, string>,
targetSec: number,
dict: Map<number, string>
): { newOffset: number } {
const sec = readUInt32BE(buf, offset);
offset += 4;
const px = readUInt8(buf, offset);
offset += 1;
const py = readUInt8(buf, offset);
offset += 1;
let idx: number | undefined;
if (type === 1) {
idx = readUInt16BE(buf, offset);
offset += 2;
}
const tickVal = sec * 60;
ticks.push(tickVal);
if (sec > targetSec) {
// Future to target time – skip without applying
return { newOffset: offset };
}
const worldX = tileX * 256 + px;
const worldY = tileY * 256 + py;
const posKey = `${worldX},${worldY}`;
if (type === 1 && idx !== undefined) {
const name = dict.get(idx);
if (name) {
active.set(posKey, name);
}
} else if (type === 2) {
active.delete(posKey);
}
return { newOffset: offset };
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,199 @@
import type { Control } from "@clusterio/web_ui";
import {
GetChartTagsRequest,
GetPlayerPathRequest,
GetRawRecipeTileRequest,
GetRawTileRequest,
type TileDataEvent,
type ChartTagDataEvent,
type RecipeDataEvent,
type PlayerPositionEvent,
} from "../../messages";
import type {
ChartTagDataWithInstance,
MinimapActiveView,
MinimapDataSource,
MinimapViewBounds,
} from "../minimap-data-source";
interface MinimapWebPlugin {
// Dual filter API: plural version accepts array (flexible), singular version is a convenience wrapper.
// Both exist for backward compatibility with different plugin implementations (check plural first, then singular).
setInstanceSurfaceFilters?(filters: Array<{ instanceId: number; surface: string }> | null): Promise<void> | void;
setInstanceSurfaceFilter?(instanceId: number | null, surface: string | null): Promise<void> | void;
onTileUpdate(callback: (event: TileDataEvent) => void): void;
offTileUpdate(callback: (event: TileDataEvent) => void): void;
onChartTagUpdate(callback: (event: ChartTagDataEvent) => void): void;
offChartTagUpdate(callback: (event: ChartTagDataEvent) => void): void;
onRecipeUpdate(callback: (event: RecipeDataEvent) => void): void;
offRecipeUpdate(callback: (event: RecipeDataEvent) => void): void;
onPlayerPositionUpdate(callback: (event: PlayerPositionEvent) => void): void;
offPlayerPositionUpdate(callback: (event: PlayerPositionEvent) => void): void;
}
const decodeBase64ToBytes = (base64: string): Uint8Array => {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
};
export class SingleInstanceDataSource implements MinimapDataSource {
private control: Control;
private plugin: MinimapWebPlugin | null = null;
private instanceId: number | null = null;
private surface = "nauvis";
private force = "player";
private activeInstances: number[] = [];
/**
* Data source for the canvas renderer that reads minimap data for a single selected instance.
*
* This bridges selection state to the minimap web plugin and keeps subscription filters in sync.
*/
constructor(control: Control) {
this.control = control;
this.plugin = control.plugins.get("minimap") as MinimapWebPlugin | undefined ?? null;
}
setInstance(instanceId: number | null): void {
this.instanceId = instanceId;
this.updateFilters();
}
setSurfaceForce(surface: string, force: string): void {
this.surface = surface;
this.force = force;
this.updateFilters();
}
setActiveView(_bounds: MinimapViewBounds): MinimapActiveView {
const nextActive = this.instanceId !== null ? [this.instanceId] : [];
const changed = nextActive.length !== this.activeInstances.length
|| nextActive.some((id, index) => id !== this.activeInstances[index]);
if (changed) {
this.activeInstances = nextActive;
}
return { changed, activeInstanceIds: [...this.activeInstances] };
}
isReady(): boolean {
return this.instanceId !== null;
}
async getTileData(tileX: number, tileY: number, tick?: number | null): Promise<Uint8Array | null> {
if (this.instanceId === null) {
return null;
}
const response = await this.control.send(new GetRawTileRequest(
this.instanceId,
this.surface,
this.force,
tileX,
tileY,
tick ?? undefined,
));
if (!response.tile_data) {
return null;
}
return decodeBase64ToBytes(response.tile_data);
}
async getRecipeTileData(tileX: number, tileY: number, tick?: number | null): Promise<Uint8Array | null> {
if (this.instanceId === null) {
return null;
}
const response = await this.control.send(new GetRawRecipeTileRequest(
this.instanceId,
this.surface,
this.force,
tileX,
tileY,
tick ?? undefined,
));
if (!response.recipe_tile) {
return null;
}
return decodeBase64ToBytes(response.recipe_tile);
}
async getChartTags(): Promise<ChartTagDataWithInstance[]> {
if (this.instanceId === null) {
return [];
}
const response = await this.control.send(new GetChartTagsRequest(
this.instanceId,
this.surface,
this.force,
));
return response.chart_tags.map(tag => ({
...tag,
instance_id: this.instanceId as number,
}));
}
async getPlayerPaths(): Promise<Array<{ instanceId: number; data: Uint8Array }>> {
if (this.instanceId === null) {
return [];
}
const response = await this.control.send(new GetPlayerPathRequest(
this.instanceId,
this.surface,
));
if (!response.positions) {
return [];
}
return [{ instanceId: this.instanceId, data: decodeBase64ToBytes(response.positions) }];
}
onTileUpdate(callback: (event: TileDataEvent) => void): () => void {
this.plugin?.onTileUpdate(callback);
return () => {
this.plugin?.offTileUpdate(callback);
};
}
onChartTagUpdate(callback: (event: ChartTagDataEvent) => void): () => void {
this.plugin?.onChartTagUpdate(callback);
return () => {
this.plugin?.offChartTagUpdate(callback);
};
}
onRecipeUpdate(callback: (event: RecipeDataEvent) => void): () => void {
this.plugin?.onRecipeUpdate(callback);
return () => {
this.plugin?.offRecipeUpdate(callback);
};
}
onPlayerPositionUpdate(callback: (event: PlayerPositionEvent) => void): () => void {
this.plugin?.onPlayerPositionUpdate(callback);
return () => {
this.plugin?.offPlayerPositionUpdate(callback);
};
}
private updateFilters() {
if (!this.plugin) {
return;
}
if (this.instanceId === null) {
if (this.plugin.setInstanceSurfaceFilters) {
this.plugin.setInstanceSurfaceFilters(null);
} else if (this.plugin.setInstanceSurfaceFilter) {
this.plugin.setInstanceSurfaceFilter(null, null);
}
return;
}
const filters = [{ instanceId: this.instanceId, surface: this.surface }];
// Support both the newer multi-filter API and the legacy single-filter API.
if (this.plugin.setInstanceSurfaceFilters) {
this.plugin.setInstanceSurfaceFilters(filters);
} else if (this.plugin.setInstanceSurfaceFilter) {
this.plugin.setInstanceSurfaceFilter(this.instanceId, this.surface);
}
}
}
+221
View File
@@ -0,0 +1,221 @@
import React from "react";
import { BaseWebPlugin } from "@clusterio/web_ui";
import * as lib from "@clusterio/lib";
import CanvasMinimapPage from "./CanvasMinimapPage";
export { default as CanvasMinimapPage } from "./CanvasMinimapPage";
export { SingleInstanceDataSource } from "./dataSources/SingleInstanceDataSource";
export type {
ChartTagDataWithInstance,
MinimapActiveView,
MinimapDataSource,
MinimapViewBounds,
} from "./minimap-data-source";
export {
GetRawTileRequest,
GetRawRecipeTileRequest,
GetChartTagsRequest,
GetPlayerPathRequest,
} from "../messages";
import { TileDataEvent, ChartTagDataEvent, RecipeDataEvent, PlayerPositionEvent } from "../messages";
export class WebPlugin extends BaseWebPlugin {
// Callback arrays for minimap updates
tileUpdateCallbacks: Array<(event: TileDataEvent) => void> = [];
chartTagUpdateCallbacks: Array<(event: ChartTagDataEvent) => void> = [];
recipeUpdateCallbacks: Array<(event: RecipeDataEvent) => void> = [];
playerPositionUpdateCallbacks: Array<(event: PlayerPositionEvent) => void> = [];
// Track subscription state
private subscribed = false;
private currentFilters: string[] | undefined = undefined;
async init() {
this.pages = [
{
path: "/minimap",
sidebarName: "Minimap",
permission: "minimap.view",
content: <CanvasMinimapPage />,
},
];
// Handle minimap events from the controller
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));
}
onControllerConnectionEvent(event: "connect" | "drop" | "resume" | "close") {
if (event === "connect" || event === "resume") {
this.updateSubscriptions();
}
}
// Update instance/surface filters used for subscriptions.
async setInstanceSurfaceFilters(filters: Array<{ instanceId: number; surface: string }> | null) {
const newFilters = filters && filters.length > 0
? filters.map(filter => `${filter.instanceId}:${filter.surface}`)
: undefined;
// No change
const prev = this.currentFilters;
const same = (
(prev === undefined && newFilters === undefined)
|| (Array.isArray(prev) && Array.isArray(newFilters)
&& prev.length === newFilters.length
&& prev.every((v, i) => v === newFilters[i]))
);
if (same) { return; }
// If we had previous filters and callbacks active, remove those filters first
if (this.hasActiveCallbacks() && prev && prev.length > 0) {
try {
await this.control.send(new lib.SubscriptionRequest("minimap:TileDataEvent", false, 0, prev));
await this.control.send(new lib.SubscriptionRequest("minimap:ChartTagDataEvent", false, 0, prev));
await this.control.send(new lib.SubscriptionRequest("minimap:RecipeDataEvent", false, 0, prev));
await this.control.send(new lib.SubscriptionRequest("minimap:PlayerPositionEvent", false, 0, prev));
} catch (err) {
this.logger.error(`Failed to remove previous minimap filters: ${err}`);
}
}
this.currentFilters = newFilters;
// Apply new filters if we have active callbacks
if (this.hasActiveCallbacks()) {
await this.updateSubscriptions();
}
}
// Update instance/surface filter used for subscriptions.
async setInstanceSurfaceFilter(instanceId: number | null, surface: string | null) {
await this.setInstanceSurfaceFilters(
instanceId !== null && surface
? [{ instanceId, surface }]
: null,
);
}
private async updateSubscriptions() {
if (!this.control.connector.connected) {
return;
}
const shouldSubscribe = this.hasActiveCallbacks();
// Only subscribe if we both have active callbacks and a specific filter set
if (shouldSubscribe && this.currentFilters && this.currentFilters.length > 0) {
// Always (re)send with current filters when we have active callbacks
const filters = this.currentFilters;
try {
await this.control.send(new lib.SubscriptionRequest("minimap:TileDataEvent", true, 0, filters));
await this.control.send(new lib.SubscriptionRequest("minimap:ChartTagDataEvent", true, 0, filters));
await this.control.send(new lib.SubscriptionRequest("minimap:RecipeDataEvent", true, 0, filters));
await this.control.send(new lib.SubscriptionRequest("minimap:PlayerPositionEvent", true, 0, filters));
this.subscribed = true;
} catch (err) {
this.logger.error(`Failed to subscribe to minimap events: ${err}`);
}
} else if ((!shouldSubscribe || !this.currentFilters || this.currentFilters.length === 0) && this.subscribed) {
// Unsubscribe from all minimap events
try {
await this.control.send(new lib.SubscriptionRequest("minimap:TileDataEvent", false));
await this.control.send(new lib.SubscriptionRequest("minimap:ChartTagDataEvent", false));
await this.control.send(new lib.SubscriptionRequest("minimap:RecipeDataEvent", false));
await this.control.send(new lib.SubscriptionRequest("minimap:PlayerPositionEvent", false));
this.subscribed = false;
} catch (err) {
this.logger.error(`Failed to unsubscribe from minimap events: ${err}`);
}
}
}
private hasActiveCallbacks(): boolean {
return this.tileUpdateCallbacks.length > 0
|| this.chartTagUpdateCallbacks.length > 0
|| this.recipeUpdateCallbacks.length > 0
|| this.playerPositionUpdateCallbacks.length > 0;
}
async handleTileDataEvent(event: TileDataEvent) {
// Notify all registered callbacks
for (let callback of this.tileUpdateCallbacks) {
callback(event);
}
}
async handleChartTagDataEvent(event: ChartTagDataEvent) {
for (let callback of this.chartTagUpdateCallbacks) {
callback(event);
}
}
async handleRecipeDataEvent(event: RecipeDataEvent) {
for (let callback of this.recipeUpdateCallbacks) {
callback(event);
}
}
async handlePlayerPositionEvent(event: PlayerPositionEvent) {
for (let callback of this.playerPositionUpdateCallbacks) {
callback(event);
}
}
onTileUpdate(callback: (event: TileDataEvent) => void) {
this.tileUpdateCallbacks.push(callback);
this.updateSubscriptions();
}
offTileUpdate(callback: (event: TileDataEvent) => void) {
let index = this.tileUpdateCallbacks.lastIndexOf(callback);
if (index === -1) {
throw new Error("callback is not registered");
}
this.tileUpdateCallbacks.splice(index, 1);
this.updateSubscriptions();
}
onChartTagUpdate(callback: (event: ChartTagDataEvent) => void) {
this.chartTagUpdateCallbacks.push(callback);
this.updateSubscriptions();
}
offChartTagUpdate(callback: (event: ChartTagDataEvent) => void) {
let index = this.chartTagUpdateCallbacks.lastIndexOf(callback);
if (index === -1) {
throw new Error("callback is not registered");
}
this.chartTagUpdateCallbacks.splice(index, 1);
this.updateSubscriptions();
}
onRecipeUpdate(callback: (event: RecipeDataEvent) => void) {
this.recipeUpdateCallbacks.push(callback);
this.updateSubscriptions();
}
offRecipeUpdate(callback: (event: RecipeDataEvent) => void) {
const index = this.recipeUpdateCallbacks.lastIndexOf(callback);
if (index === -1) {
throw new Error("callback is not registered");
}
this.recipeUpdateCallbacks.splice(index, 1);
this.updateSubscriptions();
}
onPlayerPositionUpdate(callback: (event: PlayerPositionEvent) => void) {
this.playerPositionUpdateCallbacks.push(callback);
this.updateSubscriptions();
}
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);
this.updateSubscriptions();
}
}
@@ -0,0 +1,38 @@
import type {
ChartTagData,
ChartTagDataEvent,
PlayerPositionEvent,
RecipeDataEvent,
TileDataEvent,
} from "../messages";
export interface ChartTagDataWithInstance extends ChartTagData {
instance_id: number;
}
export interface MinimapViewBounds {
worldLeft: number;
worldTop: number;
worldRight: number;
worldBottom: number;
}
export interface MinimapActiveView {
changed: boolean;
activeInstanceIds: number[];
}
export interface MinimapDataSource {
setInstance?(instanceId: number | null): void;
setSurfaceForce(surface: string, force: string): void;
setActiveView(bounds: MinimapViewBounds): MinimapActiveView;
isReady(): boolean;
getTileData(tileX: number, tileY: number, tick?: number | null): Promise<Uint8Array | null>;
getRecipeTileData(tileX: number, tileY: number, tick?: number | null): Promise<Uint8Array | null>;
getChartTags(): Promise<ChartTagDataWithInstance[]>;
getPlayerPaths(): Promise<Array<{ instanceId: number; data: Uint8Array }>>;
onTileUpdate(callback: (event: TileDataEvent) => void): () => void;
onChartTagUpdate(callback: (event: ChartTagDataEvent) => void): () => void;
onRecipeUpdate(callback: (event: RecipeDataEvent) => void): () => void;
onPlayerPositionUpdate(callback: (event: PlayerPositionEvent) => void): () => void;
}
+36
View File
@@ -0,0 +1,36 @@
"use strict";
const path = require("path");
const webpack = require("webpack");
const { merge } = require("webpack-merge");
const common = require("@clusterio/web_ui/webpack.common");
module.exports = (env = {}) => merge(common(env), {
context: __dirname,
entry: "./web/index.tsx",
output: {
path: path.resolve(__dirname, "dist", "web"),
},
plugins: [
new webpack.container.ModuleFederationPlugin({
name: "minimap",
library: { type: "var", name: "plugin_minimap" },
exposes: {
"./": "./index.ts",
"./package.json": "./package.json",
"./web": "./web/index.tsx",
},
shared: {
"@clusterio/lib": { import: false },
"@clusterio/web_ui": { import: false },
"antd": { import: false },
"react": { import: false },
"react-dom": { import: false },
},
}),
],
optimization: {
chunkIds: "named",
},
});
+36 -34
View File
@@ -1,40 +1,42 @@
packages:
- "packages/*"
- "plugins/*"
- "external_plugins/*"
- "external_plugins/*/*"
- "test/"
- packages/*
- plugins/*
- external_plugins/*
- external_plugins/*/*
- test/
catalog:
"@types/node": ^20.17.19
"@types/fs-extra": ^11.0.1
"@types/express": ^4.17.17
"@types/finalhandler": ^1.2.0
"@types/jsonwebtoken": ^9.0.9
"@types/set-blocking": ^2.0.0
"@types/react": ^18.3.18
"@types/ws": ^8.5.14
"@sinclair/typebox": ^0.30.4
"@swc/core": ^1.13.3
"antd": ^5.24.2
"react": ^18.2.0
"react-dom": ^18.2.0
"react-router-dom": ^6.29.0
"typescript": ^5.7.3
"webpack": ^5.98.0
"webpack-cli": ^5.1.4
"webpack-merge": ^5.9.0
"fs-extra": ^8.1.0
"express": ^5.1.0
"winston": ^3.17.0
"winston-transport": ^4.9.0
"winston-daily-rotate-file": ^4.7.1
"jsonwebtoken": ^9.0.2
"set-blocking": ^2.0.0
"jszip": ^3.10.1
"chalk": ^4.1.2
"phin": ^3.7.0
"ws": ^8.18.1
'@ant-design/icons': ^5.6.1
'@sinclair/typebox': ^0.30.4
'@swc/core': ^1.13.3
'@types/express': ^4.17.17
'@types/finalhandler': ^1.2.0
'@types/fs-extra': ^11.0.1
'@types/jsonwebtoken': ^9.0.9
'@types/node': ^20.17.19
'@types/react': ^18.3.18
'@types/set-blocking': ^2.0.0
'@types/ws': ^8.5.14
antd: ^5.24.2
chalk: ^4.1.2
express: ^5.1.0
fs-extra: ^8.1.0
jsonwebtoken: ^9.0.2
jszip: ^3.10.1
phin: ^3.7.0
react: ^18.2.0
react-dom: ^18.2.0
react-router-dom: ^6.29.0
set-blocking: ^2.0.0
typescript: ^5.7.3
webpack: ^5.98.0
webpack-cli: ^5.1.4
webpack-merge: ^5.9.0
winston: ^3.17.0
winston-daily-rotate-file: ^4.7.1
winston-transport: ^4.9.0
ws: ^8.18.1
catalogMode: strict
linkWorkspacePackages: true
+3
View File
@@ -15,10 +15,12 @@ const { selectTargetCommand, initialize: initializeCtl } = require("@clusterio/c
// Make sure permissions from plugins are loaded
require("../../plugins/global_chat/dist/node/index");
require("../../plugins/minimap/dist/node/index");
require("../../plugins/player_auth/dist/node/index");
require("../../plugins/research_sync/dist/node/index");
require("../../plugins/statistics_exporter/dist/node/index");
require("../../plugins/subspace_storage/dist/node/index");
require("../../plugins/inventory_sync/dist/node/index");
class TestControl extends lib.Link {
constructor(connector, subscribe = true) {
@@ -329,6 +331,7 @@ before(async function() {
await execCtlProcess("plugin add ../../plugins/subspace_storage");
await execCtlProcess("plugin add ../../plugins/inventory_sync");
await execCtlProcess("plugin add ../../plugins/player_auth");
await execCtlProcess("plugin add ../../plugins/minimap");
console.log("Bootstrapping");
await execController("bootstrap create-admin test");
+3 -2
View File
@@ -41,6 +41,7 @@ describe("Clusterio Instance", function() {
for (const plugin of [
"global_chat", "research_sync", "statistics_exporter", "subspace_storage", "player_auth", "inventory_sync",
"minimap",
]) {
await execCtl(`instance config set ${instName} ${plugin}.load_plugin false`);
}
@@ -162,7 +163,7 @@ describe("Clusterio Instance", function() {
it("should respond to a player whitelist add and remove event", async function() {
await execCtl(`${instSetConfig} factorio.sync_whitelist bidirectional`);
await sendRcon(instId, "/whitelist add WhitelistPlayer");
await lib.wait(15); // Propagation time, 10ms worked for me, so i made it 15ms
await lib.wait(30); // Propagation time, had issues with 15ms in ci so i made it 30ms
const userAdded = await getUser("WhitelistPlayer");
assert(userAdded.isWhitelisted, "Player is not whitelisted");
const isAltAdded = await sendRcon(instAltId, "/whitelist get WhitelistPlayer");
@@ -172,7 +173,7 @@ describe("Clusterio Instance", function() {
);
await sendRcon(instId, "/whitelist remove WhitelistPlayer");
await lib.wait(15); // Propagation time, 10ms worked for me, so i made it 15ms
await lib.wait(30); // Propagation time, had issues with 15ms in ci so i made it 30ms
const userRemoved = await getUser("WhitelistPlayer");
assert(!userRemoved.isWhitelisted, "Player is not unwhitelisted");
const isAltRemoved = await sendRcon(instAltId, "/whitelist get WhitelistPlayer");