mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-11 23:04:52 +09:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e0d453159 | ||
|
|
a4e4621eaa | ||
|
|
4337555ea1 | ||
|
|
9018397b09 | ||
|
|
1c9aae5909 |
No files matched your search
+3
-12
@@ -29,22 +29,13 @@ dist/
|
||||
|
||||
node_modules
|
||||
|
||||
Factorio*
|
||||
!*Factorio*.ts
|
||||
!*Factorio*.tsx
|
||||
!*Factorio*.js
|
||||
/factorio
|
||||
!/test/file/factorio
|
||||
!/test/file/html/*.html
|
||||
!/test/lib/factorio
|
||||
!/packages/lib/src/factorio
|
||||
/factorio*
|
||||
/instances
|
||||
/mods
|
||||
/database
|
||||
/plugin-list.json
|
||||
/config-controller.json
|
||||
/config-control.json
|
||||
/config-host.json
|
||||
/config-*.json
|
||||
/config-*.json.lock
|
||||
pnpm-lock.yaml
|
||||
mod_cache.json
|
||||
/static/
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
*
|
||||
!.gitignore
|
||||
!README.md
|
||||
@@ -28,8 +28,6 @@ import HostConnection from "./HostConnection";
|
||||
import HostInfo from "./HostInfo";
|
||||
import BaseControllerPlugin from "./BaseControllerPlugin";
|
||||
import ControllerRouter from "./ControllerRouter";
|
||||
import PlayerRoutingStore from "./PlayerRoutingStore";
|
||||
import PlayerProxyServer from "./PlayerProxyServer";
|
||||
|
||||
const endpointDurationSummary = new Summary(
|
||||
"clusterio_controller_http_endpoint_duration_seconds",
|
||||
@@ -78,8 +76,6 @@ export default class Controller {
|
||||
trustedProxies: BlockList;
|
||||
debugEvents = new events.EventEmitter<ControllerDebugEvents>();
|
||||
private _events = new events.EventEmitter<ControllerEvents>();
|
||||
playerRoutingStore: PlayerRoutingStore;
|
||||
playerProxyServer: PlayerProxyServer | null = null;
|
||||
|
||||
/** Event subscription controller */
|
||||
subscriptions = new lib.SubscriptionController();
|
||||
@@ -213,9 +209,6 @@ export default class Controller {
|
||||
this.clusterLogger = clusterLogger;
|
||||
this.pluginInfos = pluginInfos;
|
||||
this.config = config;
|
||||
this.playerRoutingStore = new PlayerRoutingStore(
|
||||
path.join(this.config.get("controller.database_directory"), "player-routing.json"),
|
||||
);
|
||||
|
||||
this.app = express();
|
||||
this.app.locals.controller = this;
|
||||
@@ -349,17 +342,6 @@ export default class Controller {
|
||||
// Load plugins
|
||||
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)
|
||||
this.app.use((err: Error, req: express.Request, res: express.Response, next: express.NextFunction): void => {
|
||||
logger.log({
|
||||
@@ -502,11 +484,6 @@ export default class Controller {
|
||||
|
||||
await lib.invokeHook(this.plugins, "onShutdown");
|
||||
|
||||
if (this.playerProxyServer) {
|
||||
await this.playerProxyServer.stop();
|
||||
this.playerProxyServer = null;
|
||||
}
|
||||
|
||||
await this.wsServer.stop();
|
||||
|
||||
let stopTasks = [];
|
||||
@@ -674,8 +651,6 @@ export default class Controller {
|
||||
await this.userManager.save(path.join(this.config.get("controller.database_directory"), "users.json"));
|
||||
}
|
||||
|
||||
await this.playerRoutingStore.save();
|
||||
|
||||
await lib.invokeHook(this.plugins, "onSaveData");
|
||||
}
|
||||
|
||||
@@ -885,87 +860,6 @@ export default class Controller {
|
||||
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
|
||||
*
|
||||
|
||||
@@ -72,7 +72,6 @@ export default class HostConnection extends BaseConnection {
|
||||
|
||||
this.info.connected = false;
|
||||
this._controller.hosts.set(this.info);
|
||||
this._controller.playerRoutingStore.dropHost(this.id);
|
||||
});
|
||||
|
||||
this.handle(lib.GetFactorioCredentialsRequest, this.handleGetFactorioCredentialsRequest.bind(this));
|
||||
@@ -82,7 +81,6 @@ export default class HostConnection extends BaseConnection {
|
||||
this.handle(lib.InstanceSaveDetailsUpdatesEvent, this.handleInstanceSaveDetailsUpdatesEvent.bind(this));
|
||||
this.handle(lib.LogMessageEvent, this.handleLogMessageEvent.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.InstanceBanlistUpdateEvent, this.snoopInstanceBanlistUpdateEvent.bind(this));
|
||||
this.snoopEvent(lib.InstanceWhitelistUpdateEvent, this.snoopInstanceWhitelistUpdateEvent.bind(this));
|
||||
@@ -356,30 +354,6 @@ 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) {
|
||||
if (src.type !== lib.Address.instance) {
|
||||
return;
|
||||
|
||||
@@ -1,511 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
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,7 +12,6 @@ import * as lib from "@clusterio/lib";
|
||||
import { logger } from "@clusterio/lib";
|
||||
|
||||
import type { HostConnector } from "../host";
|
||||
import FactorioProxy from "./FactorioProxy";
|
||||
import Instance from "./Instance";
|
||||
import InstanceConnection from "./InstanceConnection";
|
||||
import BaseHostPlugin from "./BaseHostPlugin";
|
||||
@@ -267,7 +266,6 @@ export default class Host extends lib.Link {
|
||||
instanceConnections = new Map<number, InstanceConnection>();
|
||||
discoveredInstanceInfos = 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>();
|
||||
banlist = new Map<string, string>();
|
||||
@@ -279,7 +277,6 @@ export default class Host extends lib.Link {
|
||||
_startup = true;
|
||||
_disconnecting = false;
|
||||
_shuttingDown = false;
|
||||
_proxyStartPromises = new Map<number, Promise<void>>();
|
||||
|
||||
static instanceConfigWarning = {
|
||||
"_warning": "Changes to this file will be overwritten by the controller's copy.",
|
||||
@@ -693,9 +690,7 @@ export default class Host extends lib.Link {
|
||||
new lib.InstanceStatusChangedEvent(
|
||||
instanceId,
|
||||
instanceConnection ? instanceConnection.instance.status : "stopped",
|
||||
instanceConnection
|
||||
? instanceConnection.instance.server.gamePort
|
||||
: this.publicGamePort(instanceId, instanceInfo),
|
||||
instanceConnection ? instanceConnection.instance.server.gamePort : this.gamePort(instanceId),
|
||||
instanceConnection
|
||||
? instanceConnection.instance.server.version
|
||||
// TODO: factorio.version is not validated
|
||||
@@ -706,8 +701,6 @@ export default class Host extends lib.Link {
|
||||
// Send the new list of saves for this assigned instance to the controller.
|
||||
await this.sendSaveListUpdate(instanceId, path.join(instanceInfo.path, "saves"));
|
||||
|
||||
await this._ensureFactorioProxy(instanceId, instanceInfo);
|
||||
|
||||
// save a copy of the instance config
|
||||
await instanceInfo.config.save();
|
||||
}
|
||||
@@ -722,10 +715,6 @@ export default class Host extends lib.Link {
|
||||
}
|
||||
|
||||
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")}`);
|
||||
}
|
||||
}
|
||||
@@ -746,23 +735,6 @@ export default class Host extends lib.Link {
|
||||
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
|
||||
* @param instanceId - ID of the instance to get the assigned game port of:
|
||||
@@ -780,18 +752,6 @@ 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) {
|
||||
const availablePorts = lib.parseRanges(this.config.get("host.factorio_port_range"), 1, 2 ** 16 - 1);
|
||||
for (const [id, instance] of this.instanceInfos) {
|
||||
@@ -819,42 +779,6 @@ export default class Host extends lib.Link {
|
||||
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
|
||||
*
|
||||
@@ -1222,9 +1146,7 @@ export default class Host extends lib.Link {
|
||||
list.push(new lib.HostInstanceUpdate(
|
||||
instanceInfo.config.toRemote("controller"),
|
||||
instanceConnection ? instanceConnection.instance.status : "stopped",
|
||||
instanceConnection
|
||||
? instanceConnection.instance.server.gamePort
|
||||
: this.publicGamePort(instanceId, instanceInfo),
|
||||
instanceConnection ? instanceConnection.instance.server.gamePort : this.gamePort(instanceId),
|
||||
instanceConnection ? instanceConnection.instance.server.version : undefined,
|
||||
));
|
||||
}
|
||||
@@ -1252,7 +1174,6 @@ export default class Host extends lib.Link {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
async prepareDisconnect() {
|
||||
@@ -1283,15 +1204,6 @@ 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 {
|
||||
await this.connector.disconnect();
|
||||
} catch (err: any) {
|
||||
|
||||
@@ -398,13 +398,6 @@ export default class Instance extends lib.Link {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -555,13 +548,12 @@ end`.replace(/\r?\n/g, " ");
|
||||
|
||||
notifyStatus(status: Instance["_status"]) {
|
||||
this._status = status;
|
||||
const publicPort = this.server.gamePort;
|
||||
this.sendTo(
|
||||
"controller",
|
||||
new lib.InstanceStatusChangedEvent(
|
||||
this.id,
|
||||
status,
|
||||
publicPort,
|
||||
this.server.gamePort,
|
||||
// TODO: factorio.version is not validated
|
||||
status === "running" ? this.server.version : this.config.get("factorio.version") as lib.TargetVersion,
|
||||
),
|
||||
|
||||
@@ -1363,40 +1363,7 @@ export class FactorioServer extends events.EventEmitter<FactorioServerEvents> {
|
||||
if (process.platform === "darwin") {
|
||||
return this.dataPath("..", "MacOS", "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;
|
||||
return this.dataPath("..", "bin", "x64", "factorio");
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -24,8 +24,6 @@ export interface ControllerConfigFields {
|
||||
"controller.metrics_timeout": number;
|
||||
"controller.system_metrics_interval": 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_token": string | null;
|
||||
"controller.share_factorio_credentials_with_hosts": boolean;
|
||||
@@ -178,20 +176,6 @@ export class ControllerConfig extends classes.Config<ControllerConfigFields> {
|
||||
type: "number",
|
||||
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": {
|
||||
title: "Factorio Username",
|
||||
description: "Username to authenticate with Factorio API with.",
|
||||
@@ -423,7 +407,6 @@ export interface InstanceConfigFields {
|
||||
"factorio.executable_path": string | null;
|
||||
"factorio.shutdown_timeout": number;
|
||||
"factorio.game_port": number | null;
|
||||
"factorio.proxy_port": number | null;
|
||||
"factorio.host_assigned_game_port": number | null;
|
||||
"factorio.rcon_port": number | null;
|
||||
"factorio.rcon_password": string | null;
|
||||
@@ -520,13 +503,6 @@ export class InstanceConfig extends classes.Config<InstanceConfigFields> {
|
||||
type: "number",
|
||||
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": {
|
||||
access: ["host"],
|
||||
type: "number",
|
||||
|
||||
@@ -976,30 +976,3 @@ export class InstancePlayerUpdateEvent {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -145,7 +145,7 @@ function readMapPosition(state: MapReaderState) {
|
||||
y = state.last_position.y + y_diff;
|
||||
}
|
||||
state.last_position.x = x;
|
||||
state.last_position.y = y;
|
||||
state.last_position.x = y;
|
||||
return { x, y };
|
||||
}
|
||||
|
||||
|
||||
@@ -93,7 +93,6 @@ export const dataClasses: (RequestClass<unknown, unknown> | EventClass<unknown>)
|
||||
instance.InstanceAdminlistUpdateEvent,
|
||||
instance.InstanceWhitelistUpdateEvent,
|
||||
instance.InstancePlayerUpdateEvent,
|
||||
instance.InstancePlayerRouteUpdateEvent,
|
||||
|
||||
mod.ModPackGetRequest,
|
||||
mod.ModPackGetDefaultRequest,
|
||||
|
||||
@@ -275,6 +275,9 @@ function keyToLabels(key: string) {
|
||||
if (key !== "") {
|
||||
for (let pair of key.split(",")) {
|
||||
let [name, value] = pair.split("=", 2);
|
||||
if (!name || value === undefined) {
|
||||
continue;
|
||||
}
|
||||
labels.set(name, value
|
||||
.slice(1, -1)
|
||||
.replace(/\\"/g, "\"")
|
||||
|
||||
@@ -76,6 +76,12 @@ end
|
||||
-- min
|
||||
-- max
|
||||
function serialize.serialize_personal_logistic_slots(player)
|
||||
-- Check if logistics technology is researched
|
||||
local force = player.force
|
||||
if not force.technologies["logistic-robotics"] or not force.technologies["logistic-robotics"].researched then
|
||||
return nil
|
||||
end
|
||||
|
||||
if v2_logistic_api then
|
||||
local logistic_point = player.get_requester_point()
|
||||
if logistic_point == nil then
|
||||
@@ -129,9 +135,18 @@ function serialize.deserialize_personal_logistic_slots(player, serialized)
|
||||
return
|
||||
end
|
||||
|
||||
-- Check if logistics technology is researched
|
||||
local force = player.force
|
||||
if not force.technologies["logistic-robotics"] or not force.technologies["logistic-robotics"].researched then
|
||||
return
|
||||
end
|
||||
|
||||
-- Load personal logistic slots
|
||||
if v2_logistic_api then
|
||||
local logistic_point = player.get_requester_point()
|
||||
if logistic_point == nil then
|
||||
return
|
||||
end
|
||||
|
||||
-- Remove old sections up to section_count
|
||||
for i = logistic_point.sections_count, 1, -1 do
|
||||
|
||||
Reference in new issue
Block a user