From ba7a4d522f4f6c98279eab4584a3496205835545 Mon Sep 17 00:00:00 2001 From: ILLISIS Date: Mon, 23 Mar 2026 01:29:30 +1100 Subject: [PATCH 1/9] implement UE hooking --- module/control.lua | 47 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/module/control.lua b/module/control.lua index 4834ad3..bc14da1 100644 --- a/module/control.lua +++ b/module/control.lua @@ -2,6 +2,7 @@ local clusterio_api = require("modules/clusterio/api") local rail_sync_manager = require("modules/gridworld/rail_sync_manager") local train_path_manager = require("modules/gridworld/train_path_manager") local time_sync_manager = require("modules/gridworld/time_sync_manager") +local ue_hooks = require("modules/universal_edges/universal_serializer/hooks") local gridworld = { events = {}, @@ -226,4 +227,50 @@ gridworld.events[defines.events.on_chunk_generated] = function(event) end end +-- Serialization hooks for universal_edges train transfer + +-- Remove the current schedule record if it matches the source trainstop we're departing from +ue_hooks.register("LuaTrain", "post_serialize", function(train_data, context) + local edge = context.edge + local offset = context.offset + local train = context.train + + if edge and offset and train_data.schedule and train_data.schedule.records then + local stop_name = edge.id .. " " .. offset + local record = train_data.schedule.records[train_data.schedule.current] + if record and record.station and record.station == stop_name then + local new_schedule = table.deepcopy(train_data.schedule) + table.remove(new_schedule.records, new_schedule.current) + train_data.schedule = new_schedule + log("Modified schedule - current: " .. new_schedule.current .. " records: " .. serpent.block(new_schedule.records)) + end + end + return train_data +end) + +-- Destroy train pathing proxy for the arriving train's destination +ue_hooks.register("LuaTrainComplete", "post_deserialize", function(train_data, context) + local first_locomotive = context.first_locomotive + local proxies = storage.gridworld and storage.gridworld.train_proxies + if not proxies then return end + + local schedule = first_locomotive and first_locomotive.valid + and first_locomotive.train and first_locomotive.train.schedule + if not schedule then return end + + local record = schedule.records and schedule.records[schedule.current] + local destination = record and record.station + if not destination or not proxies[destination] or #proxies[destination] == 0 then return end + + local loco = table.remove(proxies[destination]) + if loco and loco.valid then + loco.destroy() + else + log("Failed to destroy train proxy for destination " .. destination .. " - invalid entity") + end + if #proxies[destination] == 0 then + proxies[destination] = nil + end +end) + return gridworld From aacd92cdecea4d0727dc727bc44739e91a1535b4 Mon Sep 17 00:00:00 2001 From: ILLISIS Date: Mon, 23 Mar 2026 01:56:36 +1100 Subject: [PATCH 2/9] add diagonal teleportation for entities and players --- controller.ts | 69 ++++++++++++++- index.ts | 3 + instance.ts | 79 +++++++++++++++++ messages.ts | 98 +++++++++++++++++++++ module/control.lua | 86 ++++++++++++++++++ module/corner_scanner.lua | 178 ++++++++++++++++++++++++++++++++++++++ 6 files changed, 512 insertions(+), 1 deletion(-) create mode 100644 module/corner_scanner.lua diff --git a/controller.ts b/controller.ts index 962a361..623ffbf 100644 --- a/controller.ts +++ b/controller.ts @@ -177,6 +177,7 @@ export class ControllerPlugin extends BaseControllerPlugin { this.controller.handle(messages.GridworldReturnTrainPathResult, this.handleGridworldReturnTrainPathResult.bind(this)); this.controller.handle(messages.GridworldClearTrainPath, this.handleGridworldClearTrainPath.bind(this)); this.controller.handle(messages.GridworldRemoveTrainProxy, this.handleGridworldRemoveTrainProxy.bind(this)); + this.controller.handle(messages.GridworldCornerTeleportPlayer, this.handleCornerTeleportPlayer.bind(this)); this.controller.subscriptions.handle(messages.GridworldStateUpdate, this.handleGridworldStateSubscription.bind(this)); this.tiles = await loadTiles(this.controller.config, this.logger); @@ -202,7 +203,6 @@ export class ControllerPlugin extends BaseControllerPlugin { async onInstanceStatusChanged(instance: InstanceInfo, prev?: lib.InstanceStatus) { if (instance.status === "running" && prev !== "running") { - // Skip pathworld — it doesn't need daytime sync if (instance.config.get("instance.name") === "pathworld") { return; } @@ -213,6 +213,11 @@ export class ControllerPlugin extends BaseControllerPlugin { `Failed sending startup daytime to instance ${instance.id}: ${err?.message ?? err}`, ); } + // Send corner neighbor info for diagonal entity transport + const tile = this.tilesByInstance.get(instance.id); + if (tile) { + await this.sendCornerNeighbors(tile); + } } } @@ -419,6 +424,7 @@ export class ControllerPlugin extends BaseControllerPlugin { this.tilesByInstance.set(instanceId, tile); this.storageDirty = true; this.markStateDirty(); + await this.updateCornerNeighborsForNewTile(tile); this.logger.info(`Created tile ${x},${y} for instance ${instanceId} (${reason})`); return tile; } @@ -514,6 +520,67 @@ export class ControllerPlugin extends BaseControllerPlugin { } } + private computeCornerNeighbors(tile: TileRecord): { ne?: number; se?: number; sw?: number; nw?: number } { + const ne = this.tiles.get(tileKey(tile.x + 1, tile.y - 1)); + const se = this.tiles.get(tileKey(tile.x + 1, tile.y + 1)); + const sw = this.tiles.get(tileKey(tile.x - 1, tile.y + 1)); + const nw = this.tiles.get(tileKey(tile.x - 1, tile.y - 1)); + return { + ne: ne?.instanceId, + se: se?.instanceId, + sw: sw?.instanceId, + nw: nw?.instanceId, + }; + } + + private async sendCornerNeighbors(tile: TileRecord) { + const neighbors = this.computeCornerNeighbors(tile); + const instance = this.controller.instances.get(tile.instanceId); + if (!instance || instance.status !== "running") { + return; + } + try { + await this.controller.sendTo({ instanceId: tile.instanceId }, new messages.GridworldCornerNeighbors(neighbors)); + } catch (err: any) { + this.logger.warn(`Failed to send corner neighbors to tile ${tile.x},${tile.y}: ${err?.message ?? err}`); + } + } + + private async updateCornerNeighborsForNewTile(tile: TileRecord) { + await this.sendCornerNeighbors(tile); + const DIAGONAL_DELTAS = [ + { dx: -1, dy: -1 }, { dx: 1, dy: -1 }, + { dx: -1, dy: 1 }, { dx: 1, dy: 1 }, + ]; + for (const delta of DIAGONAL_DELTAS) { + const diag = this.tiles.get(tileKey(tile.x + delta.dx, tile.y + delta.dy)); + if (diag) { + await this.sendCornerNeighbors(diag); + } + } + } + + async handleCornerTeleportPlayer({ playerName, instanceId }: messages.GridworldCornerTeleportPlayer) { + const instance = this.controller.instances.get(instanceId); + if (!instance) { + throw new lib.ResponseError(`Instance ${instanceId} not found for corner teleport`); + } + const hostId = instance.config.get("instance.assigned_host"); + if (!hostId) { + throw new lib.ResponseError(`Instance ${instanceId} has no assigned host`); + } + const host = this.controller.hosts.get(hostId); + if (!host) { + throw new lib.ResponseError(`Host ${hostId} not found for instance ${instanceId}`); + } + if (!host.publicAddress) { + throw new lib.ResponseError(`Host ${hostId} has no public address configured`); + } + const address = `${host.publicAddress}:${instance.gamePort || instance.config.get("factorio.game_port")}`; + this.logger.info(`Corner teleporting ${playerName} to ${address} (instance ${instanceId})`); + return { address }; + } + private async ensureEdgeBetween(tile: TileRecord, neighbor: TileRecord) { const ue = this.getUniversalEdgesController(); if (!ue?.handleSetEdgeConfigRequest) { diff --git a/index.ts b/index.ts index 3006e52..62bad9b 100644 --- a/index.ts +++ b/index.ts @@ -138,6 +138,9 @@ export const plugin: lib.PluginDeclaration = { messages.GridworldForwardClearTrainPath, messages.GridworldRemoveTrainProxy, messages.GridworldForwardRemoveTrainProxy, + messages.GridworldCornerNeighbors, + messages.GridworldCornerTeleportPlayer, + messages.GridworldDiagonalEntityTransfer, ], webEntrypoint: "./web", diff --git a/instance.ts b/instance.ts index 1e2c2ff..5dc3ab0 100644 --- a/instance.ts +++ b/instance.ts @@ -29,8 +29,29 @@ type RequestTrainPathIPC = { destination: string; }; +type CornerNeighbors = { ne?: number; se?: number; sw?: number; nw?: number }; + +type CornerTeleportPlayerIPC = { + player_name: string; + corner: "ne" | "se" | "sw" | "nw"; + world_position: [number, number]; +}; + +type CornerEntityTransferIPC = { + corner: "ne" | "se" | "sw" | "nw"; + entity_transfers: Array<{ + type: "player" | "vehicle"; + world_position: [number, number]; + player_name?: string; + serialized_entity?: Record; + driver_name?: string; + passenger_name?: string; + }>; +}; + export class InstancePlugin extends BaseInstancePlugin { private warnedMissingConfig = false; + private cornerNeighbors: CornerNeighbors = {}; async init() { this.instance.handle(messages.GridworldSyncTileAreas, this.handleGridworldSyncTileAreas.bind(this)); @@ -80,6 +101,21 @@ export class InstancePlugin extends BaseInstancePlugin { (this.instance.server as any).on("ipc-gridworld:remove_train_proxy", (data: { last_edge_stop: string; destination: string }) => { this.instance.sendTo("controller", new messages.GridworldRemoveTrainProxy(data.last_edge_stop, data.destination)); }); + + // Corner diagonal transport IPC handlers + (this.instance.server as any).on("ipc-gridworld:corner_teleport_player", (data: CornerTeleportPlayerIPC) => { + this.handleCornerTeleportPlayerIpc(data).catch(err => this.logger.error( + `Error handling corner_teleport_player IPC:\n${err.stack}`, + )); + }); + (this.instance.server as any).on("ipc-gridworld:corner_entity_transfer", (data: CornerEntityTransferIPC) => { + this.handleCornerEntityTransferIpc(data).catch(err => this.logger.error( + `Error handling corner_entity_transfer IPC:\n${err.stack}`, + )); + }); + + this.instance.handle(messages.GridworldCornerNeighbors, this.handleCornerNeighbors.bind(this)); + this.instance.handle(messages.GridworldDiagonalEntityTransfer, this.handleDiagonalEntityTransfer.bind(this)); } async handleForwardRemoveTrainProxy(event: messages.GridworldForwardRemoveTrainProxy) { @@ -268,4 +304,47 @@ export class InstancePlugin extends BaseInstancePlugin { })); await this.sendRcon(`/sc train_path_manager.find_train_path('${json}')`); } + + // --- Corner diagonal transport --- + + async handleCornerNeighbors(event: messages.GridworldCornerNeighbors) { + this.cornerNeighbors = event.neighbors; + const json = lib.escapeString(JSON.stringify(event.neighbors)); + await this.sendRcon(`/sc gridworld.set_corner_neighbors('${json}')`); + } + + private async handleCornerTeleportPlayerIpc(data: CornerTeleportPlayerIPC) { + const targetInstanceId = this.cornerNeighbors[data.corner]; + if (!targetInstanceId) { + this.logger.warn(`No diagonal neighbor for corner ${data.corner}`); + return; + } + const { address } = await this.instance.sendTo( + "controller", + new messages.GridworldCornerTeleportPlayer(data.player_name, targetInstanceId), + ); + const escapedName = lib.escapeString(data.player_name); + const escapedAddress = lib.escapeString(address); + await this.sendRcon(`/sc gridworld.corner_teleport_response("${escapedName}", "${escapedAddress}")`); + } + + private async handleCornerEntityTransferIpc(data: CornerEntityTransferIPC) { + const targetInstanceId = this.cornerNeighbors[data.corner]; + if (!targetInstanceId) { + this.logger.warn(`No diagonal neighbor for corner ${data.corner}`); + return; + } + await this.instance.sendTo( + { instanceId: targetInstanceId }, + new messages.GridworldDiagonalEntityTransfer(data.entity_transfers), + ); + } + + async handleDiagonalEntityTransfer(message: messages.GridworldDiagonalEntityTransfer) { + const json = lib.escapeString(JSON.stringify({ + entity_transfers: message.entityTransfers, + })); + await this.sendRcon(`/sc gridworld.receive_diagonal_entity('${json}')`, true); + return { success: true }; + } } diff --git a/messages.ts b/messages.ts index 9af396f..c32acd0 100644 --- a/messages.ts +++ b/messages.ts @@ -497,3 +497,101 @@ export class GridworldApplyUeStops { return new this(json.tileX, json.tileY, json.stops); } } + +// Controller → Instance: update corner neighbor instance IDs for diagonal transport. +export class GridworldCornerNeighbors { + declare ["constructor"]: typeof GridworldCornerNeighbors; + static type = "event" as const; + static src = "controller" as const; + static dst = "instance" as const; + static plugin = "gridworld" as const; + + constructor(public neighbors: { + ne?: number; + se?: number; + sw?: number; + nw?: number; + }) { } + + static jsonSchema = Type.Object({ + neighbors: Type.Object({ + ne: Type.Optional(Type.Number()), + se: Type.Optional(Type.Number()), + sw: Type.Optional(Type.Number()), + nw: Type.Optional(Type.Number()), + }), + }); + + static fromJSON(json: Static) { + return new this(json.neighbors); + } +} + +// Instance → Controller: resolve server address for diagonal corner teleport. +export class GridworldCornerTeleportPlayer { + declare ["constructor"]: typeof GridworldCornerTeleportPlayer; + static type = "request" as const; + static src = "instance" as const; + static dst = "controller" as const; + static plugin = "gridworld" as const; + + constructor(public playerName: string, public instanceId: number) { } + + static jsonSchema = Type.Object({ + playerName: Type.String(), + instanceId: Type.Number(), + }); + + static fromJSON(json: Static) { + return new this(json.playerName, json.instanceId); + } + + static Response = plainJson(Type.Object({ + address: Type.String(), + })); +} + +// Instance → Instance: transfer entities diagonally to a corner neighbor. +export class GridworldDiagonalEntityTransfer { + declare ["constructor"]: typeof GridworldDiagonalEntityTransfer; + static type = "request" as const; + static src = "instance" as const; + static dst = "instance" as const; + static plugin = "gridworld" as const; + + constructor( + public entityTransfers: Array<{ + type: "player" | "vehicle"; + world_position: [number, number]; + player_name?: string; + serialized_entity?: Record; + driver_name?: string; + passenger_name?: string; + }>, + ) { } + + static jsonSchema = Type.Object({ + entityTransfers: Type.Array(Type.Union([ + Type.Object({ + type: Type.Literal("player"), + player_name: Type.String(), + world_position: Type.Tuple([Type.Number(), Type.Number()]), + }), + Type.Object({ + type: Type.Literal("vehicle"), + serialized_entity: Type.Object({}), + world_position: Type.Tuple([Type.Number(), Type.Number()]), + driver_name: Type.Optional(Type.String()), + passenger_name: Type.Optional(Type.String()), + }), + ])), + }); + + static fromJSON(json: Static) { + return new this(json.entityTransfers); + } + + static Response = plainJson(Type.Object({ + success: Type.Boolean(), + })); +} diff --git a/module/control.lua b/module/control.lua index bc14da1..f7fc6ea 100644 --- a/module/control.lua +++ b/module/control.lua @@ -2,6 +2,8 @@ local clusterio_api = require("modules/clusterio/api") local rail_sync_manager = require("modules/gridworld/rail_sync_manager") local train_path_manager = require("modules/gridworld/train_path_manager") local time_sync_manager = require("modules/gridworld/time_sync_manager") +local corner_scanner = require("modules/gridworld/corner_scanner") +local universal_serializer = require("modules/universal_edges/universal_serializer/universal_serializer") local ue_hooks = require("modules/universal_edges/universal_serializer/hooks") local gridworld = { @@ -25,6 +27,21 @@ local function ensure_storage() if not storage.gridworld.train_proxies then storage.gridworld.train_proxies = {} end + if not storage.gridworld.corner_neighbors then + storage.gridworld.corner_neighbors = {} + end + if not storage.gridworld.players_waiting_to_leave_diagonal then + storage.gridworld.players_waiting_to_leave_diagonal = {} + end + if not storage.gridworld.players_waiting_to_join_diagonal then + storage.gridworld.players_waiting_to_join_diagonal = {} + end + if not storage.gridworld.diagonal_vehicle_drivers then + storage.gridworld.diagonal_vehicle_drivers = {} + end + if not storage.gridworld.diagonal_vehicle_passengers then + storage.gridworld.diagonal_vehicle_passengers = {} + end end local function update_bounds() @@ -62,6 +79,61 @@ function gridworld.set_pathworld() log("[gridworld] this instance is pathworld; on_chunk_generated will clear entities and decoratives") end +---@param json string +function gridworld.set_corner_neighbors(json) + ensure_storage() + local data = helpers.json_to_table(json) + if data then + storage.gridworld.corner_neighbors = data + end +end + +---@param player_name string +---@param address string +function gridworld.corner_teleport_response(player_name, address) + if player_name == nil or address == nil then return end + local player = game.players[player_name] + if player == nil then + log("[gridworld] Corner teleport failed: Player " .. player_name .. " not found") + return + end + player.connect_to_server({ + address = address, + name = "Diagonal transfer", + description = "Connect to diagonal server", + }) +end + +---@param json string +function gridworld.receive_diagonal_entity(json) + ensure_storage() + local data = helpers.json_to_table(json) + if data == nil then return end + local entity_transfers = data.entity_transfers + if entity_transfers == nil then return end + + for _, transfer in ipairs(entity_transfers) do + if transfer.type == "player" then + storage.gridworld.players_waiting_to_join_diagonal[transfer.player_name] = { + world_position = transfer.world_position, + } + elseif transfer.type == "vehicle" then + -- Fix position format after JSON round-trip (Lua arrays become {"1":x,"2":y}) + local pos = transfer.serialized_entity.position + if pos then + transfer.serialized_entity.position = { x = pos[1] or pos["1"], y = pos[2] or pos["2"] } + end + local entity = universal_serializer.LuaEntity.deserialize(transfer.serialized_entity) + if transfer.driver_name and entity and entity.valid then + storage.gridworld.diagonal_vehicle_drivers[transfer.driver_name] = entity + end + if transfer.passenger_name and entity and entity.valid then + storage.gridworld.diagonal_vehicle_passengers[transfer.passenger_name] = entity + end + end + end +end + --- Called on the pathworld instance via RCON to generate and chart chunks --- for all known gridworld tile areas. ---@param json string JSON array of {minX, maxX, minY, maxY, surfaceName} objects @@ -138,6 +210,20 @@ gridworld.events[clusterio_api.events.on_server_startup] = function(_event) update_bounds() end +gridworld.on_nth_tick[90] = function() + corner_scanner.poll_corners() +end + +gridworld.events[defines.events.on_player_joined_game] = function(event) + ensure_storage() + corner_scanner.on_player_joined_game(event) +end + +gridworld.events[defines.events.on_player_left_game] = function(event) + ensure_storage() + corner_scanner.on_player_left_game(event) +end + gridworld.events[defines.events.on_train_changed_state] = function(event) train_path_manager.on_train_changed_state(event) end diff --git a/module/corner_scanner.lua b/module/corner_scanner.lua new file mode 100644 index 0000000..690f17d --- /dev/null +++ b/module/corner_scanner.lua @@ -0,0 +1,178 @@ +local clusterio_api = require("modules/clusterio/api") +local universal_serializer = require("modules/universal_edges/universal_serializer/universal_serializer") + +local corner_scanner = {} + +local CORNER_SCAN_PADDING = 6 +local ENTITY_TYPES = {"character", "spider-vehicle", "car"} -- tanks are type "car" in Factorio + +-- Corner directions mapped to scan area quadrants in the gap zone past both tile boundaries +local CORNERS = { + ne = { bounds_fn = function(b) return {{b.max_x, b.min_y - CORNER_SCAN_PADDING}, {b.max_x + CORNER_SCAN_PADDING, b.min_y}} end }, + se = { bounds_fn = function(b) return {{b.max_x, b.max_y}, {b.max_x + CORNER_SCAN_PADDING, b.max_y + CORNER_SCAN_PADDING}} end }, + sw = { bounds_fn = function(b) return {{b.min_x - CORNER_SCAN_PADDING, b.max_y}, {b.min_x, b.max_y + CORNER_SCAN_PADDING}} end }, + nw = { bounds_fn = function(b) return {{b.min_x - CORNER_SCAN_PADDING, b.min_y - CORNER_SCAN_PADDING}, {b.min_x, b.min_y}} end }, +} + +function corner_scanner.poll_corners() + local config = storage.gridworld + if config == nil or config.bounds == nil then + return + end + if not config.corner_neighbors then + return + end + local surface_name = config.surface_name + if not surface_name then + return + end + local surface = game.surfaces[surface_name] + if not surface then + return + end + + for corner_name, corner_def in pairs(CORNERS) do + local neighbor_id = config.corner_neighbors[corner_name] + if neighbor_id then + local scan_area = corner_def.bounds_fn(config.bounds) + local entities = surface.find_entities_filtered{ + type = ENTITY_TYPES, + area = scan_area, + } + for _, entity in ipairs(entities) do + if entity.valid then + corner_scanner.handle_corner_entity(entity, corner_name) + end + end + end + end +end + +---@param entity LuaEntity +---@param corner string +function corner_scanner.handle_corner_entity(entity, corner) + if entity.type == "character" then + if entity.player then + local waiting = storage.gridworld.players_waiting_to_leave_diagonal + if not waiting[entity.player.name] then + waiting[entity.player.name] = { + corner = corner, + world_position = {entity.position.x, entity.position.y}, + } + clusterio_api.send_json("gridworld:corner_teleport_player", { + player_name = entity.player.name, + corner = corner, + world_position = {entity.position.x, entity.position.y}, + }) + end + end + elseif entity.type == "spider-vehicle" or entity.type == "car" then + local driver_name = nil + local passenger_name = nil + local driver = entity.get_driver() + if driver and driver.player then + driver_name = driver.player.name + storage.gridworld.players_waiting_to_leave_diagonal[driver_name] = { + corner = corner, + world_position = {entity.position.x, entity.position.y}, + } + clusterio_api.send_json("gridworld:corner_teleport_player", { + player_name = driver_name, + corner = corner, + world_position = {entity.position.x, entity.position.y}, + }) + end + local passenger = entity.get_passenger() + if passenger and passenger.player then + passenger_name = passenger.player.name + storage.gridworld.players_waiting_to_leave_diagonal[passenger_name] = { + corner = corner, + world_position = {entity.position.x, entity.position.y}, + } + clusterio_api.send_json("gridworld:corner_teleport_player", { + player_name = passenger_name, + corner = corner, + world_position = {entity.position.x, entity.position.y}, + }) + end + + local world_position = {entity.position.x, entity.position.y} + local serialized = universal_serializer.LuaEntity.serialize(entity) + entity.destroy{raise_destroy = true} + + -- Clear waiting entries so on_player_left_game doesn't send duplicate player transfers + local waiting = storage.gridworld.players_waiting_to_leave_diagonal + if driver_name then waiting[driver_name] = nil end + if passenger_name then waiting[passenger_name] = nil end + + clusterio_api.send_json("gridworld:corner_entity_transfer", { + corner = corner, + entity_transfers = { + { + type = "vehicle", + world_position = world_position, + serialized_entity = serialized, + driver_name = driver_name, + passenger_name = passenger_name, + }, + }, + }) + end +end + +---@param event EventData.on_player_left_game +function corner_scanner.on_player_left_game(event) + local player = game.get_player(event.player_index) + if player == nil then + return + end + local waiting = storage.gridworld.players_waiting_to_leave_diagonal + if not waiting[player.name] then + return + end + local leave = waiting[player.name] + waiting[player.name] = nil + + clusterio_api.send_json("gridworld:corner_entity_transfer", { + corner = leave.corner, + entity_transfers = { + { + type = "player", + player_name = player.name, + world_position = leave.world_position, + }, + }, + }) +end + +---@param event EventData.on_player_joined_game +function corner_scanner.on_player_joined_game(event) + local player = game.get_player(event.player_index) + if player == nil then + return + end + local waiting = storage.gridworld.players_waiting_to_join_diagonal + if not waiting[player.name] then + return + end + local join = waiting[player.name] + waiting[player.name] = nil + player.teleport({join.world_position[1], join.world_position[2]}) + + if storage.gridworld.diagonal_vehicle_drivers and storage.gridworld.diagonal_vehicle_drivers[player.name] then + local entity = storage.gridworld.diagonal_vehicle_drivers[player.name] + if entity.valid then + entity.set_driver(player) + end + storage.gridworld.diagonal_vehicle_drivers[player.name] = nil + end + if storage.gridworld.diagonal_vehicle_passengers and storage.gridworld.diagonal_vehicle_passengers[player.name] then + local entity = storage.gridworld.diagonal_vehicle_passengers[player.name] + if entity.valid then + entity.set_passenger(player) + end + storage.gridworld.diagonal_vehicle_passengers[player.name] = nil + end +end + +return corner_scanner From e942f10cc84a1c5f45332d05c0e735fe6d7131c6 Mon Sep 17 00:00:00 2001 From: ILLISIS Date: Tue, 24 Mar 2026 01:43:04 +1100 Subject: [PATCH 3/9] normalize map settings --- index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/index.ts b/index.ts index 62bad9b..b344286 100644 --- a/index.ts +++ b/index.ts @@ -44,7 +44,7 @@ export const plugin: lib.PluginDeclaration = { title: "Map Exchange String", description: "Map exchange string used to generate tiles.", type: "string", - initialValue: ">>>eNpjYmBg8GRgZGDgYUnOT8wB8uwZGA44gDBXcn5BQWqRbn5RKrIwZ3JRaUqqbn4mquLUvNTcSt2kxGKg4gaocIM9R2ZRfh66CTx5iaVlmcXxyTmZaWkQ1RDMWpSfnF2MLCJWXJJYVJKZlx6fWJSaGJ+bn1lcUlqUiqKpuCQ/D8V81pKi1FQUY7hLixLzMktzIS5psIOrLE8sSS1CVsnAqBBSYtHQIscAwv/rGRT+/wdhIOsB0A4QZmBsgKhmBArCACvUMwwKjkDshDCOkbFaZJ37w6op9owQlXoOUMYHqMiBJJiIJ4zh54BTSgXGMEEyxxgMPiMxIJaWAK2AquJwQDAgki0gSUbG3rdbF3w/dsGO8c/Kj5d8kxLsGQ1dRd59MFpnB5RmB3mXCU7MmgkCO2FeYYCZ+cAeKnXTnvHsGRB4Y8/ICtIhAiIcLIDEAW9mBkYBPiBrQQ+QUJBhgDnNDmaMiANjGhh8g/nkMYxx2R7dH8CAsAEZLgciToAIsIVwlzFCmA79DowO8jBZSYQSoH4jBmQ3pCB8eBJm7WEk+9EcghkRyP5AE1FxwBINXCALU+DEC2a4a4DheYEdxnOY78DIDGKAVH0BikF4IBmYURBawAEc3MzwRPnBHjWlgRggQwplrp4BAHC3vz8=<<<", + initialValue: ">>>eNpjYmBg8AFiBh6W5PzEHAaGBnsY5krOLyhILdLNL0pFFuZMLipNSdXNz0RVnJqXmlupm5RYjKKYI7MoPw/dBJ68xNKyzOL45JzMtDRkCdai/OTsYmQRseKSxKKSzLz0+MSi1MT43PzM4pJSVNNYi0vy81BFSopSU1GM4S4tSszLLM1FdwlreWJJahGyCANj2XeTFw0tcgwg/L+eQeH/fxAGsh4AQwmEGRgbIKoZgYIwwAr1DIOCIxA7IYxjZKwWWef+sGqKPSNEpZ4DlPEBKnIgCSbiCWP4OeCUUoExTJDMMQaDz0gMiKUlQCugqjgcEAyIZAtIkpGx9+3WBd+PXbBj/LPy4yXfpAR7RkNXkXcfjNbZASXZQd5lghOzZoLATphXGGBmPrCHSt20Zzx7BgTe2DOygnSIgAgHCyBxwJuZgVGAD8ha0AMkFGQYYE6zgxkj4sCYBgbfYD55DGNctkf3BzAgbECGy4GIEyACbCHcZYwQpkO/A6ODPExWEqEEqN+IAdkNKQgfnoRZexjJfjSHYEYEsj/QRFQcsEQDF8jCFDjxghnuGmB4XmCH8RzmOzAygxggVV+AYhAeSAZmFIQWcAAHNzM8UX6wR01pIAbIkCB3zfkAC3S/Eg==<<<", }, "gridworld.tile_size": { title: "Tile Size", From 0b10e699be5605afec9c928d0b8b592c57ffb1b8 Mon Sep 17 00:00:00 2001 From: ILLISIS Date: Tue, 31 Mar 2026 00:19:02 +1100 Subject: [PATCH 4/9] fixed players couldn't reuse the corner after canceling the connection request --- module/corner_scanner.lua | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/module/corner_scanner.lua b/module/corner_scanner.lua index 690f17d..7878b72 100644 --- a/module/corner_scanner.lua +++ b/module/corner_scanner.lua @@ -31,6 +31,8 @@ function corner_scanner.poll_corners() return end + local found_players = {} + for corner_name, corner_def in pairs(CORNERS) do local neighbor_id = config.corner_neighbors[corner_name] if neighbor_id then @@ -42,10 +44,32 @@ function corner_scanner.poll_corners() for _, entity in ipairs(entities) do if entity.valid then corner_scanner.handle_corner_entity(entity, corner_name) + if entity.type == "character" then + if entity.player then + found_players[entity.player.name] = true + end + elseif entity.type == "spider-vehicle" or entity.type == "car" then + local driver = entity.get_driver() + if driver and driver.player then + found_players[driver.player.name] = true + end + local passenger = entity.get_passenger() + if passenger and passenger.player then + found_players[passenger.player.name] = true + end + end end end end end + + -- Clear waiting entries for players no longer in any corner zone + local waiting = storage.gridworld.players_waiting_to_leave_diagonal + for name, _ in pairs(waiting) do + if not found_players[name] then + waiting[name] = nil + end + end end ---@param entity LuaEntity From 8c33c792e8b13d76b3e6fbed4e1286341c566722 Mon Sep 17 00:00:00 2001 From: ILLISIS Date: Tue, 31 Mar 2026 00:19:17 +1100 Subject: [PATCH 5/9] remove spammy logger --- controller.ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/controller.ts b/controller.ts index 623ffbf..647b2bd 100644 --- a/controller.ts +++ b/controller.ts @@ -1113,13 +1113,6 @@ export class ControllerPlugin extends BaseControllerPlugin { // tile's first synced parking rail at that offset. const [worldX, worldY] = edgePosToWorld([edgeX + 2, -1], side.origin as [number, number], side.direction); - this.logger.info( - `[gridworld] ue_stop reposition: ${stop.stopName} tile=${tileX},${tileY}` - + ` edge=${edgeId} offset=${offset} edgeX=${edgeX}` - + ` origin=[${side.origin}] dir=${side.direction}` - + ` old=(${stop.x},${stop.y}) new=(${worldX},${worldY})`, - ); - return { ...stop, x: worldX, From 2a65c85538dbf06235a09c2ad00070d57ac2ac00 Mon Sep 17 00:00:00 2001 From: ILLISIS Date: Tue, 31 Mar 2026 01:21:33 +1100 Subject: [PATCH 6/9] fix a minor race condition that causes an error --- module/rail_sync_manager.lua | 1 + 1 file changed, 1 insertion(+) diff --git a/module/rail_sync_manager.lua b/module/rail_sync_manager.lua index 7431ce1..dde9e30 100644 --- a/module/rail_sync_manager.lua +++ b/module/rail_sync_manager.lua @@ -79,6 +79,7 @@ end function rail_sync_manager.collect_and_send_ue_stops() local config = storage.gridworld if config == nil or config.is_pathworld then return end + if config.tile_x == nil or config.tile_y == nil then return end local results = {} for _, surface in pairs(game.surfaces) do From 3c63de018cf475b396701364d38f6e54f3757b31 Mon Sep 17 00:00:00 2001 From: ILLISIS Date: Sat, 4 Apr 2026 00:08:45 +1100 Subject: [PATCH 7/9] cleanup edge datastore when gridworld is deleted --- controller.ts | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/controller.ts b/controller.ts index 647b2bd..2b61aa1 100644 --- a/controller.ts +++ b/controller.ts @@ -29,6 +29,7 @@ type EdgeTargetSpec = { type UniversalEdgesController = { edgeDatastore?: Map; + storageDirty?: boolean; handleSetEdgeConfigRequest?: (request: { edge: any }) => Promise | void; }; @@ -1559,7 +1560,7 @@ export class ControllerPlugin extends BaseControllerPlugin { private async removeEdgesForTiles(tiles: TileRecord[]) { const ue = this.getUniversalEdgesController(); - if (!ue?.handleSetEdgeConfigRequest || !ue.edgeDatastore) { + if (!ue?.edgeDatastore) { return; } @@ -1575,17 +1576,10 @@ export class ControllerPlugin extends BaseControllerPlugin { } for (const edgeId of edgeIds) { - const edge = ue.edgeDatastore.get(edgeId); - if (!edge || edge.isDeleted) { - continue; + if (ue.edgeDatastore.has(edgeId)) { + ue.edgeDatastore.delete(edgeId); + ue.storageDirty = true; } - await ue.handleSetEdgeConfigRequest({ - edge: { - ...edge, - isDeleted: true, - updatedAtMs: Date.now(), - }, - }); } } } From 89ff0ebce5277e1e6412ad50e5f27f1993e78530 Mon Sep 17 00:00:00 2001 From: ILLISIS Date: Sat, 4 Apr 2026 14:54:25 +1100 Subject: [PATCH 8/9] disable noisy debug logging --- module/train_path_manager.lua | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/module/train_path_manager.lua b/module/train_path_manager.lua index 71a3c84..1dfd665 100644 --- a/module/train_path_manager.lua +++ b/module/train_path_manager.lua @@ -65,8 +65,6 @@ end ---@param LuaTrain LuaTrain function tpm.request_train_path(LuaTrain) - log("tpm:request_train_path") - -- Implementation for requesting a train path -- skip if train is already in manual mode (e.g., request already in progress) if LuaTrain.manual_mode then return end @@ -113,8 +111,6 @@ end -- called from rcon by the Clusterio Controller function tpm.apply_train_path_result(json) - log("tpm:apply_train_path_result") - -- Implementation for applying a train path -- convert json local path_result = helpers.json_to_table(json) @@ -240,8 +236,6 @@ end -- called from rcon by the Clusterio Controller function tpm.find_train_path(json) - log("tpm:find_train_path") - -- Implementation for finding a train path -- convert json local path_request = helpers.json_to_table(json) @@ -266,7 +260,6 @@ tpm.RAIL_TYPES = { ---@param path_request TrainPathRequest function tpm.process_path_request(path_request) - log("tpm:process_path_request id=" .. tostring(path_request.id)) local path = { id = path_request.id, path = {}, @@ -369,8 +362,6 @@ end ---@param path table function tpm.return_train_path_result(path) - log("tpm:return_train_path_result") - -- Implementation for returning a train path -- send result to controller clusterio_api.send_json("gridworld:return_train_path", path) @@ -381,7 +372,6 @@ end -------------------------------------------------------------------------------------------------- function tpm.queue_path_request(path_request) - log("tpm:queue_path_request id=" .. tostring(path_request.id)) storage.gridworld.train_path_requests[path_request.id] = path_request end @@ -398,7 +388,6 @@ end -- proxy train creation on destination when a path request is returned function tpm.create_train_proxy(json) - log("tpm:create_train_proxy") local data = helpers.json_to_table(json) if not data then return end @@ -459,7 +448,6 @@ function tpm.create_train_proxy(json) storage.gridworld.train_proxies[destination] = {} end table.insert(storage.gridworld.train_proxies[destination], loco) - log("create_train_proxy: created proxy for station " .. destination) end -- called from rcon by the Clusterio Controller to cancel a pending path request @@ -467,7 +455,6 @@ function tpm.clear_train_path_request(json) local data = helpers.json_to_table(json) if not data then return end storage.gridworld.train_path_requests[data.id] = nil - log("tpm:clear_train_path_request: cleared id=" .. tostring(data.id)) end return tpm \ No newline at end of file From 285452b3d8d34f83383c295e761d4cefe8a01465 Mon Sep 17 00:00:00 2001 From: ILLISIS Date: Sat, 4 Apr 2026 15:40:58 +1100 Subject: [PATCH 9/9] update Gridworld to Clusterio Version 2.0.0-alpha.23 --- controller.ts | 24 ++++++++++++------------ instance.ts | 9 +++++++++ module/control.lua | 2 +- package.json | 7 ++++--- 4 files changed, 26 insertions(+), 16 deletions(-) diff --git a/controller.ts b/controller.ts index 2b61aa1..8940a53 100644 --- a/controller.ts +++ b/controller.ts @@ -2,7 +2,7 @@ import fs from "fs/promises"; import path from "path"; import * as lib from "@clusterio/lib"; -import { BaseControllerPlugin, type InstanceInfo } from "@clusterio/controller"; +import { BaseControllerPlugin, type InstanceRecord } from "@clusterio/controller"; import * as messages from "./messages"; type TileRecord = { @@ -202,7 +202,7 @@ export class ControllerPlugin extends BaseControllerPlugin { } } - async onInstanceStatusChanged(instance: InstanceInfo, prev?: lib.InstanceStatus) { + async onInstanceStatusChanged(instance: InstanceRecord, prev?: lib.InstanceStatus) { if (instance.status === "running" && prev !== "running") { if (instance.config.get("instance.name") === "pathworld") { return; @@ -233,7 +233,7 @@ export class ControllerPlugin extends BaseControllerPlugin { } } - async onPlayerEvent(instance: InstanceInfo, event: lib.PlayerEvent) { + async onPlayerEvent(instance: InstanceRecord, event: lib.PlayerEvent) { if (event.type !== "join") { return; } @@ -393,7 +393,7 @@ export class ControllerPlugin extends BaseControllerPlugin { instanceConfig.set("instance.auto_start", false, "controller"); this.setInstanceConfigForTile(instanceConfig, x, y); - await this.controller.instanceCreate(instanceConfig); + await this.controller.instances.createInstance(instanceConfig); const instanceId = instanceConfig.get("instance.id"); const tile: TileRecord = { x, @@ -448,7 +448,7 @@ export class ControllerPlugin extends BaseControllerPlugin { return false; } try { - await this.controller.instanceAssign(tile.instanceId, resolvedHostId); + await this.controller.instances.assignInstance(tile.instanceId, resolvedHostId); return true; } catch (err: any) { this.logger.error(`Failed to assign instance ${tile.instanceId}: ${err?.message ?? err}`); @@ -766,7 +766,7 @@ export class ControllerPlugin extends BaseControllerPlugin { instanceConfig.set("instance.name", instanceName, "controller"); instanceConfig.set("instance.auto_start", false, "controller"); this.setInstanceConfigForTile(instanceConfig, tile.x, tile.y); - await this.controller.instanceCreate(instanceConfig); + await this.controller.instances.createInstance(instanceConfig); const newInstanceId = instanceConfig.get("instance.id"); this.tilesByInstance.delete(tile.instanceId); tile.instanceId = newInstanceId; @@ -882,7 +882,7 @@ export class ControllerPlugin extends BaseControllerPlugin { await this.stopInstanceBeforeDelete(tile.instanceId, `aborted tile ${tile.x},${tile.y}`); try { - await this.controller.instanceDelete(tile.instanceId); + await this.controller.instances.deleteInstance(tile.instanceId); } catch (err: any) { this.logger.error( `Failed deleting aborted tile instance ${tile.instanceId} (${tile.x},${tile.y}): ${err?.message ?? err}`, @@ -1317,7 +1317,7 @@ export class ControllerPlugin extends BaseControllerPlugin { for (const tile of tiles) { await this.stopInstanceBeforeDelete(tile.instanceId, `gridworld tile ${tile.x},${tile.y}`); try { - await this.controller.instanceDelete(tile.instanceId); + await this.controller.instances.deleteInstance(tile.instanceId); deletedInstanceIds.add(tile.instanceId); } catch (err: any) { this.logger.error( @@ -1337,7 +1337,7 @@ export class ControllerPlugin extends BaseControllerPlugin { } await this.stopInstanceBeforeDelete(instance.id, name); try { - await this.controller.instanceDelete(instance.id); + await this.controller.instances.deleteInstance(instance.id); } catch (err: any) { this.logger.error( `Failed deleting instance ${instance.id} (${name}): ${err?.message ?? err}`, @@ -1426,15 +1426,15 @@ export class ControllerPlugin extends BaseControllerPlugin { instanceConfig.set("instance.auto_start", true, "controller"); // instanceConfig.set("factorio.settings", { public: false, lan: false }, "controller"); - await this.controller.instanceCreate(instanceConfig); + await this.controller.instances.createInstance(instanceConfig); const instanceId = instanceConfig.get("instance.id"); try { - await this.controller.instanceAssign(instanceId, hostId); + await this.controller.instances.assignInstance(instanceId, hostId); } catch (err: any) { this.logger.error(`Failed to assign pathworld instance ${instanceId}: ${err?.message ?? err}`); try { - await this.controller.instanceDelete(instanceId); + await this.controller.instances.deleteInstance(instanceId); } catch { /* ignore */ } return; } diff --git a/instance.ts b/instance.ts index 5dc3ab0..9a90e3f 100644 --- a/instance.ts +++ b/instance.ts @@ -2,6 +2,15 @@ import * as lib from "@clusterio/lib"; import { BaseInstancePlugin } from "@clusterio/host"; import * as messages from "./messages"; +declare module "@clusterio/lib" { + export interface InstanceConfigFields { + "gridworld.tile_x": number; + "gridworld.tile_y": number; + "gridworld.tile_size": number; + "gridworld.surface_name": string; + } +} + type RailEntitiesIPC = { tile_x: number; tile_y: number; diff --git a/module/control.lua b/module/control.lua index f7fc6ea..17a44d9 100644 --- a/module/control.lua +++ b/module/control.lua @@ -328,7 +328,7 @@ ue_hooks.register("LuaTrain", "post_serialize", function(train_data, context) local new_schedule = table.deepcopy(train_data.schedule) table.remove(new_schedule.records, new_schedule.current) train_data.schedule = new_schedule - log("Modified schedule - current: " .. new_schedule.current .. " records: " .. serpent.block(new_schedule.records)) + -- log("Modified schedule - current: " .. new_schedule.current .. " records: " .. serpent.block(new_schedule.records)) end end return train_data diff --git a/package.json b/package.json index 581d492..5ab549c 100644 --- a/package.json +++ b/package.json @@ -18,9 +18,10 @@ "@clusterio/web_ui": "^2.0.0-alpha.0" }, "devDependencies": { - "@clusterio/controller": "^2.0.0-alpha.0", - "@clusterio/lib": "^2.0.0-alpha.0", - "@clusterio/web_ui": "^2.0.0-alpha.0", + "@clusterio/controller": "^2.0.0-alpha.23", + "@clusterio/host": "^2.0.0-alpha.23", + "@clusterio/lib": "^2.0.0-alpha.23", + "@clusterio/web_ui": "^2.0.0-alpha.23", "@types/node": "^20.17.19", "@types/react": "^18.3.18", "antd": "^5.24.2",