mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-12 15:04:52 +09:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
898b4b738c | ||
|
|
15fc41a801 | ||
|
|
fc0279ad11 | ||
|
|
9bf817e665 |
No files matched your search
@@ -1,3 +1,2 @@
|
|||||||
*
|
|
||||||
!.gitignore
|
!.gitignore
|
||||||
!README.md
|
!README.md
|
||||||
@@ -28,6 +28,8 @@ import HostConnection from "./HostConnection";
|
|||||||
import HostInfo from "./HostInfo";
|
import HostInfo from "./HostInfo";
|
||||||
import BaseControllerPlugin from "./BaseControllerPlugin";
|
import BaseControllerPlugin from "./BaseControllerPlugin";
|
||||||
import ControllerRouter from "./ControllerRouter";
|
import ControllerRouter from "./ControllerRouter";
|
||||||
|
import PlayerRoutingStore from "./PlayerRoutingStore";
|
||||||
|
import PlayerProxyServer from "./PlayerProxyServer";
|
||||||
|
|
||||||
const endpointDurationSummary = new Summary(
|
const endpointDurationSummary = new Summary(
|
||||||
"clusterio_controller_http_endpoint_duration_seconds",
|
"clusterio_controller_http_endpoint_duration_seconds",
|
||||||
@@ -76,6 +78,8 @@ export default class Controller {
|
|||||||
trustedProxies: BlockList;
|
trustedProxies: BlockList;
|
||||||
debugEvents = new events.EventEmitter<ControllerDebugEvents>();
|
debugEvents = new events.EventEmitter<ControllerDebugEvents>();
|
||||||
private _events = new events.EventEmitter<ControllerEvents>();
|
private _events = new events.EventEmitter<ControllerEvents>();
|
||||||
|
playerRoutingStore: PlayerRoutingStore;
|
||||||
|
playerProxyServer: PlayerProxyServer | null = null;
|
||||||
|
|
||||||
/** Event subscription controller */
|
/** Event subscription controller */
|
||||||
subscriptions = new lib.SubscriptionController();
|
subscriptions = new lib.SubscriptionController();
|
||||||
@@ -209,6 +213,9 @@ export default class Controller {
|
|||||||
this.clusterLogger = clusterLogger;
|
this.clusterLogger = clusterLogger;
|
||||||
this.pluginInfos = pluginInfos;
|
this.pluginInfos = pluginInfos;
|
||||||
this.config = config;
|
this.config = config;
|
||||||
|
this.playerRoutingStore = new PlayerRoutingStore(
|
||||||
|
path.join(this.config.get("controller.database_directory"), "player-routing.json"),
|
||||||
|
);
|
||||||
|
|
||||||
this.app = express();
|
this.app = express();
|
||||||
this.app.locals.controller = this;
|
this.app.locals.controller = this;
|
||||||
@@ -342,6 +349,17 @@ export default class Controller {
|
|||||||
// Load plugins
|
// Load plugins
|
||||||
await this.loadPlugins();
|
await this.loadPlugins();
|
||||||
|
|
||||||
|
await this.playerRoutingStore.load();
|
||||||
|
const playerProxyPort = this.config.get("controller.player_proxy_port");
|
||||||
|
if (playerProxyPort !== null) {
|
||||||
|
this.playerProxyServer = new PlayerProxyServer(
|
||||||
|
this,
|
||||||
|
logger.child({ component: "player_proxy" }),
|
||||||
|
playerProxyPort,
|
||||||
|
);
|
||||||
|
await this.playerProxyServer.start();
|
||||||
|
}
|
||||||
|
|
||||||
// Log all express errors from middleware or routes (app.use is missing the type overload)
|
// Log all express errors from middleware or routes (app.use is missing the type overload)
|
||||||
this.app.use((err: Error, req: express.Request, res: express.Response, next: express.NextFunction): void => {
|
this.app.use((err: Error, req: express.Request, res: express.Response, next: express.NextFunction): void => {
|
||||||
logger.log({
|
logger.log({
|
||||||
@@ -484,6 +502,11 @@ export default class Controller {
|
|||||||
|
|
||||||
await lib.invokeHook(this.plugins, "onShutdown");
|
await lib.invokeHook(this.plugins, "onShutdown");
|
||||||
|
|
||||||
|
if (this.playerProxyServer) {
|
||||||
|
await this.playerProxyServer.stop();
|
||||||
|
this.playerProxyServer = null;
|
||||||
|
}
|
||||||
|
|
||||||
await this.wsServer.stop();
|
await this.wsServer.stop();
|
||||||
|
|
||||||
let stopTasks = [];
|
let stopTasks = [];
|
||||||
@@ -651,6 +674,8 @@ export default class Controller {
|
|||||||
await this.userManager.save(path.join(this.config.get("controller.database_directory"), "users.json"));
|
await this.userManager.save(path.join(this.config.get("controller.database_directory"), "users.json"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await this.playerRoutingStore.save();
|
||||||
|
|
||||||
await lib.invokeHook(this.plugins, "onSaveData");
|
await lib.invokeHook(this.plugins, "onSaveData");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -860,6 +885,87 @@ export default class Controller {
|
|||||||
return hosts.length ? new lib.HostUpdatesEvent(hosts) : null;
|
return hosts.length ? new lib.HostUpdatesEvent(hosts) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
resolvePlayerRoute(username: string): { hostId: number; instanceId: number; publicAddress: string; gamePort: number } | null {
|
||||||
|
const key = username.toLowerCase();
|
||||||
|
let route = this.playerRoutingStore.get(key);
|
||||||
|
let instance: InstanceInfo | undefined;
|
||||||
|
|
||||||
|
if (!route) {
|
||||||
|
const fallbackId = this.config.get("controller.player_proxy_default_instance_id");
|
||||||
|
if (fallbackId !== null) {
|
||||||
|
instance = this.instances.get(fallbackId);
|
||||||
|
if (instance) {
|
||||||
|
route = {
|
||||||
|
name: username,
|
||||||
|
instanceId: instance.id,
|
||||||
|
hostId: instance.config.get("instance.assigned_host") ?? -1,
|
||||||
|
gamePort: instance.gamePort ?? 0,
|
||||||
|
timestampMs: Date.now(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
instance = this._chooseFallbackInstance();
|
||||||
|
if (instance) {
|
||||||
|
route = {
|
||||||
|
name: username,
|
||||||
|
instanceId: instance.id,
|
||||||
|
hostId: instance.config.get("instance.assigned_host") ?? -1,
|
||||||
|
gamePort: instance.gamePort ?? 0,
|
||||||
|
timestampMs: Date.now(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!route) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
instance = instance ?? this.instances.get(route.instanceId);
|
||||||
|
if (!instance || instance.isDeleted) {
|
||||||
|
this.playerRoutingStore.delete(key);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hostId = instance.config.get("instance.assigned_host");
|
||||||
|
if (hostId === null || route.hostId !== hostId) {
|
||||||
|
this.playerRoutingStore.delete(key);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const host = this.hosts.get(hostId);
|
||||||
|
if (!host || !host.connected) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const gamePort = instance.gamePort ?? route.gamePort;
|
||||||
|
const publicAddress = host.publicAddress || host.remoteAddress;
|
||||||
|
if (!gamePort || !publicAddress) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
hostId,
|
||||||
|
instanceId: instance.id,
|
||||||
|
publicAddress,
|
||||||
|
gamePort,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private _chooseFallbackInstance() {
|
||||||
|
for (const instance of this.instances.values()) {
|
||||||
|
if (instance.status === "running" && !instance.isDeleted) {
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const instance of this.instances.values()) {
|
||||||
|
if (!instance.isDeleted && instance.config.get("instance.assigned_host") !== null) {
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get instance by ID for a request
|
* Get instance by ID for a request
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ export default class HostConnection extends BaseConnection {
|
|||||||
|
|
||||||
this.info.connected = false;
|
this.info.connected = false;
|
||||||
this._controller.hosts.set(this.info);
|
this._controller.hosts.set(this.info);
|
||||||
|
this._controller.playerRoutingStore.dropHost(this.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
this.handle(lib.GetFactorioCredentialsRequest, this.handleGetFactorioCredentialsRequest.bind(this));
|
this.handle(lib.GetFactorioCredentialsRequest, this.handleGetFactorioCredentialsRequest.bind(this));
|
||||||
@@ -81,6 +82,7 @@ export default class HostConnection extends BaseConnection {
|
|||||||
this.handle(lib.InstanceSaveDetailsUpdatesEvent, this.handleInstanceSaveDetailsUpdatesEvent.bind(this));
|
this.handle(lib.InstanceSaveDetailsUpdatesEvent, this.handleInstanceSaveDetailsUpdatesEvent.bind(this));
|
||||||
this.handle(lib.LogMessageEvent, this.handleLogMessageEvent.bind(this));
|
this.handle(lib.LogMessageEvent, this.handleLogMessageEvent.bind(this));
|
||||||
this.handle(lib.InstancePlayerUpdateEvent, this.handleInstancePlayerUpdateEvent.bind(this));
|
this.handle(lib.InstancePlayerUpdateEvent, this.handleInstancePlayerUpdateEvent.bind(this));
|
||||||
|
this.handle(lib.InstancePlayerRouteUpdateEvent, this.handleInstancePlayerRouteUpdateEvent.bind(this));
|
||||||
this.snoopEvent(lib.InstanceAdminlistUpdateEvent, this.snoopInstanceAdminlistUpdateEvent.bind(this));
|
this.snoopEvent(lib.InstanceAdminlistUpdateEvent, this.snoopInstanceAdminlistUpdateEvent.bind(this));
|
||||||
this.snoopEvent(lib.InstanceBanlistUpdateEvent, this.snoopInstanceBanlistUpdateEvent.bind(this));
|
this.snoopEvent(lib.InstanceBanlistUpdateEvent, this.snoopInstanceBanlistUpdateEvent.bind(this));
|
||||||
this.snoopEvent(lib.InstanceWhitelistUpdateEvent, this.snoopInstanceWhitelistUpdateEvent.bind(this));
|
this.snoopEvent(lib.InstanceWhitelistUpdateEvent, this.snoopInstanceWhitelistUpdateEvent.bind(this));
|
||||||
@@ -354,6 +356,30 @@ export default class HostConnection extends BaseConnection {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async handleInstancePlayerRouteUpdateEvent(event: lib.InstancePlayerRouteUpdateEvent, src: lib.Address) {
|
||||||
|
const instanceId = src.id;
|
||||||
|
if (event.instanceId !== instanceId) {
|
||||||
|
logger.warn(
|
||||||
|
`Received route update for instance ${event.instanceId} from ${instanceId}, ignoring mismatched id`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.hostId !== this.id) {
|
||||||
|
logger.warn(
|
||||||
|
`Received route update for host ${event.hostId} from ${this.id}, ignoring mismatched host`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._controller.playerRoutingStore.update(
|
||||||
|
event.name,
|
||||||
|
instanceId,
|
||||||
|
this.id,
|
||||||
|
event.gamePort,
|
||||||
|
event.timestampMs,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async snoopInstanceAdminlistUpdateEvent(event: lib.InstanceAdminlistUpdateEvent, src: lib.Address) {
|
async snoopInstanceAdminlistUpdateEvent(event: lib.InstanceAdminlistUpdateEvent, src: lib.Address) {
|
||||||
if (src.type !== lib.Address.instance) {
|
if (src.type !== lib.Address.instance) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -0,0 +1,511 @@
|
|||||||
|
import dgram from "dgram";
|
||||||
|
|
||||||
|
import * as lib from "@clusterio/lib";
|
||||||
|
|
||||||
|
import type Controller from "./Controller";
|
||||||
|
|
||||||
|
const TYPE_MASK = 0x1f;
|
||||||
|
const FLAG_MASK = 0xe0;
|
||||||
|
|
||||||
|
const TYPE_CONNECTION_REQUEST = 2;
|
||||||
|
const TYPE_CONNECTION_REQUEST_REPLY = 3;
|
||||||
|
const TYPE_CONNECTION_REQUEST_REPLY_CONFIRM = 4;
|
||||||
|
|
||||||
|
const SESSION_IDLE_TIMEOUT_MS = 30_000;
|
||||||
|
const MAX_PENDING_PACKETS = 64;
|
||||||
|
const MAX_PACKET_SIZE = 1400;
|
||||||
|
|
||||||
|
type ClientKey = string;
|
||||||
|
|
||||||
|
type RouteTarget = {
|
||||||
|
hostId: number;
|
||||||
|
instanceId: number;
|
||||||
|
publicAddress: string;
|
||||||
|
gamePort: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ClientSession = {
|
||||||
|
clientAddress: string;
|
||||||
|
clientPort: number;
|
||||||
|
lastSeenMs: number;
|
||||||
|
pending: Buffer[];
|
||||||
|
upstream: dgram.Socket | null;
|
||||||
|
route: RouteTarget | null;
|
||||||
|
requestBuffer?: Buffer;
|
||||||
|
requestFlags?: number;
|
||||||
|
requestMessageIdAndFlags?: number;
|
||||||
|
requestVersion?: ApplicationVersion;
|
||||||
|
clientRequestId?: number;
|
||||||
|
proxyServerId?: number;
|
||||||
|
pendingConfirm?: Buffer;
|
||||||
|
confirmServerIdOffset?: number;
|
||||||
|
serverConnectionId?: number;
|
||||||
|
username?: string;
|
||||||
|
activating?: Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ApplicationVersion = {
|
||||||
|
major: number;
|
||||||
|
minor: number;
|
||||||
|
sub: number;
|
||||||
|
build: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
class BufferReader {
|
||||||
|
private _offset = 0;
|
||||||
|
constructor(private _buffer: Buffer) {}
|
||||||
|
|
||||||
|
get offset() {
|
||||||
|
return this._offset;
|
||||||
|
}
|
||||||
|
|
||||||
|
readUInt8() {
|
||||||
|
const value = this._buffer.readUInt8(this._offset);
|
||||||
|
this._offset += 1;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
readUInt16LE() {
|
||||||
|
const value = this._buffer.readUInt16LE(this._offset);
|
||||||
|
this._offset += 2;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
readUInt32LE() {
|
||||||
|
const value = this._buffer.readUInt32LE(this._offset);
|
||||||
|
this._offset += 4;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
readSpaceOptimized() {
|
||||||
|
const value = this.readUInt8();
|
||||||
|
if (value === 0xff) {
|
||||||
|
return this.readUInt16LE();
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
readContainerSize() {
|
||||||
|
const value = this.readUInt8();
|
||||||
|
if (value === 0xff) {
|
||||||
|
return this.readUInt32LE();
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
readBytes(length: number) {
|
||||||
|
const start = this._offset;
|
||||||
|
const end = start + length;
|
||||||
|
if (end > this._buffer.length) {
|
||||||
|
throw new Error("Buffer too short");
|
||||||
|
}
|
||||||
|
this._offset = end;
|
||||||
|
return this._buffer.slice(start, end);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class BufferWriter {
|
||||||
|
private _parts: Buffer[] = [];
|
||||||
|
|
||||||
|
writeUInt8(value: number) {
|
||||||
|
const buf = Buffer.allocUnsafe(1);
|
||||||
|
buf.writeUInt8(value, 0);
|
||||||
|
this._parts.push(buf);
|
||||||
|
}
|
||||||
|
|
||||||
|
writeUInt16LE(value: number) {
|
||||||
|
const buf = Buffer.allocUnsafe(2);
|
||||||
|
buf.writeUInt16LE(value, 0);
|
||||||
|
this._parts.push(buf);
|
||||||
|
}
|
||||||
|
|
||||||
|
writeUInt32LE(value: number) {
|
||||||
|
const buf = Buffer.allocUnsafe(4);
|
||||||
|
buf.writeUInt32LE(value >>> 0, 0);
|
||||||
|
this._parts.push(buf);
|
||||||
|
}
|
||||||
|
|
||||||
|
writeSpaceOptimized(value: number) {
|
||||||
|
if (value >= 0xff) {
|
||||||
|
this.writeUInt8(0xff);
|
||||||
|
this.writeUInt16LE(value);
|
||||||
|
} else {
|
||||||
|
this.writeUInt8(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
concat() {
|
||||||
|
return Buffer.concat(this._parts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clientKey(address: string, port: number): ClientKey {
|
||||||
|
return `${address}:${port}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class PlayerProxyServer {
|
||||||
|
private _socket: dgram.Socket | null = null;
|
||||||
|
private _sessions = new Map<ClientKey, ClientSession>();
|
||||||
|
private _cleanupTimer: ReturnType<typeof setInterval> | undefined;
|
||||||
|
private _closed = false;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private _controller: Controller,
|
||||||
|
private _logger: lib.Logger,
|
||||||
|
private _port: number,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async start() {
|
||||||
|
if (this._socket || this._closed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const socket = dgram.createSocket("udp4");
|
||||||
|
this._socket = socket;
|
||||||
|
socket.on("message", this._handleClientMessage.bind(this));
|
||||||
|
socket.on("error", err => {
|
||||||
|
this._logger.error(`Player proxy socket error: ${err.message}`);
|
||||||
|
this.stop().catch(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
socket.once("listening", resolve);
|
||||||
|
socket.once("error", reject);
|
||||||
|
socket.bind(this._port, "0.0.0.0");
|
||||||
|
});
|
||||||
|
|
||||||
|
this._cleanupTimer = setInterval(() => this._cleanupSessions(), SESSION_IDLE_TIMEOUT_MS);
|
||||||
|
this._cleanupTimer.unref();
|
||||||
|
this._logger.info(`Player proxy listening on UDP ${this._port}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async stop() {
|
||||||
|
if (this._closed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this._closed = true;
|
||||||
|
|
||||||
|
if (this._cleanupTimer) {
|
||||||
|
clearInterval(this._cleanupTimer);
|
||||||
|
this._cleanupTimer = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const session of this._sessions.values()) {
|
||||||
|
session.upstream?.close();
|
||||||
|
}
|
||||||
|
this._sessions.clear();
|
||||||
|
|
||||||
|
if (this._socket) {
|
||||||
|
await new Promise<void>(resolve => this._socket!.close(() => resolve()));
|
||||||
|
this._socket = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private _handleClientMessage(message: Buffer, rinfo: dgram.RemoteInfo) {
|
||||||
|
if (this._closed || !this._socket) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = clientKey(rinfo.address, rinfo.port);
|
||||||
|
let session = this._sessions.get(key);
|
||||||
|
if (!session) {
|
||||||
|
session = this._createSession(rinfo.address, rinfo.port);
|
||||||
|
this._sessions.set(key, session);
|
||||||
|
}
|
||||||
|
session.lastSeenMs = Date.now();
|
||||||
|
|
||||||
|
let type: number;
|
||||||
|
try {
|
||||||
|
type = message.readUInt8(0) & TYPE_MASK;
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === TYPE_CONNECTION_REQUEST) {
|
||||||
|
this._handleConnectionRequest(session, message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === TYPE_CONNECTION_REQUEST_REPLY_CONFIRM) {
|
||||||
|
this._handleConnectionRequestReplyConfirm(session, message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!session.route || !session.upstream) {
|
||||||
|
this._queuePending(session, message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._flushPending(session);
|
||||||
|
session.upstream.send(message, session.route.gamePort, session.route.publicAddress);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _createSession(address: string, port: number): ClientSession {
|
||||||
|
return {
|
||||||
|
clientAddress: address,
|
||||||
|
clientPort: port,
|
||||||
|
lastSeenMs: Date.now(),
|
||||||
|
pending: [],
|
||||||
|
upstream: null,
|
||||||
|
route: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private _handleConnectionRequest(session: ClientSession, message: Buffer) {
|
||||||
|
let parsed: {
|
||||||
|
flags: number;
|
||||||
|
messageIdAndFlags: number;
|
||||||
|
version: ApplicationVersion;
|
||||||
|
requestId: number;
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
parsed = this._parseConnectionRequest(message);
|
||||||
|
} catch (err: any) {
|
||||||
|
this._logger.warn(`Failed to parse connection request: ${err.message ?? err}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
session.requestBuffer = message;
|
||||||
|
session.requestFlags = parsed.flags;
|
||||||
|
session.requestMessageIdAndFlags = parsed.messageIdAndFlags;
|
||||||
|
session.requestVersion = parsed.version;
|
||||||
|
session.clientRequestId = parsed.requestId;
|
||||||
|
if (!session.proxyServerId) {
|
||||||
|
session.proxyServerId = Math.floor(Math.random() * 2 ** 32);
|
||||||
|
}
|
||||||
|
|
||||||
|
const reply = this._buildConnectionRequestReply(session);
|
||||||
|
this._socket?.send(reply, session.clientPort, session.clientAddress);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _handleConnectionRequestReplyConfirm(session: ClientSession, message: Buffer) {
|
||||||
|
let parsed: { username: string; serverIdOffset: number };
|
||||||
|
try {
|
||||||
|
parsed = this._parseConnectionRequestReplyConfirm(message);
|
||||||
|
} catch (err: any) {
|
||||||
|
this._logger.warn(`Failed to parse connection confirm: ${err.message ?? err}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
session.username = parsed.username;
|
||||||
|
session.pendingConfirm = message;
|
||||||
|
session.confirmServerIdOffset = parsed.serverIdOffset;
|
||||||
|
|
||||||
|
if (session.route || session.activating) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
session.activating = this._activateRoute(session).finally(() => {
|
||||||
|
session.activating = undefined;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private _buildConnectionRequestReply(session: ClientSession) {
|
||||||
|
const flags = session.requestFlags ?? 0;
|
||||||
|
const version = session.requestVersion ?? { major: 0, minor: 0, sub: 0, build: 0 };
|
||||||
|
const requestId = session.clientRequestId ?? 0;
|
||||||
|
const serverId = session.proxyServerId ?? 0;
|
||||||
|
|
||||||
|
const writer = new BufferWriter();
|
||||||
|
writer.writeUInt8((flags & FLAG_MASK) | TYPE_CONNECTION_REQUEST_REPLY);
|
||||||
|
writer.writeSpaceOptimized(version.major);
|
||||||
|
writer.writeSpaceOptimized(version.minor);
|
||||||
|
writer.writeSpaceOptimized(version.sub);
|
||||||
|
writer.writeUInt32LE(version.build);
|
||||||
|
writer.writeUInt32LE(requestId);
|
||||||
|
writer.writeUInt32LE(serverId);
|
||||||
|
writer.writeUInt32LE(MAX_PACKET_SIZE);
|
||||||
|
return writer.concat();
|
||||||
|
}
|
||||||
|
|
||||||
|
private _parseConnectionRequest(buffer: Buffer) {
|
||||||
|
const reader = new BufferReader(buffer);
|
||||||
|
const header = reader.readUInt8();
|
||||||
|
const type = header & TYPE_MASK;
|
||||||
|
if (type !== TYPE_CONNECTION_REQUEST) {
|
||||||
|
throw new Error("Unexpected message type");
|
||||||
|
}
|
||||||
|
|
||||||
|
const flags = header & FLAG_MASK;
|
||||||
|
const messageIdAndFlags = reader.readUInt16LE();
|
||||||
|
const version = {
|
||||||
|
major: reader.readSpaceOptimized(),
|
||||||
|
minor: reader.readSpaceOptimized(),
|
||||||
|
sub: reader.readSpaceOptimized(),
|
||||||
|
build: reader.readUInt32LE(),
|
||||||
|
};
|
||||||
|
const requestId = reader.readUInt32LE();
|
||||||
|
return { flags, messageIdAndFlags, version, requestId };
|
||||||
|
}
|
||||||
|
|
||||||
|
private _parseConnectionRequestReplyConfirm(buffer: Buffer) {
|
||||||
|
const reader = new BufferReader(buffer);
|
||||||
|
const header = reader.readUInt8();
|
||||||
|
const type = header & TYPE_MASK;
|
||||||
|
if (type !== TYPE_CONNECTION_REQUEST_REPLY_CONFIRM) {
|
||||||
|
throw new Error("Unexpected message type");
|
||||||
|
}
|
||||||
|
reader.readUInt16LE();
|
||||||
|
reader.readUInt32LE(); // client request id
|
||||||
|
const serverIdOffset = reader.offset;
|
||||||
|
reader.readUInt32LE();
|
||||||
|
reader.readUInt32LE(); // instance id
|
||||||
|
const nameLength = reader.readContainerSize();
|
||||||
|
const nameBuffer = reader.readBytes(nameLength);
|
||||||
|
const username = nameBuffer.toString("utf8");
|
||||||
|
return { username, serverIdOffset };
|
||||||
|
}
|
||||||
|
|
||||||
|
private _handleUpstreamMessage(session: ClientSession, data: Buffer) {
|
||||||
|
if (!this._socket || !session.route) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let type: number;
|
||||||
|
try {
|
||||||
|
type = data.readUInt8(0) & TYPE_MASK;
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === TYPE_CONNECTION_REQUEST_REPLY && session.serverConnectionId === undefined) {
|
||||||
|
try {
|
||||||
|
const serverConnectionId = this._parseConnectionRequestReply(data);
|
||||||
|
session.serverConnectionId = serverConnectionId;
|
||||||
|
this._sendPendingConfirm(session);
|
||||||
|
} catch (err: any) {
|
||||||
|
this._logger.warn(`Failed to parse upstream reply: ${err.message ?? err}`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._socket.send(data, session.clientPort, session.clientAddress);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _parseConnectionRequestReply(buffer: Buffer) {
|
||||||
|
const reader = new BufferReader(buffer);
|
||||||
|
const header = reader.readUInt8();
|
||||||
|
const type = header & TYPE_MASK;
|
||||||
|
if (type !== TYPE_CONNECTION_REQUEST_REPLY) {
|
||||||
|
throw new Error("Unexpected message type");
|
||||||
|
}
|
||||||
|
reader.readSpaceOptimized();
|
||||||
|
reader.readSpaceOptimized();
|
||||||
|
reader.readSpaceOptimized();
|
||||||
|
reader.readUInt32LE();
|
||||||
|
reader.readUInt32LE(); // client request id
|
||||||
|
return reader.readUInt32LE();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _activateRoute(session: ClientSession) {
|
||||||
|
if (!session.username) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const route = this._controller.resolvePlayerRoute(session.username);
|
||||||
|
if (!route) {
|
||||||
|
this._logger.warn(`No route for ${session.username}, dropping client ${session.clientAddress}:${session.clientPort}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const instanceReady = await this._ensureInstanceRunning(route.instanceId);
|
||||||
|
if (!instanceReady) {
|
||||||
|
this._logger.warn(`Instance ${route.instanceId} not ready for ${session.username}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
session.route = route;
|
||||||
|
if (!session.upstream) {
|
||||||
|
const upstream = dgram.createSocket("udp4");
|
||||||
|
session.upstream = upstream;
|
||||||
|
upstream.on("message", data => this._handleUpstreamMessage(session, data));
|
||||||
|
upstream.on("error", err => {
|
||||||
|
this._logger.warn(
|
||||||
|
`Upstream error for ${session.clientAddress}:${session.clientPort}: ${err.message}`
|
||||||
|
);
|
||||||
|
upstream.close();
|
||||||
|
this._sessions.delete(clientKey(session.clientAddress, session.clientPort));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.requestBuffer) {
|
||||||
|
session.upstream.send(session.requestBuffer, route.gamePort, route.publicAddress);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _ensureInstanceRunning(instanceId: number) {
|
||||||
|
const instance = this._controller.instances.get(instanceId);
|
||||||
|
if (!instance || instance.isDeleted) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (instance.status === "running") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (instance.config.get("instance.assigned_host") === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this._controller.sendTo({ instanceId }, new lib.InstanceStartRequest());
|
||||||
|
} catch (err: any) {
|
||||||
|
this._logger.warn(`Failed to start instance ${instanceId}: ${err.message ?? err}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const deadline = Date.now() + SESSION_IDLE_TIMEOUT_MS;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
const current = this._controller.instances.get(instanceId);
|
||||||
|
if (!current || current.isDeleted) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (current.status === "running") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500));
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private _sendPendingConfirm(session: ClientSession) {
|
||||||
|
if (!session.pendingConfirm || session.confirmServerIdOffset === undefined || !session.upstream || !session.route) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.serverConnectionId === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = Buffer.from(session.pendingConfirm);
|
||||||
|
payload.writeUInt32LE(session.serverConnectionId, session.confirmServerIdOffset);
|
||||||
|
session.upstream.send(payload, session.route.gamePort, session.route.publicAddress);
|
||||||
|
session.pendingConfirm = undefined;
|
||||||
|
this._flushPending(session);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _queuePending(session: ClientSession, message: Buffer) {
|
||||||
|
if (session.pending.length >= MAX_PENDING_PACKETS) {
|
||||||
|
session.pending.shift();
|
||||||
|
}
|
||||||
|
session.pending.push(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _flushPending(session: ClientSession) {
|
||||||
|
if (!session.route || !session.upstream || !session.pending.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const pending of session.pending) {
|
||||||
|
session.upstream.send(pending, session.route.gamePort, session.route.publicAddress);
|
||||||
|
}
|
||||||
|
session.pending = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private _cleanupSessions() {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [key, session] of this._sessions) {
|
||||||
|
if (now - session.lastSeenMs > SESSION_IDLE_TIMEOUT_MS) {
|
||||||
|
session.upstream?.close();
|
||||||
|
this._sessions.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import fs from "fs-extra";
|
||||||
|
|
||||||
|
import * as lib from "@clusterio/lib";
|
||||||
|
|
||||||
|
export type StoredRoute = {
|
||||||
|
name: string;
|
||||||
|
instanceId: number;
|
||||||
|
hostId: number;
|
||||||
|
gamePort: number;
|
||||||
|
timestampMs: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default class PlayerRoutingStore {
|
||||||
|
private _routes = new Map<string, StoredRoute>();
|
||||||
|
private _dirty = false;
|
||||||
|
|
||||||
|
constructor(private _filePath: string) {}
|
||||||
|
|
||||||
|
get(name: string): StoredRoute | undefined {
|
||||||
|
return this._routes.get(name.toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
update(name: string, instanceId: number, hostId: number, gamePort: number, timestampMs: number) {
|
||||||
|
const key = name.toLowerCase();
|
||||||
|
this._routes.set(key, { name, instanceId, hostId, gamePort, timestampMs });
|
||||||
|
this._dirty = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(name: string) {
|
||||||
|
const key = name.toLowerCase();
|
||||||
|
if (this._routes.delete(key)) {
|
||||||
|
this._dirty = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dropHost(hostId: number) {
|
||||||
|
let removed = false;
|
||||||
|
for (const [key, route] of this._routes) {
|
||||||
|
if (route.hostId === hostId) {
|
||||||
|
this._routes.delete(key);
|
||||||
|
removed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (removed) {
|
||||||
|
this._dirty = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async load(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const routes = JSON.parse(await fs.readFile(this._filePath, { encoding: "utf8" })) as StoredRoute[];
|
||||||
|
this._routes.clear();
|
||||||
|
for (const route of routes) {
|
||||||
|
if (!route || !route.name) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
this._routes.set(route.name.toLowerCase(), route);
|
||||||
|
}
|
||||||
|
this._dirty = false;
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err.code !== "ENOENT") {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async save(): Promise<void> {
|
||||||
|
if (!this._dirty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const payload = JSON.stringify([...this._routes.values()], null, "\t");
|
||||||
|
await lib.safeOutputFile(this._filePath, payload);
|
||||||
|
this._dirty = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import * as lib from "@clusterio/lib";
|
|||||||
import { logger } from "@clusterio/lib";
|
import { logger } from "@clusterio/lib";
|
||||||
|
|
||||||
import type { HostConnector } from "../host";
|
import type { HostConnector } from "../host";
|
||||||
|
import FactorioProxy from "./FactorioProxy";
|
||||||
import Instance from "./Instance";
|
import Instance from "./Instance";
|
||||||
import InstanceConnection from "./InstanceConnection";
|
import InstanceConnection from "./InstanceConnection";
|
||||||
import BaseHostPlugin from "./BaseHostPlugin";
|
import BaseHostPlugin from "./BaseHostPlugin";
|
||||||
@@ -266,6 +267,7 @@ export default class Host extends lib.Link {
|
|||||||
instanceConnections = new Map<number, InstanceConnection>();
|
instanceConnections = new Map<number, InstanceConnection>();
|
||||||
discoveredInstanceInfos = new Map<number, { path: string, config: lib.InstanceConfig }>();
|
discoveredInstanceInfos = new Map<number, { path: string, config: lib.InstanceConfig }>();
|
||||||
instanceInfos = new Map<number, { path: string, config: lib.InstanceConfig }>();
|
instanceInfos = new Map<number, { path: string, config: lib.InstanceConfig }>();
|
||||||
|
factorioProxies = new Map<number, FactorioProxy>();
|
||||||
|
|
||||||
adminlist = new Set<string>();
|
adminlist = new Set<string>();
|
||||||
banlist = new Map<string, string>();
|
banlist = new Map<string, string>();
|
||||||
@@ -277,6 +279,7 @@ export default class Host extends lib.Link {
|
|||||||
_startup = true;
|
_startup = true;
|
||||||
_disconnecting = false;
|
_disconnecting = false;
|
||||||
_shuttingDown = false;
|
_shuttingDown = false;
|
||||||
|
_proxyStartPromises = new Map<number, Promise<void>>();
|
||||||
|
|
||||||
static instanceConfigWarning = {
|
static instanceConfigWarning = {
|
||||||
"_warning": "Changes to this file will be overwritten by the controller's copy.",
|
"_warning": "Changes to this file will be overwritten by the controller's copy.",
|
||||||
@@ -690,7 +693,9 @@ export default class Host extends lib.Link {
|
|||||||
new lib.InstanceStatusChangedEvent(
|
new lib.InstanceStatusChangedEvent(
|
||||||
instanceId,
|
instanceId,
|
||||||
instanceConnection ? instanceConnection.instance.status : "stopped",
|
instanceConnection ? instanceConnection.instance.status : "stopped",
|
||||||
instanceConnection ? instanceConnection.instance.server.gamePort : this.gamePort(instanceId),
|
instanceConnection
|
||||||
|
? instanceConnection.instance.server.gamePort
|
||||||
|
: this.publicGamePort(instanceId, instanceInfo),
|
||||||
instanceConnection
|
instanceConnection
|
||||||
? instanceConnection.instance.server.version
|
? instanceConnection.instance.server.version
|
||||||
// TODO: factorio.version is not validated
|
// TODO: factorio.version is not validated
|
||||||
@@ -701,6 +706,8 @@ export default class Host extends lib.Link {
|
|||||||
// Send the new list of saves for this assigned instance to the controller.
|
// Send the new list of saves for this assigned instance to the controller.
|
||||||
await this.sendSaveListUpdate(instanceId, path.join(instanceInfo.path, "saves"));
|
await this.sendSaveListUpdate(instanceId, path.join(instanceInfo.path, "saves"));
|
||||||
|
|
||||||
|
await this._ensureFactorioProxy(instanceId, instanceInfo);
|
||||||
|
|
||||||
// save a copy of the instance config
|
// save a copy of the instance config
|
||||||
await instanceInfo.config.save();
|
await instanceInfo.config.save();
|
||||||
}
|
}
|
||||||
@@ -715,6 +722,10 @@ export default class Host extends lib.Link {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.instanceInfos.delete(instanceId);
|
this.instanceInfos.delete(instanceId);
|
||||||
|
if (this.factorioProxies.has(instanceId)) {
|
||||||
|
await this.factorioProxies.get(instanceId)!.stop();
|
||||||
|
this.factorioProxies.delete(instanceId);
|
||||||
|
}
|
||||||
logger.verbose(`unassigned instance ${instanceInfo.config.get("instance.name")}`);
|
logger.verbose(`unassigned instance ${instanceInfo.config.get("instance.name")}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -735,6 +746,23 @@ export default class Host extends lib.Link {
|
|||||||
return instanceInfo;
|
return instanceInfo;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieved public port for clients to connect to for the given instance.
|
||||||
|
* @param instanceId - ID of the instance to get the port for.
|
||||||
|
* @returns Game port for the instance.
|
||||||
|
*/
|
||||||
|
publicGamePort(instanceId: number, instanceInfo?: { config: lib.InstanceConfig }) {
|
||||||
|
instanceInfo = instanceInfo ?? this.discoveredInstanceInfos.get(instanceId);
|
||||||
|
if (!instanceInfo) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
instanceInfo.config.get("factorio.game_port")
|
||||||
|
?? instanceInfo.config.get("factorio.host_assigned_game_port")
|
||||||
|
?? undefined
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrieved assigned port of the given instance
|
* Retrieved assigned port of the given instance
|
||||||
* @param instanceId - ID of the instance to get the assigned game port of:
|
* @param instanceId - ID of the instance to get the assigned game port of:
|
||||||
@@ -752,6 +780,18 @@ export default class Host extends lib.Link {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ensureGamePortAssigned(instanceId: number, instanceInfo: { config: lib.InstanceConfig }) {
|
||||||
|
const configuredPort = instanceInfo.config.get("factorio.game_port");
|
||||||
|
const assignedPort = instanceInfo.config.get("factorio.host_assigned_game_port");
|
||||||
|
if (configuredPort !== null) {
|
||||||
|
return configuredPort;
|
||||||
|
}
|
||||||
|
if (assignedPort !== null) {
|
||||||
|
return assignedPort;
|
||||||
|
}
|
||||||
|
return this.assignGamePort(instanceId);
|
||||||
|
}
|
||||||
|
|
||||||
assignGamePort(instanceId: number) {
|
assignGamePort(instanceId: number) {
|
||||||
const availablePorts = lib.parseRanges(this.config.get("host.factorio_port_range"), 1, 2 ** 16 - 1);
|
const availablePorts = lib.parseRanges(this.config.get("host.factorio_port_range"), 1, 2 ** 16 - 1);
|
||||||
for (const [id, instance] of this.instanceInfos) {
|
for (const [id, instance] of this.instanceInfos) {
|
||||||
@@ -779,6 +819,42 @@ export default class Host extends lib.Link {
|
|||||||
return newPort;
|
return newPort;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async startInstanceFromProxy(instanceId: number) {
|
||||||
|
const existing = this._proxyStartPromises.get(instanceId);
|
||||||
|
if (existing) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this._shuttingDown || this.recoveryMode) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const promise = (async () => {
|
||||||
|
let instanceConnection = this.instanceConnections.get(instanceId);
|
||||||
|
if (instanceConnection && instanceConnection.instance.status === "running") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!instanceConnection) {
|
||||||
|
instanceConnection = await this._connectInstance(instanceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (instanceConnection.instance.status !== "running") {
|
||||||
|
await instanceConnection.instance.handleInstanceStartRequest(new lib.InstanceStartRequest());
|
||||||
|
}
|
||||||
|
})().catch(err => {
|
||||||
|
logger.error(
|
||||||
|
`Error starting instance from proxy:\n${err.stack ?? err.message ?? err}`,
|
||||||
|
this.instanceLogMeta(instanceId)
|
||||||
|
);
|
||||||
|
}).finally(() => {
|
||||||
|
this._proxyStartPromises.delete(instanceId);
|
||||||
|
});
|
||||||
|
|
||||||
|
this._proxyStartPromises.set(instanceId, promise);
|
||||||
|
return promise;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize and connect an unloaded instance
|
* Initialize and connect an unloaded instance
|
||||||
*
|
*
|
||||||
@@ -1146,7 +1222,9 @@ export default class Host extends lib.Link {
|
|||||||
list.push(new lib.HostInstanceUpdate(
|
list.push(new lib.HostInstanceUpdate(
|
||||||
instanceInfo.config.toRemote("controller"),
|
instanceInfo.config.toRemote("controller"),
|
||||||
instanceConnection ? instanceConnection.instance.status : "stopped",
|
instanceConnection ? instanceConnection.instance.status : "stopped",
|
||||||
instanceConnection ? instanceConnection.instance.server.gamePort : this.gamePort(instanceId),
|
instanceConnection
|
||||||
|
? instanceConnection.instance.server.gamePort
|
||||||
|
: this.publicGamePort(instanceId, instanceInfo),
|
||||||
instanceConnection ? instanceConnection.instance.server.version : undefined,
|
instanceConnection ? instanceConnection.instance.server.version : undefined,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
@@ -1174,6 +1252,7 @@ export default class Host extends lib.Link {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async prepareDisconnect() {
|
async prepareDisconnect() {
|
||||||
@@ -1204,6 +1283,15 @@ export default class Host extends lib.Link {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const proxy of this.factorioProxies.values()) {
|
||||||
|
try {
|
||||||
|
await proxy.stop();
|
||||||
|
} catch (err: any) {
|
||||||
|
logger.error(`Unexpected error stopping proxy:\n${err.stack ?? err.message ?? err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.factorioProxies.clear();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.connector.disconnect();
|
await this.connector.disconnect();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
|
|||||||
@@ -398,6 +398,13 @@ export default class Instance extends lib.Link {
|
|||||||
stats,
|
stats,
|
||||||
};
|
};
|
||||||
this.sendTo("controller", new lib.InstancePlayerUpdateEvent("join", name, stats));
|
this.sendTo("controller", new lib.InstancePlayerUpdateEvent("join", name, stats));
|
||||||
|
const hostId = this.config.get("instance.assigned_host");
|
||||||
|
if (hostId !== null) {
|
||||||
|
this.sendTo(
|
||||||
|
"controller",
|
||||||
|
new lib.InstancePlayerRouteUpdateEvent(name, this.id, hostId, this.server.gamePort, Date.now()),
|
||||||
|
);
|
||||||
|
}
|
||||||
lib.invokeHook(this.plugins, "onPlayerEvent", event);
|
lib.invokeHook(this.plugins, "onPlayerEvent", event);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -548,12 +555,13 @@ end`.replace(/\r?\n/g, " ");
|
|||||||
|
|
||||||
notifyStatus(status: Instance["_status"]) {
|
notifyStatus(status: Instance["_status"]) {
|
||||||
this._status = status;
|
this._status = status;
|
||||||
|
const publicPort = this.server.gamePort;
|
||||||
this.sendTo(
|
this.sendTo(
|
||||||
"controller",
|
"controller",
|
||||||
new lib.InstanceStatusChangedEvent(
|
new lib.InstanceStatusChangedEvent(
|
||||||
this.id,
|
this.id,
|
||||||
status,
|
status,
|
||||||
this.server.gamePort,
|
publicPort,
|
||||||
// TODO: factorio.version is not validated
|
// TODO: factorio.version is not validated
|
||||||
status === "running" ? this.server.version : this.config.get("factorio.version") as lib.TargetVersion,
|
status === "running" ? this.server.version : this.config.get("factorio.version") as lib.TargetVersion,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1363,7 +1363,40 @@ export class FactorioServer extends events.EventEmitter<FactorioServerEvents> {
|
|||||||
if (process.platform === "darwin") {
|
if (process.platform === "darwin") {
|
||||||
return this.dataPath("..", "MacOS", "factorio");
|
return this.dataPath("..", "MacOS", "factorio");
|
||||||
}
|
}
|
||||||
return this.dataPath("..", "bin", "x64", "factorio");
|
const binDir = this.dataPath("..", "bin", "x64");
|
||||||
|
const runPath = path.join(binDir, "factorio-run");
|
||||||
|
if (fs.existsSync(runPath)) {
|
||||||
|
return runPath;
|
||||||
|
}
|
||||||
|
const runPathExe = `${runPath}.exe`;
|
||||||
|
if (fs.existsSync(runPathExe)) {
|
||||||
|
return runPathExe;
|
||||||
|
}
|
||||||
|
const factorioPath = path.join(binDir, "factorio");
|
||||||
|
if (fs.existsSync(factorioPath)) {
|
||||||
|
return factorioPath;
|
||||||
|
}
|
||||||
|
const factorioPathExe = `${factorioPath}.exe`;
|
||||||
|
if (fs.existsSync(factorioPathExe)) {
|
||||||
|
return factorioPathExe;
|
||||||
|
}
|
||||||
|
const directRunPath = path.join(this._factorioDir, "factorio-run");
|
||||||
|
if (fs.existsSync(directRunPath)) {
|
||||||
|
return directRunPath;
|
||||||
|
}
|
||||||
|
const directRunPathExe = `${directRunPath}.exe`;
|
||||||
|
if (fs.existsSync(directRunPathExe)) {
|
||||||
|
return directRunPathExe;
|
||||||
|
}
|
||||||
|
const directFactorioPath = path.join(this._factorioDir, "factorio");
|
||||||
|
if (fs.existsSync(directFactorioPath)) {
|
||||||
|
return directFactorioPath;
|
||||||
|
}
|
||||||
|
const directFactorioPathExe = `${directFactorioPath}.exe`;
|
||||||
|
if (fs.existsSync(directFactorioPathExe)) {
|
||||||
|
return directFactorioPathExe;
|
||||||
|
}
|
||||||
|
return factorioPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ export interface ControllerConfigFields {
|
|||||||
"controller.metrics_timeout": number;
|
"controller.metrics_timeout": number;
|
||||||
"controller.system_metrics_interval": number;
|
"controller.system_metrics_interval": number;
|
||||||
"controller.proxy_stream_timeout": number;
|
"controller.proxy_stream_timeout": number;
|
||||||
|
"controller.player_proxy_port": number | null;
|
||||||
|
"controller.player_proxy_default_instance_id": number | null;
|
||||||
"controller.factorio_username": string | null;
|
"controller.factorio_username": string | null;
|
||||||
"controller.factorio_token": string | null;
|
"controller.factorio_token": string | null;
|
||||||
"controller.share_factorio_credentials_with_hosts": boolean;
|
"controller.share_factorio_credentials_with_hosts": boolean;
|
||||||
@@ -176,6 +178,20 @@ export class ControllerConfig extends classes.Config<ControllerConfigFields> {
|
|||||||
type: "number",
|
type: "number",
|
||||||
initialValue: 15,
|
initialValue: 15,
|
||||||
},
|
},
|
||||||
|
"controller.player_proxy_port": {
|
||||||
|
title: "Player Proxy Port",
|
||||||
|
description: "UDP port to accept player connections for the controller proxy, set to null to disable.",
|
||||||
|
restartRequired: true,
|
||||||
|
type: "number",
|
||||||
|
optional: true,
|
||||||
|
initialValue: 34197,
|
||||||
|
},
|
||||||
|
"controller.player_proxy_default_instance_id": {
|
||||||
|
title: "Player Proxy Default Instance",
|
||||||
|
description: "Instance ID to route players to when no routing history is available.",
|
||||||
|
type: "number",
|
||||||
|
optional: true,
|
||||||
|
},
|
||||||
"controller.factorio_username": {
|
"controller.factorio_username": {
|
||||||
title: "Factorio Username",
|
title: "Factorio Username",
|
||||||
description: "Username to authenticate with Factorio API with.",
|
description: "Username to authenticate with Factorio API with.",
|
||||||
@@ -407,6 +423,7 @@ export interface InstanceConfigFields {
|
|||||||
"factorio.executable_path": string | null;
|
"factorio.executable_path": string | null;
|
||||||
"factorio.shutdown_timeout": number;
|
"factorio.shutdown_timeout": number;
|
||||||
"factorio.game_port": number | null;
|
"factorio.game_port": number | null;
|
||||||
|
"factorio.proxy_port": number | null;
|
||||||
"factorio.host_assigned_game_port": number | null;
|
"factorio.host_assigned_game_port": number | null;
|
||||||
"factorio.rcon_port": number | null;
|
"factorio.rcon_port": number | null;
|
||||||
"factorio.rcon_password": string | null;
|
"factorio.rcon_password": string | null;
|
||||||
@@ -503,6 +520,13 @@ export class InstanceConfig extends classes.Config<InstanceConfigFields> {
|
|||||||
type: "number",
|
type: "number",
|
||||||
optional: true,
|
optional: true,
|
||||||
},
|
},
|
||||||
|
"factorio.proxy_port": {
|
||||||
|
description:
|
||||||
|
"UDP port to accept client connections on. When set, the host will proxy traffic to " +
|
||||||
|
"the instance game port and start the instance on first packet.",
|
||||||
|
type: "number",
|
||||||
|
optional: true,
|
||||||
|
},
|
||||||
"factorio.host_assigned_game_port": {
|
"factorio.host_assigned_game_port": {
|
||||||
access: ["host"],
|
access: ["host"],
|
||||||
type: "number",
|
type: "number",
|
||||||
|
|||||||
@@ -976,3 +976,30 @@ export class InstancePlayerUpdateEvent {
|
|||||||
return new this(json.type, json.name, PlayerStats.fromJSON(json.stats), json.reason);
|
return new this(json.type, json.name, PlayerStats.fromJSON(json.stats), json.reason);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class InstancePlayerRouteUpdateEvent {
|
||||||
|
declare ["constructor"]: typeof InstancePlayerRouteUpdateEvent;
|
||||||
|
static type = "event" as const;
|
||||||
|
static src = "instance" as const;
|
||||||
|
static dst = "controller" as const;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
public name: string,
|
||||||
|
public instanceId: number,
|
||||||
|
public hostId: number,
|
||||||
|
public gamePort: number,
|
||||||
|
public timestampMs: number,
|
||||||
|
) { }
|
||||||
|
|
||||||
|
static jsonSchema = Type.Object({
|
||||||
|
"name": Type.String(),
|
||||||
|
"instanceId": Type.Integer(),
|
||||||
|
"hostId": Type.Integer(),
|
||||||
|
"gamePort": Type.Integer(),
|
||||||
|
"timestampMs": Type.Number(),
|
||||||
|
});
|
||||||
|
|
||||||
|
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||||
|
return new this(json.name, json.instanceId, json.hostId, json.gamePort, json.timestampMs);
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
import { Static, Type } from "@sinclair/typebox";
|
import { Static, Type } from "@sinclair/typebox";
|
||||||
import { FullVersionSchema, integerFullVersion, isFullVersion } from "../data/version";
|
import { FullVersionSchema, integerFullVersion, isFullVersion } from "../data";
|
||||||
|
|
||||||
const ARCHIVE_URL = "https://factorio.com/download/archive";
|
const ARCHIVE_URL = "https://factorio.com/download/archive";
|
||||||
|
|
||||||
|
|||||||
@@ -145,7 +145,7 @@ function readMapPosition(state: MapReaderState) {
|
|||||||
y = state.last_position.y + y_diff;
|
y = state.last_position.y + y_diff;
|
||||||
}
|
}
|
||||||
state.last_position.x = x;
|
state.last_position.x = x;
|
||||||
state.last_position.x = y;
|
state.last_position.y = y;
|
||||||
return { x, y };
|
return { x, y };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ export const dataClasses: (RequestClass<unknown, unknown> | EventClass<unknown>)
|
|||||||
instance.InstanceAdminlistUpdateEvent,
|
instance.InstanceAdminlistUpdateEvent,
|
||||||
instance.InstanceWhitelistUpdateEvent,
|
instance.InstanceWhitelistUpdateEvent,
|
||||||
instance.InstancePlayerUpdateEvent,
|
instance.InstancePlayerUpdateEvent,
|
||||||
|
instance.InstancePlayerRouteUpdateEvent,
|
||||||
|
|
||||||
mod.ModPackGetRequest,
|
mod.ModPackGetRequest,
|
||||||
mod.ModPackGetDefaultRequest,
|
mod.ModPackGetDefaultRequest,
|
||||||
|
|||||||
@@ -275,9 +275,6 @@ function keyToLabels(key: string) {
|
|||||||
if (key !== "") {
|
if (key !== "") {
|
||||||
for (let pair of key.split(",")) {
|
for (let pair of key.split(",")) {
|
||||||
let [name, value] = pair.split("=", 2);
|
let [name, value] = pair.split("=", 2);
|
||||||
if (!name || value === undefined) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
labels.set(name, value
|
labels.set(name, value
|
||||||
.slice(1, -1)
|
.slice(1, -1)
|
||||||
.replace(/\\"/g, "\"")
|
.replace(/\\"/g, "\"")
|
||||||
|
|||||||
@@ -195,6 +195,7 @@ export class SubscriptionController {
|
|||||||
throw new Error(`Event ${event.eventName} is not a registered as subscribable`);
|
throw new Error(`Event ${event.eventName} is not a registered as subscribable`);
|
||||||
}
|
}
|
||||||
if (event.subscribe === false) {
|
if (event.subscribe === false) {
|
||||||
|
// Unsubscribe logic
|
||||||
const subscriber = eventData.subscriptions.find(sub => sub.dst.addressedTo(src));
|
const subscriber = eventData.subscriptions.find(sub => sub.dst.addressedTo(src));
|
||||||
if (!subscriber) {
|
if (!subscriber) {
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React, { useContext, useEffect, useState } from "react";
|
|||||||
import { TreeSelect } from "antd";
|
import { TreeSelect } from "antd";
|
||||||
|
|
||||||
import * as lib from "@clusterio/lib";
|
import * as lib from "@clusterio/lib";
|
||||||
import type { InputComponentProps } from "../BaseWebPlugin";
|
import { InputComponentProps } from "../BaseWebPlugin";
|
||||||
import { useAccount } from "../model/account";
|
import { useAccount } from "../model/account";
|
||||||
import ControlContext from "./ControlContext";
|
import ControlContext from "./ControlContext";
|
||||||
|
|
||||||
@@ -18,16 +18,15 @@ export default function InputPartialVersion (
|
|||||||
const [versions, setVersions] = useState<readonly lib.PartialVersion[]>(lib.ApiVersions);
|
const [versions, setVersions] = useState<readonly lib.PartialVersion[]>(lib.ApiVersions);
|
||||||
const control = useContext(ControlContext);
|
const control = useContext(ControlContext);
|
||||||
const account = useAccount();
|
const account = useAccount();
|
||||||
const hasPermission = account.hasPermission("core.external.get_factorio_versions");
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
if (hasPermission) {
|
if (account.hasPermission("core.external.get_factorio_versions")) {
|
||||||
const res = await control.factorioVersions.get(5 * 60 * 1000);
|
const res = await control.factorioVersions.get(5 * 60 * 1000);
|
||||||
setVersions(res.map(v => v.version));
|
setVersions(res.map(v => v.version));
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
}, [control, hasPermission]);
|
}, [control]);
|
||||||
|
|
||||||
// Split the versions into groups based on major minor
|
// Split the versions into groups based on major minor
|
||||||
const groups = new Map<lib.PartialVersion, lib.PartialVersion[]>();
|
const groups = new Map<lib.PartialVersion, lib.PartialVersion[]>();
|
||||||
@@ -43,20 +42,18 @@ export default function InputPartialVersion (
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Construct the tree to be disabled
|
// 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,
|
title: majorMinor,
|
||||||
value: majorMinor,
|
value: majorMinor,
|
||||||
key: groupKey,
|
key: majorMinor,
|
||||||
children: patchVersions.map((v) => ({
|
children: patchVersions.map((v) => ({
|
||||||
title: v,
|
title: v,
|
||||||
value: v,
|
value: v,
|
||||||
key: v,
|
key: v,
|
||||||
})),
|
})),
|
||||||
};
|
}
|
||||||
});
|
));
|
||||||
|
|
||||||
return <TreeSelect
|
return <TreeSelect
|
||||||
showSearch
|
showSearch
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React, { useContext, useEffect, useState } from "react";
|
|||||||
import { TreeSelect } from "antd";
|
import { TreeSelect } from "antd";
|
||||||
|
|
||||||
import * as lib from "@clusterio/lib";
|
import * as lib from "@clusterio/lib";
|
||||||
import type { InputComponentProps } from "../BaseWebPlugin";
|
import { InputComponentProps } from "../BaseWebPlugin";
|
||||||
import { useAccount } from "../model/account";
|
import { useAccount } from "../model/account";
|
||||||
import ControlContext from "./ControlContext";
|
import ControlContext from "./ControlContext";
|
||||||
|
|
||||||
@@ -18,16 +18,15 @@ export default function InputTargetVersion (
|
|||||||
const [versions, setVersions] = useState<readonly lib.TargetVersion[]>(lib.ApiVersions);
|
const [versions, setVersions] = useState<readonly lib.TargetVersion[]>(lib.ApiVersions);
|
||||||
const control = useContext(ControlContext);
|
const control = useContext(ControlContext);
|
||||||
const account = useAccount();
|
const account = useAccount();
|
||||||
const hasPermission = account.hasPermission("core.external.get_factorio_versions");
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
if (hasPermission) {
|
if (account.hasPermission("core.external.get_factorio_versions")) {
|
||||||
const res = await control.factorioVersions.get(5 * 60 * 1000);
|
const res = await control.factorioVersions.get(5 * 60 * 1000);
|
||||||
setVersions(res.map(v => v.version));
|
setVersions(res.map(v => v.version));
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
}, [control, hasPermission]);
|
}, [control]);
|
||||||
|
|
||||||
// Split the versions into groups based on major minor
|
// Split the versions into groups based on major minor
|
||||||
const groups = new Map<lib.TargetVersion, lib.TargetVersion[]>();
|
const groups = new Map<lib.TargetVersion, lib.TargetVersion[]>();
|
||||||
@@ -43,20 +42,18 @@ export default function InputTargetVersion (
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Construct the tree to be disabled
|
// 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,
|
title: majorMinor,
|
||||||
value: majorMinor,
|
value: majorMinor,
|
||||||
key: groupKey,
|
key: majorMinor,
|
||||||
children: patchVersions.map((v) => ({
|
children: patchVersions.map((v) => ({
|
||||||
title: v,
|
title: v,
|
||||||
value: v,
|
value: v,
|
||||||
key: v,
|
key: v,
|
||||||
})),
|
})),
|
||||||
};
|
}
|
||||||
});
|
));
|
||||||
|
|
||||||
// Add "latest" option for TargetVersion
|
// Add "latest" option for TargetVersion
|
||||||
tree.unshift({ title: "latest", value: "latest", key: "latest", children: [] });
|
tree.unshift({ title: "latest", value: "latest", key: "latest", children: [] });
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
node_modules/
|
|
||||||
*.ts
|
|
||||||
!*.d.ts
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
# 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
@@ -1,88 +0,0 @@
|
|||||||
# 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) |
|
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
# 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
|
|
||||||
```
|
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
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"],
|
|
||||||
};
|
|
||||||
@@ -1,227 +0,0 @@
|
|||||||
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}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,464 +0,0 @@
|
|||||||
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()]),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
@@ -1,591 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "minimap",
|
|
||||||
"load": ["minimap.lua"]
|
|
||||||
}
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
{
|
|
||||||
"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/**/*"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
{
|
|
||||||
"files": [],
|
|
||||||
"references": [
|
|
||||||
{ "path": "./tsconfig.node.json" },
|
|
||||||
{ "path": "./tsconfig.browser.json" }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "../../tsconfig.node.json",
|
|
||||||
"compilerOptions": {
|
|
||||||
"outDir": "dist/node"
|
|
||||||
},
|
|
||||||
"exclude": [
|
|
||||||
"web/**/*",
|
|
||||||
"dist/**/*"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
// 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);
|
|
||||||
}
|
|
||||||
@@ -1,315 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,871 +0,0 @@
|
|||||||
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
@@ -1,199 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,221 +0,0 @@
|
|||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
"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",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
+34
-36
@@ -1,42 +1,40 @@
|
|||||||
packages:
|
packages:
|
||||||
- packages/*
|
- "packages/*"
|
||||||
- plugins/*
|
- "plugins/*"
|
||||||
- external_plugins/*
|
- "external_plugins/*"
|
||||||
- external_plugins/*/*
|
- "external_plugins/*/*"
|
||||||
- test/
|
- "test/"
|
||||||
|
|
||||||
catalog:
|
catalog:
|
||||||
'@ant-design/icons': ^5.6.1
|
"@types/node": ^20.17.19
|
||||||
'@sinclair/typebox': ^0.30.4
|
"@types/fs-extra": ^11.0.1
|
||||||
'@swc/core': ^1.13.3
|
"@types/express": ^4.17.17
|
||||||
'@types/express': ^4.17.17
|
"@types/finalhandler": ^1.2.0
|
||||||
'@types/finalhandler': ^1.2.0
|
"@types/jsonwebtoken": ^9.0.9
|
||||||
'@types/fs-extra': ^11.0.1
|
"@types/set-blocking": ^2.0.0
|
||||||
'@types/jsonwebtoken': ^9.0.9
|
"@types/react": ^18.3.18
|
||||||
'@types/node': ^20.17.19
|
"@types/ws": ^8.5.14
|
||||||
'@types/react': ^18.3.18
|
"@sinclair/typebox": ^0.30.4
|
||||||
'@types/set-blocking': ^2.0.0
|
"@swc/core": ^1.13.3
|
||||||
'@types/ws': ^8.5.14
|
"antd": ^5.24.2
|
||||||
antd: ^5.24.2
|
"react": ^18.2.0
|
||||||
chalk: ^4.1.2
|
"react-dom": ^18.2.0
|
||||||
express: ^5.1.0
|
"react-router-dom": ^6.29.0
|
||||||
fs-extra: ^8.1.0
|
"typescript": ^5.7.3
|
||||||
jsonwebtoken: ^9.0.2
|
"webpack": ^5.98.0
|
||||||
jszip: ^3.10.1
|
"webpack-cli": ^5.1.4
|
||||||
phin: ^3.7.0
|
"webpack-merge": ^5.9.0
|
||||||
react: ^18.2.0
|
"fs-extra": ^8.1.0
|
||||||
react-dom: ^18.2.0
|
"express": ^5.1.0
|
||||||
react-router-dom: ^6.29.0
|
"winston": ^3.17.0
|
||||||
set-blocking: ^2.0.0
|
"winston-transport": ^4.9.0
|
||||||
typescript: ^5.7.3
|
"winston-daily-rotate-file": ^4.7.1
|
||||||
webpack: ^5.98.0
|
"jsonwebtoken": ^9.0.2
|
||||||
webpack-cli: ^5.1.4
|
"set-blocking": ^2.0.0
|
||||||
webpack-merge: ^5.9.0
|
"jszip": ^3.10.1
|
||||||
winston: ^3.17.0
|
"chalk": ^4.1.2
|
||||||
winston-daily-rotate-file: ^4.7.1
|
"phin": ^3.7.0
|
||||||
winston-transport: ^4.9.0
|
"ws": ^8.18.1
|
||||||
ws: ^8.18.1
|
|
||||||
|
|
||||||
catalogMode: strict
|
catalogMode: strict
|
||||||
|
|
||||||
linkWorkspacePackages: true
|
linkWorkspacePackages: true
|
||||||
@@ -15,12 +15,10 @@ const { selectTargetCommand, initialize: initializeCtl } = require("@clusterio/c
|
|||||||
|
|
||||||
// Make sure permissions from plugins are loaded
|
// Make sure permissions from plugins are loaded
|
||||||
require("../../plugins/global_chat/dist/node/index");
|
require("../../plugins/global_chat/dist/node/index");
|
||||||
require("../../plugins/minimap/dist/node/index");
|
|
||||||
require("../../plugins/player_auth/dist/node/index");
|
require("../../plugins/player_auth/dist/node/index");
|
||||||
require("../../plugins/research_sync/dist/node/index");
|
require("../../plugins/research_sync/dist/node/index");
|
||||||
require("../../plugins/statistics_exporter/dist/node/index");
|
require("../../plugins/statistics_exporter/dist/node/index");
|
||||||
require("../../plugins/subspace_storage/dist/node/index");
|
require("../../plugins/subspace_storage/dist/node/index");
|
||||||
require("../../plugins/inventory_sync/dist/node/index");
|
|
||||||
|
|
||||||
class TestControl extends lib.Link {
|
class TestControl extends lib.Link {
|
||||||
constructor(connector, subscribe = true) {
|
constructor(connector, subscribe = true) {
|
||||||
@@ -331,7 +329,6 @@ before(async function() {
|
|||||||
await execCtlProcess("plugin add ../../plugins/subspace_storage");
|
await execCtlProcess("plugin add ../../plugins/subspace_storage");
|
||||||
await execCtlProcess("plugin add ../../plugins/inventory_sync");
|
await execCtlProcess("plugin add ../../plugins/inventory_sync");
|
||||||
await execCtlProcess("plugin add ../../plugins/player_auth");
|
await execCtlProcess("plugin add ../../plugins/player_auth");
|
||||||
await execCtlProcess("plugin add ../../plugins/minimap");
|
|
||||||
|
|
||||||
console.log("Bootstrapping");
|
console.log("Bootstrapping");
|
||||||
await execController("bootstrap create-admin test");
|
await execController("bootstrap create-admin test");
|
||||||
|
|||||||
@@ -41,7 +41,6 @@ describe("Clusterio Instance", function() {
|
|||||||
|
|
||||||
for (const plugin of [
|
for (const plugin of [
|
||||||
"global_chat", "research_sync", "statistics_exporter", "subspace_storage", "player_auth", "inventory_sync",
|
"global_chat", "research_sync", "statistics_exporter", "subspace_storage", "player_auth", "inventory_sync",
|
||||||
"minimap",
|
|
||||||
]) {
|
]) {
|
||||||
await execCtl(`instance config set ${instName} ${plugin}.load_plugin false`);
|
await execCtl(`instance config set ${instName} ${plugin}.load_plugin false`);
|
||||||
}
|
}
|
||||||
@@ -163,7 +162,7 @@ describe("Clusterio Instance", function() {
|
|||||||
it("should respond to a player whitelist add and remove event", async function() {
|
it("should respond to a player whitelist add and remove event", async function() {
|
||||||
await execCtl(`${instSetConfig} factorio.sync_whitelist bidirectional`);
|
await execCtl(`${instSetConfig} factorio.sync_whitelist bidirectional`);
|
||||||
await sendRcon(instId, "/whitelist add WhitelistPlayer");
|
await sendRcon(instId, "/whitelist add WhitelistPlayer");
|
||||||
await lib.wait(30); // Propagation time, had issues with 15ms in ci so i made it 30ms
|
await lib.wait(15); // Propagation time, 10ms worked for me, so i made it 15ms
|
||||||
const userAdded = await getUser("WhitelistPlayer");
|
const userAdded = await getUser("WhitelistPlayer");
|
||||||
assert(userAdded.isWhitelisted, "Player is not whitelisted");
|
assert(userAdded.isWhitelisted, "Player is not whitelisted");
|
||||||
const isAltAdded = await sendRcon(instAltId, "/whitelist get WhitelistPlayer");
|
const isAltAdded = await sendRcon(instAltId, "/whitelist get WhitelistPlayer");
|
||||||
@@ -173,7 +172,7 @@ describe("Clusterio Instance", function() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
await sendRcon(instId, "/whitelist remove WhitelistPlayer");
|
await sendRcon(instId, "/whitelist remove WhitelistPlayer");
|
||||||
await lib.wait(30); // Propagation time, had issues with 15ms in ci so i made it 30ms
|
await lib.wait(15); // Propagation time, 10ms worked for me, so i made it 15ms
|
||||||
const userRemoved = await getUser("WhitelistPlayer");
|
const userRemoved = await getUser("WhitelistPlayer");
|
||||||
assert(!userRemoved.isWhitelisted, "Player is not unwhitelisted");
|
assert(!userRemoved.isWhitelisted, "Player is not unwhitelisted");
|
||||||
const isAltRemoved = await sendRcon(instAltId, "/whitelist get WhitelistPlayer");
|
const isAltRemoved = await sendRcon(instAltId, "/whitelist get WhitelistPlayer");
|
||||||
|
|||||||
Reference in new issue
Block a user