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

825 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import * as lib from "@clusterio/lib";
import { BaseControllerPlugin } from "@clusterio/controller";
import {
TileDataEvent,
GetRawTileRequest,
GetChartTagsRequest,
ChartData,
ChartTagDataEvent,
ChartTagData,
RecipeDataEvent,
RecipeData,
GetRawRecipeTileRequest,
} 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 chartTagsPath: string = "";
private recipeTilesPath: string = "";
private chunkSavingQueue = new Map<string, Map<string, { data: ChartData, tick: number }>>();
private chartTagQueues = new Map<string, ChartTagData[]>();
private recipeSavingQueue = new Map<string, RecipeData[]>();
private lastSeenTagContent = new Map<string, Map<number, {
position: [number, number];
text: string;
icon?: any;
last_user?: string;
}>>();
private lastSeenRecipeContent = new Map<string, Map<string, string | undefined>>(); // tileKey -> posKey -> recipe
private savingTiles: boolean = false;
private savingChartTags = new Set<string>();
private saveInterval: NodeJS.Timeout | null = null;
private saveRecipeTilesInProgress: boolean = false;
async init() {
this.tilesPath = path.resolve(
this.controller.config.get("controller.database_directory"),
"minimap_tiles"
);
this.chartTagsPath = path.resolve(
this.controller.config.get("controller.database_directory"),
"minimap_chart_tags"
);
this.recipeTilesPath = path.resolve(
this.controller.config.get("controller.database_directory"),
"minimap_recipe_tiles"
);
await fs.ensureDir(this.tilesPath);
await fs.ensureDir(this.chartTagsPath);
await fs.ensureDir(this.recipeTilesPath);
this.controller.handle(TileDataEvent, this.handleTileDataEvent.bind(this));
this.controller.handle(ChartTagDataEvent, this.handleChartTagDataEvent.bind(this));
this.controller.handle(GetRawTileRequest, this.handleGetRawTileRequest.bind(this));
this.controller.handle(GetChartTagsRequest, this.handleGetChartTagsRequest.bind(this));
this.controller.handle(RecipeDataEvent, this.handleRecipeDataEvent.bind(this));
this.controller.handle(GetRawRecipeTileRequest, this.handleGetRawRecipeTileRequest.bind(this));
// Set up HTTP routes for serving tiles
this.setupTileRoutes();
// Load existing tag numbers for deduplication
await this.loadExistingTagContent();
// Load existing recipe data for deduplication
await this.loadExistingRecipeContent();
this.saveInterval = setInterval(() => {
this.saveTiles().catch(err => this.logger.error(`Error saving tiles: ${err}`));
this.saveChartTags().catch(err => this.logger.error(`Error saving chart tags: ${err}`));
this.saveRecipeTiles().catch(err => this.logger.error(`Error saving recipe tiles: ${err}`));
}, 5000);
}
async onShutdown() {
if (this.saveInterval) {
clearInterval(this.saveInterval);
this.saveInterval = null;
}
// Perform final save before shutdown
await this.saveTiles();
await this.saveChartTags();
await this.saveRecipeTiles();
}
// 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 loadExistingTagContent() {
try {
if (!await fs.pathExists(this.chartTagsPath)) {
return;
}
const files = await fs.readdir(this.chartTagsPath);
const tagFiles = files.filter(file => file.endsWith('_chart_tags.json'));
for (const file of tagFiles) {
const fileKey = file.replace('_chart_tags.json', '');
const filePath = path.join(this.chartTagsPath, file);
if (!this.lastSeenTagContent.has(fileKey)) {
this.lastSeenTagContent.set(fileKey, new Map());
}
const seenTags = this.lastSeenTagContent.get(fileKey)!;
try {
const content = await fs.readFile(filePath, 'utf-8');
const lines = content.trim().split('\n').filter(line => line.trim());
for (const line of lines) {
try {
const tagData = JSON.parse(line);
if (typeof tagData.tag_number === 'number' && !tagData.end_tick) {
// Only store the latest content for tags that haven't been deleted
seenTags.set(tagData.tag_number, {
position: tagData.position,
text: tagData.text,
icon: tagData.icon,
last_user: tagData.last_user
});
}
} catch (parseErr) {
this.logger.warn(`Failed to parse chart tag line in ${file}: ${parseErr}`);
}
}
this.logger.info(`Loaded ${seenTags.size} existing tag contents from ${file}`);
} catch (readErr) {
this.logger.warn(`Failed to read chart tag file ${file}: ${readErr}`);
}
}
} catch (err) {
this.logger.error(`Error loading existing tag content: ${err}`);
}
}
/**
* Parses existing recipe jsonl files and builds an in-memory map that tracks
* the latest active (non-ended) recipe for every position. This allows incoming
* RecipeDataEvents to be deduplicated so we do not append identical recipe lines
* to disk multiple times.
*/
async loadExistingRecipeContent() {
try {
if (!await fs.pathExists(this.recipeTilesPath)) {
return;
}
const files = await fs.readdir(this.recipeTilesPath);
const recipeFiles = files.filter(file => file.endsWith("_recipes.jsonl"));
for (const file of recipeFiles) {
const tileKey = file.replace("_recipes.jsonl", "");
const filePath = path.join(this.recipeTilesPath, file);
if (!this.lastSeenRecipeContent.has(tileKey)) {
this.lastSeenRecipeContent.set(tileKey, new Map());
}
const seenRecipes = this.lastSeenRecipeContent.get(tileKey)!;
try {
const content = await fs.readFile(filePath, "utf-8");
const lines = content.trim().split("\n").filter(line => line.trim());
for (const line of lines) {
try {
const recipeData = JSON.parse(line) as RecipeData;
const posKey = `${recipeData.position[0]}_${recipeData.position[1]}`;
// Update map: if the recipe has an end_tick it's no longer active, otherwise store/update it.
if (recipeData.end_tick !== undefined) {
seenRecipes.delete(posKey);
} else {
seenRecipes.set(posKey, recipeData.recipe);
}
} catch (parseErr) {
this.logger.warn(`Failed to parse recipe line in ${file}: ${parseErr}`);
}
}
this.logger.info(`Loaded ${seenRecipes.size} existing recipe entries from ${file}`);
} catch (readErr) {
this.logger.warn(`Failed to read recipe file ${file}: ${readErr}`);
}
}
} catch (err) {
this.logger.error(`Error loading existing recipe content: ${err}`);
}
}
async saveChartTags() {
if (this.chartTagQueues.size === 0) {
return;
}
const savePromises = [];
for (const [fileKey, tags] of this.chartTagQueues) {
if (tags.length === 0 || this.savingChartTags.has(fileKey)) {
// Remove empty queues to prevent repeated processing
if (tags.length === 0 && !this.savingChartTags.has(fileKey)) {
this.chartTagQueues.delete(fileKey);
}
continue;
}
const promise = (async () => {
this.savingChartTags.add(fileKey);
const tagsToSave = [...tags];
tags.length = 0; // Clear the queue
try {
const filePath = path.join(this.chartTagsPath, `${fileKey}_chart_tags.json`);
// Convert chart tags to newline-delimited JSON format
const jsonLines = tagsToSave.map(tag => JSON.stringify(tag)).join('\n');
await fs.appendFile(filePath, jsonLines + '\n');
// Mark tags as successfully saved
if (!this.lastSeenTagContent.has(fileKey)) {
this.lastSeenTagContent.set(fileKey, new Map());
}
const seenTags = this.lastSeenTagContent.get(fileKey)!;
for (const tag of tagsToSave) {
seenTags.set(tag.tag_number, {
position: tag.position,
text: tag.text,
icon: tag.icon,
last_user: tag.last_user
});
}
this.logger.info(`Saved ${tagsToSave.length} chart tag entries to ${fileKey}`);
} catch (err) {
this.logger.error(`Error saving chart tags for ${fileKey}: ${err}`);
// Re-queue the failed tags
tags.unshift(...tagsToSave);
} finally {
this.savingChartTags.delete(fileKey);
}
})();
savePromises.push(promise);
}
await Promise.all(savePromises);
}
async handleChartTagDataEvent(event: ChartTagDataEvent) {
try {
const { instance_id, tag_data } = event;
// Create file key based on instance, surface, and force
const fileKey = `${instance_id}_${tag_data.surface}_${tag_data.force}`;
// Initialize seen tags map if it doesn't exist
if (!this.lastSeenTagContent.has(fileKey)) {
this.lastSeenTagContent.set(fileKey, new Map());
}
// Check for duplicate content, but allow deletion events (end_tick set) even if we've seen the tag before
const seenTags = this.lastSeenTagContent.get(fileKey)!;
const lastSeenContent = seenTags.get(tag_data.tag_number);
// For deletion events (end_tick set), always allow through
if (tag_data.end_tick !== undefined) {
// Don't skip deletion events
} else if (lastSeenContent) {
// For creation/modification events, check if content has actually changed
const contentUnchanged =
lastSeenContent.position[0] === tag_data.position[0] &&
lastSeenContent.position[1] === tag_data.position[1] &&
lastSeenContent.text === tag_data.text &&
JSON.stringify(lastSeenContent.icon) === JSON.stringify(tag_data.icon) &&
lastSeenContent.last_user === tag_data.last_user;
if (contentUnchanged) {
return; // Skip if content hasn't changed
}
}
// Add instance_id to the tag data for storage
const enrichedTagData = {
...tag_data,
instance_id
};
// Immediately update last seen content to improve deduplication before save completes
if (tag_data.end_tick !== undefined) {
seenTags.delete(tag_data.tag_number);
} else {
seenTags.set(tag_data.tag_number, {
position: tag_data.position,
text: tag_data.text,
icon: tag_data.icon,
last_user: tag_data.last_user
});
}
// Initialize queue if it doesn't exist
if (!this.chartTagQueues.has(fileKey)) {
this.chartTagQueues.set(fileKey, []);
}
// Queue for batch saving
this.chartTagQueues.get(fileKey)!.push(enrichedTagData);
// Send live update to connected web clients
const updateEvent = new ChartTagDataEvent(instance_id, tag_data);
// 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 chart tag data: ${err}`);
}
}
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 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 };
}
}
async handleGetChartTagsRequest(request: GetChartTagsRequest) {
const { instance_id, surface, force } = request;
const fileKey = `${instance_id}_${surface}_${force}`;
const filePath = path.join(this.chartTagsPath, `${fileKey}_chart_tags.json`);
if (!await fs.pathExists(filePath)) {
return { chart_tags: [] };
}
try {
const content = await fs.readFile(filePath, 'utf-8');
const lines = content.trim().split('\n').filter(line => line.trim());
const chartTags: ChartTagData[] = [];
for (const line of lines) {
try {
const tagData = JSON.parse(line);
chartTags.push(tagData);
} catch (parseErr) {
this.logger.warn(`Failed to parse chart tag line: ${parseErr}`);
}
}
return { chart_tags: chartTags };
} catch (err) {
this.logger.error(`Error reading chart tag file: ${err}`);
return { chart_tags: [] };
}
}
async saveRecipeTiles() {
if (this.saveRecipeTilesInProgress) {
return;
}
if (this.recipeSavingQueue.size === 0) {
return;
}
this.saveRecipeTilesInProgress = true;
const queue = this.recipeSavingQueue;
this.recipeSavingQueue = new Map();
try {
const promises = [] as Promise<void>[];
for (const [tileName, list] of queue) {
const promise = (async () => {
const tilePath = path.join(this.recipeTilesPath, `${tileName}_recipes.jsonl`);
const lines = list.map(rec => JSON.stringify(rec)).join("\n") + "\n";
await fs.appendFile(tilePath, lines, "utf-8");
})();
promises.push(promise);
}
await Promise.all(promises);
} catch (err) {
this.logger.error(`Error saving recipe tiles: ${err}`);
} finally {
this.saveRecipeTilesInProgress = false;
}
}
async handleRecipeDataEvent(event: RecipeDataEvent) {
try {
const { instance_id, recipe_data } = event;
const x = recipe_data.position[0];
const y = recipe_data.position[1];
const tileX = Math.floor(x / 256);
const tileY = Math.floor(y / 256);
const tileKey = `${instance_id}_${recipe_data.surface}_${recipe_data.force}_${tileX}_${tileY}`;
const posKey = `${x}_${y}`;
// Initialise the deduplication map for this tile if needed
if (!this.lastSeenRecipeContent.has(tileKey)) {
this.lastSeenRecipeContent.set(tileKey, new Map());
}
const seenRecipes = this.lastSeenRecipeContent.get(tileKey)!;
let shouldEnqueue = true;
if (recipe_data.end_tick !== undefined) {
// A recipe is being removed/ended – always enqueue the event but update our cache
seenRecipes.delete(posKey);
} else {
const existingRecipe = seenRecipes.get(posKey);
if (existingRecipe === recipe_data.recipe) {
// Duplicate event – skip persisting
shouldEnqueue = false;
} else {
// New or changed recipe – remember it
seenRecipes.set(posKey, recipe_data.recipe);
}
}
if (!shouldEnqueue) {
return;
}
if (!this.recipeSavingQueue.has(tileKey)) {
this.recipeSavingQueue.set(tileKey, []);
}
this.recipeSavingQueue.get(tileKey)!.push(recipe_data);
// broadcast live update
this.controller.sendTo(new lib.Address(lib.Address.broadcast, lib.Address.control), event);
} catch (err) {
this.logger.error(`Error processing recipe data: ${err}`);
}
}
async handleGetRawRecipeTileRequest(request: GetRawRecipeTileRequest) {
const { instance_id, surface, force, tile_x, tile_y } = request;
const tileKey = `${instance_id}_${surface}_${force}_${tile_x}_${tile_y}`;
const filePath = path.join(this.recipeTilesPath, `${tileKey}_recipes.jsonl`);
if (!await fs.pathExists(filePath)) {
return { recipe_tile: null };
}
try {
const data = await fs.readFile(filePath, "utf-8");
const base64 = Buffer.from(data, "utf-8").toString("base64");
return { recipe_tile: base64 };
} catch (err) {
this.logger.error(`Error reading recipe tile: ${err}`);
return { recipe_tile: null };
}
}
}