Files
factorio-clusterio/plugins/minimap/controller.ts
T

439 lines
13 KiB
TypeScript

import * as lib from "@clusterio/lib";
import { BaseControllerPlugin } from "@clusterio/controller";
import {
TileDataEvent,
GetInstanceBoundsRequest,
GetRawTileRequest,
ChartData,
} from "./messages";
import * as fs from "fs-extra";
import * as path from "path";
import * as zlib from "zlib";
import { promisify } from "util";
import sharp from "sharp";
import {
renderChunkToPixels,
extractChunkFromTile,
renderTileToPixels,
pixelsToRGBA
} from "./tile-utils";
const CHUNK_SIZE = 32;
const inflateAsync = promisify(zlib.inflate);
interface PixelChange {
x: number;
y: number;
newColor: number;
oldColor: number;
}
export class ControllerPlugin extends BaseControllerPlugin {
private tilesPath: string = "";
private chunkSavingQueue = new Map<string, Map<string, { data: ChartData, tick: number }>>();
private savingTiles: boolean = false;
// Set to true if colors appear inverted - this will try big-endian byte order
private useBigEndian: boolean = false;
async init() {
this.tilesPath = path.resolve(
this.controller.config.get("controller.database_directory"),
"minimap_tiles"
);
await fs.ensureDir(this.tilesPath);
this.controller.handle(TileDataEvent, this.handleTileDataEvent.bind(this));
this.controller.handle(GetInstanceBoundsRequest, this.handleGetInstanceBoundsRequest.bind(this));
this.controller.handle(GetRawTileRequest, this.handleGetRawTileRequest.bind(this));
// Set up HTTP routes for serving tiles
this.setupTileRoutes();
setInterval(() => {
this.saveTiles().catch(err => this.logger.error(`Error saving tiles: ${err}`));
}, 5000);
}
// Compare two pixel arrays and return changes
private comparePixels(oldPixels: Uint16Array, newPixels: Uint16Array, chunkX: number, chunkY: number): PixelChange[] {
const changes: PixelChange[] = [];
for (let y = 0; y < 32; y++) {
for (let x = 0; x < 32; x++) {
const index = y * 32 + x;
const oldColor = oldPixels[index];
const newColor = newPixels[index];
if (oldColor !== newColor) {
// Calculate absolute position within the 256x256 tile
const absoluteX = chunkX * 32 + x;
const absoluteY = chunkY * 32 + y;
changes.push({
x: absoluteX,
y: absoluteY,
newColor,
oldColor,
});
}
}
}
return changes;
}
// Create pixel changeset data buffer
private createPixelChangeset(changes: PixelChange[], tick: number): Buffer {
if (changes.length === 0) {
return Buffer.alloc(0);
}
// Type (1) + Tick (4) + Length (2) + Pixel data (6 bytes per pixel)
const headerSize = 7;
const pixelDataSize = changes.length * 6; // 6 bytes per pixel change
const totalSize = headerSize + pixelDataSize;
const buffer = Buffer.alloc(totalSize);
let offset = 0;
// Type 2 for pixels
buffer.writeUInt8(2, offset);
offset += 1;
// Tick (converted to seconds)
buffer.writeUInt32BE(Math.floor(tick / 60), offset);
offset += 4;
// Length (number of pixel changes, not bytes)
buffer.writeUInt16BE(changes.length, offset);
offset += 2;
// Write pixel changes
for (const change of changes) {
buffer.writeUInt8(change.x, offset);
offset += 1;
buffer.writeUInt8(change.y, offset);
offset += 1;
buffer.writeUInt16BE(change.newColor, offset);
offset += 2;
buffer.writeUInt16BE(change.oldColor, offset);
offset += 2;
}
return buffer;
}
private setupTileRoutes() {
const app = this.controller.app;
app.get("/api/minimap/tile/:instanceId/:surface/:force/:z/:x/:y.png", async (req, res) => {
try {
const { instanceId, surface, force, z, x, y } = req.params;
const tileX = parseInt(x);
const tileY = parseInt(y);
const tileName = `${instanceId}_${surface}_${force}_${tileX}_${tileY}.bin`;
const tilePath = path.join(this.tilesPath, tileName);
if (!await fs.pathExists(tilePath)) {
res.status(404).send("Tile not found");
return;
}
const tileData = await fs.readFile(tilePath);
const image = await this.renderTile(tileData);
res.setHeader("Content-Type", "image/png");
res.send(image);
} catch (err) {
this.logger.error(`Error serving tile: ${err}`);
res.status(500).send("Error serving tile");
}
});
app.get("/api/minimap/surfaces", async (req, res) => {
try {
const files = await fs.readdir(this.tilesPath);
const surfaces = new Set<string>();
const forces = new Set<string>();
for (const file of files) {
if (file.endsWith(".bin")) {
const parts = file.split("_");
if (parts.length === 5) {
surfaces.add(parts[1]);
forces.add(parts[2]);
}
}
}
res.json({
surfaces: Array.from(surfaces),
forces: Array.from(forces),
});
} catch (err) {
this.logger.error(`Error listing surfaces and forces: ${err}`);
res.status(500).json({ error: "Failed to list surfaces and forces" });
}
});
}
async renderTile(tileData: Buffer): Promise<Buffer> {
// Use renderTileToPixels to get the current state, then convert to image
const currentPixels = await renderTileToPixels(tileData);
// Convert RGB565 pixel data to RGBA buffer for Sharp
const rgbaData = pixelsToRGBA(currentPixels);
const raw = Buffer.from(rgbaData);
// Create and return the image directly
return sharp(raw, {
raw: { width: 256, height: 256, channels: 4 }
}).png().toBuffer();
}
async saveTiles() {
if (this.savingTiles) {
return;
}
const queue = this.chunkSavingQueue;
if (queue.size === 0) {
return;
}
this.logger.info(`Starting save for ${queue.size} tiles.`);
this.savingTiles = true;
this.chunkSavingQueue = new Map();
try {
const savePromises = [];
for (const [tileName, chunks] of queue) {
const promise = (async () => {
const tilePath = path.join(this.tilesPath, tileName);
let existingTile = Buffer.alloc(0);
if (await fs.pathExists(tilePath)) {
existingTile = await fs.readFile(tilePath);
}
const newTile = await this.updateTile(existingTile, chunks);
if (newTile && newTile.length > 0) {
await fs.writeFile(tilePath, newTile);
}
})();
savePromises.push(promise);
}
await Promise.all(savePromises);
} catch (err) {
this.logger.error(`Error saving tiles: ${err}`);
} finally {
this.savingTiles = false;
}
}
async updateTile(existingTile: Buffer, newChunks: Map<string, { data: ChartData, tick: number }>): Promise<Buffer | undefined> {
const existingChunks = new Map<string, { tick: number, data: Buffer }>();
// Parse existing tile data to find existing chunks
let offset = 0;
while (offset < existingTile.length) {
if (offset + 1 > existingTile.length) {
this.logger.error(`Invalid tile data: cannot read type at offset ${offset}`);
break;
}
const type = existingTile.readUInt8(offset);
offset += 1;
if (type === 1) { // Chunk
if (offset + 7 > existingTile.length) {
this.logger.error(`Invalid tile data: insufficient chunk header data at offset ${offset}`);
break;
}
const tick = existingTile.readUInt32BE(offset);
offset += 4;
const chunkCoordsByte = existingTile.readUInt8(offset);
offset += 1;
const length = existingTile.readUInt16BE(offset);
offset += 2;
if (offset + length > existingTile.length) {
this.logger.error(`Invalid tile data: insufficient chunk data at offset ${offset}`);
break;
}
const chunkX = chunkCoordsByte >> 4;
const chunkY = chunkCoordsByte & 0x0F;
const chunkName = `${chunkX}_${chunkY}`;
const data = existingTile.slice(offset, offset + length);
offset += length;
existingChunks.set(chunkName, { tick, data });
} else if (type === 2) { // Pixels
if (offset + 6 > existingTile.length) {
this.logger.error(`Invalid tile data: insufficient pixel header data at offset ${offset}`);
break;
}
const tick = existingTile.readUInt32BE(offset);
offset += 4;
const pixelCount = existingTile.readUInt16BE(offset);
offset += 2;
const pixelDataLength = pixelCount * 6;
if (offset + pixelDataLength > existingTile.length) {
this.logger.error(`Invalid tile data: insufficient pixel data at offset ${offset}`);
break;
}
// Skip pixel data since we're not rebuilding the file
offset += pixelDataLength;
} else {
this.logger.error(`Unknown tile data type: ${type} at offset ${offset}`);
break;
}
}
// Render the current state of the entire tile if we have existing data
let currentTilePixels: Uint16Array | null = null;
if (existingTile.length > 0) {
currentTilePixels = await renderTileToPixels(existingTile);
}
// Process new chunks and determine what to append
const appendBuffers: Buffer[] = [];
let hasChanges = false;
for (const [chunkName, newChunk] of newChunks) {
const newChunkData = Buffer.from(newChunk.data.chart_data, "base64");
const [chunkX, chunkY] = chunkName.split("_").map(Number);
if (!existingChunks.has(chunkName)) {
// New chunk - append as chunk data (type 1)
this.logger.info(`Adding new chunk ${chunkName} to tile`);
const header = Buffer.alloc(8);
header.writeUInt8(1, 0); // Type 1 (chunk)
header.writeUInt32BE(Math.floor(newChunk.tick / 60), 1); // Tick
header.writeUInt8((chunkX << 4) | chunkY, 5); // Coords
header.writeUInt16BE(newChunkData.length, 6); // Length
appendBuffers.push(header, newChunkData);
hasChanges = true;
} else {
// Existing chunk - compare current tile state with new chunk data
const newPixels = await renderChunkToPixels(newChunkData);
let oldPixels: Uint16Array;
if (currentTilePixels) {
// Extract current chunk area from the rendered tile
oldPixels = extractChunkFromTile(currentTilePixels, chunkX, chunkY);
} else {
// Fallback to black pixels if tile couldn't be rendered
oldPixels = new Uint16Array(32 * 32);
}
const changes = this.comparePixels(oldPixels, newPixels, chunkX, chunkY);
if (changes.length > 0) {
this.logger.info(`Adding ${changes.length} pixel changes for chunk ${chunkName} to tile`);
const changeset = this.createPixelChangeset(changes, newChunk.tick);
if (changeset.length > 0) {
appendBuffers.push(changeset);
hasChanges = true;
}
}
}
}
if (!hasChanges) {
return;
}
// Append new data to existing tile (never modify existing data)
return Buffer.concat([existingTile, ...appendBuffers]);
}
async handleTileDataEvent(event: TileDataEvent) {
try {
const { instance_id, surface, force, x, y, tick, chunk } = event;
// Calculate tile coordinates for 8x8 chunks per tile (256x256 pixels)
const tileX = Math.floor(x / 32 / 8);
const tileY = Math.floor(y / 32 / 8);
const tileName = `${instance_id}_${surface}_${force}_${tileX}_${tileY}.bin`;
// Fix negative modulo results by using proper modulo operation (0-7 range for 8x8)
const chunkX = ((Math.floor(x / 32) % 8) + 8) % 8;
const chunkY = ((Math.floor(y / 32) % 8) + 8) % 8;
const chunkName = `${chunkX}_${chunkY}`;
if (!this.chunkSavingQueue.has(tileName)) {
this.chunkSavingQueue.set(tileName, new Map());
}
this.chunkSavingQueue.get(tileName)!.set(chunkName, { data: chunk, tick });
// Send live update to connected web clients
const updateEvent = new TileDataEvent(
instance_id,
surface,
force,
x,
y,
tick,
chunk
);
// Broadcast to web clients (overriding the formal src/dst routing)
this.controller.sendTo(new lib.Address(lib.Address.broadcast, lib.Address.control), updateEvent);
} catch (err) {
this.logger.error(`Error processing tile data: ${err}`);
}
}
async handleGetInstanceBoundsRequest(request: GetInstanceBoundsRequest) {
const instances = Array.from(this.controller.instances.values())
.filter(instance => instance.status === "running")
.map(instance => ({
instanceId: instance.config.get("instance.id"),
name: instance.config.get("instance.name"),
bounds: {
x1: -512, // Default bounds, could be made configurable
y1: -512,
x2: 512,
y2: 512,
},
}));
return { instances };
}
async handleGetRawTileRequest(request: GetRawTileRequest) {
const { instance_id, surface, force, tile_x, tile_y, tick } = request;
const tileName = `${instance_id}_${surface}_${force}_${tile_x}_${tile_y}.bin`;
const tilePath = path.join(this.tilesPath, tileName);
if (!await fs.pathExists(tilePath)) {
return { tile_data: null, tick: tick || 0 };
}
try {
const tileData = await fs.readFile(tilePath);
const base64Data = tileData.toString('base64');
return {
tile_data: base64Data
};
} catch (err) {
this.logger.error(`Error reading tile file: ${err}`);
return { tile_data: null };
}
}
}