From ba7a4d522f4f6c98279eab4584a3496205835545 Mon Sep 17 00:00:00 2001 From: ILLISIS Date: Mon, 23 Mar 2026 01:29:30 +1100 Subject: [PATCH 01/15] 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 02/15] 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 03/15] 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 04/15] 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 05/15] 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 06/15] 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 07/15] 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 08/15] 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 09/15] 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", From 3a1d4053f7f8cc61633a57138dde95c5876a39a9 Mon Sep 17 00:00:00 2001 From: ILLISIS Date: Fri, 10 Apr 2026 23:38:19 +1000 Subject: [PATCH 10/15] Deduplicate IPC sends for rail entities and UE stops --- module/rail_sync_manager.lua | 30 +++++++++++++++++++----------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/module/rail_sync_manager.lua b/module/rail_sync_manager.lua index dde9e30..9d53f67 100644 --- a/module/rail_sync_manager.lua +++ b/module/rail_sync_manager.lua @@ -65,12 +65,16 @@ function rail_sync_manager.collect_and_send_rail_entities() end end - clusterio_api.send_json("gridworld:rail_entities", { - tile_x = config.tile_x, - tile_y = config.tile_y, - tile_size = config.tile_size, - entities = results, - }) + table.sort(results, function(a, b) + if a.x ~= b.x then return a.x < b.x end + return a.y < b.y + end) + local payload = { tile_x = config.tile_x, tile_y = config.tile_y, tile_size = config.tile_size, entities = results } + local serialized = helpers.table_to_json(payload) + if storage.gridworld_last_rail_entities_json ~= serialized then + storage.gridworld_last_rail_entities_json = serialized + clusterio_api.send_json("gridworld:rail_entities", payload) + end end -- Collect all ue_source_trainstop entities on every surface and send them via IPC. @@ -97,11 +101,15 @@ function rail_sync_manager.collect_and_send_ue_stops() end end - clusterio_api.send_json("gridworld:ue_stops", { - tile_x = config.tile_x, - tile_y = config.tile_y, - stops = results, - }) + table.sort(results, function(a, b) + return (a.stopName or "") < (b.stopName or "") + end) + local payload = { tile_x = config.tile_x, tile_y = config.tile_y, stops = results } + local serialized = helpers.table_to_json(payload) + if storage.gridworld_last_ue_stops_json ~= serialized then + storage.gridworld_last_ue_stops_json = serialized + clusterio_api.send_json("gridworld:ue_stops", payload) + end end -- Apply a set of rail entities sent from a tile instance onto this pathworld surface. From f8819f0d1619a669391a6be6fcf306b66482c607 Mon Sep 17 00:00:00 2001 From: ILLISIS Date: Fri, 10 Apr 2026 23:39:44 +1000 Subject: [PATCH 11/15] Harden train path manager lifecycle and state handling --- module/control.lua | 13 +++++ module/train_path_manager.lua | 92 ++++++++++++++++++++++++++--------- 2 files changed, 82 insertions(+), 23 deletions(-) diff --git a/module/control.lua b/module/control.lua index 17a44d9..510a024 100644 --- a/module/control.lua +++ b/module/control.lua @@ -315,6 +315,19 @@ end -- Serialization hooks for universal_edges train transfer +-- Clear pending train path request before a train is serialized for edge transfer +ue_hooks.register("LuaTrainComplete", "pre_serialize", function(_data, context) + local LuaTrain = context.LuaTrain + if not LuaTrain or not LuaTrain.valid then return end + local train_id = LuaTrain.front_stock.unit_number + local pending = storage.gridworld and storage.gridworld.train_path_requests + and storage.gridworld.train_path_requests[train_id] + if not pending then return end + if pending.render and pending.render.valid then pending.render.destroy() end + storage.gridworld.train_path_requests[train_id] = nil + clusterio_api.send_json("gridworld:clear_train_path_request", { id = train_id }) +end) + -- 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 diff --git a/module/train_path_manager.lua b/module/train_path_manager.lua index 1dfd665..99d8fad 100644 --- a/module/train_path_manager.lua +++ b/module/train_path_manager.lua @@ -23,6 +23,9 @@ local HANDLED_TRAIN_STATES = { function tpm.on_train_schedule_changed(event) local LuaTrain = event.train local train_id = LuaTrain.front_stock.unit_number + -- skip if we're applying a path result (we change the schedule ourselves) + if storage.gridworld._applying_path == train_id then return end + storage.gridworld.train_path_requests[train_id] = nil clusterio_api.send_json("gridworld:clear_train_path_request", { id = train_id }) end @@ -30,10 +33,20 @@ function tpm.on_train_changed_state(event) local old_state = event.old_state local LuaTrain = event.train local new_state = LuaTrain.state - -- game.print("old_state: " .. TRAIN_STATE_NAMES[old_state] .. ", new_state: " .. TRAIN_STATE_NAMES[new_state]) + local train_id = LuaTrain.front_stock.unit_number + local is_spawning = storage.universal_edges.delayed_entities + and storage.universal_edges.delayed_entities[train_id] + + -- Rule 1: ignore spawn-cycle transitions (UE carriage add/restore always goes through manual_control) + if is_spawning + and (old_state == defines.train_state.manual_control + or new_state == defines.train_state.manual_control) + then + return + end + + -- Rule 2: manual mode (not spawning) — cleanup if new_state == defines.train_state.manual_control then - -- clean up pending path request if one exists - local train_id = LuaTrain.front_stock.unit_number local pending = storage.gridworld.train_path_requests[train_id] if pending then if pending.render and pending.render.valid then @@ -42,23 +55,25 @@ function tpm.on_train_changed_state(event) storage.gridworld.train_path_requests[train_id] = nil clusterio_api.send_json("gridworld:clear_train_path_request", { id = train_id }) end - -- notify destination to remove proxy before we clear the schedule tpm.notify_proxy_removal(LuaTrain) - -- remove edge temporary stops from train schedule tpm.remove_temporary_schedule_stops(LuaTrain) return - elseif old_state == defines.train_state.manual_control and new_state == defines.train_state.destination_full then - local schedule = LuaTrain.schedule - local current_record = schedule and schedule.records and schedule.records[schedule.current] - if current_record and current_record.temporary then return end + end + + -- Rule 3: skip if current record is a temp edge waypoint we inserted + local schedule = LuaTrain.schedule + local current_record = schedule and schedule.records and schedule.records[schedule.current] + if current_record and current_record.temporary then return end + + -- Rule 4: skip if request already pending or path already applied + if storage.gridworld.train_path_requests[train_id] then return end + + -- Rules 5 & 6: request cross-tile path + if old_state == defines.train_state.manual_control + and new_state == defines.train_state.destination_full + then tpm.request_train_path(LuaTrain) elseif HANDLED_TRAIN_STATES[old_state] then - -- only request a new path when the train was previously waiting at a station - -- skip if the current schedule record is an edge waypoint we inserted; - -- allow interrupt temporary stops through so they can trigger re-pathing - local schedule = LuaTrain.schedule - local current_record = schedule and schedule.records and schedule.records[schedule.current] - if current_record and current_record.temporary then return end tpm.request_train_path(LuaTrain) end end @@ -77,12 +92,11 @@ function tpm.request_train_path(LuaTrain) local train_id = front_stock.unit_number local front_end = LuaTrain.front_end if not front_end then return end - -- set manual mode first (triggers on_train_changed_state synchronously; - -- storage entry must not exist yet so the handler knows we initiated it) - -- LuaTrain.manual_mode = true + -- skip if a request is already in-flight for this train + if storage.gridworld.train_path_requests[train_id] then return end -- add request to globals storage.gridworld.train_path_requests[train_id] = { - LuaTrain = LuaTrain, + front_stock = LuaTrain.front_stock, is_pathing = true, } local rail_pos = front_end.rail.position @@ -121,8 +135,14 @@ function tpm.apply_train_path_result(json) log("No pending train path request found for train id: " .. path_result.id) return end - local LuaTrain = storage.gridworld.train_path_requests[path_result.id].LuaTrain local pending = storage.gridworld.train_path_requests[path_result.id] + local front_stock = pending.front_stock + if not front_stock or not front_stock.valid then + if pending.render and pending.render.valid then pending.render.destroy() end + storage.gridworld.train_path_requests[path_result.id] = nil + return + end + local LuaTrain = front_stock.train if #path_result.path == 0 then -- no path found, re-enable train and let it retry naturally -- LuaTrain.manual_mode = false @@ -159,12 +179,15 @@ function tpm.apply_train_path_result(json) end -- point to the first temporary stop so the train paths there schedule.current = insert_index + -- suppress on_train_schedule_changed from clearing the pending entry + storage.gridworld._applying_path = path_result.id LuaTrain.schedule = schedule + storage.gridworld._applying_path = nil LuaTrain.manual_mode = false -- remove train status text if pending.render and pending.render.valid then pending.render.destroy() end - -- remove train from storage flag - storage.gridworld.train_path_requests[path_result.id] = nil + -- keep pending entry so spawn-cycling and repeated state changes don't re-request; + -- cleared by manual_control cleanup (rule 2) or external schedule change end function tpm.remove_temporary_schedule_stops(LuaTrain) @@ -241,6 +264,8 @@ function tpm.find_train_path(json) local path_request = helpers.json_to_table(json) if not path_request then return end assert(type(path_request) == "table") + -- clear any stale queued retry for this train before processing + storage.gridworld.train_path_requests[path_request.id] = nil tpm.process_path_request(path_request) end @@ -341,15 +366,36 @@ function tpm.process_path_request(path_request) -- adjust_pathworld_station_limit local station = goals[result.goal_index].train_stop station.trains_limit = math.max(0, (station.trains_limit or 0) - 1) - -- iterate path for ue_source_trainstop + -- iterate path for ue_source_trainstop, drawing a line between each consecutive rail local seen = {} + local prev_rail = start_rail for _, rail in ipairs(result.path) do if rail.valid then + rendering.draw_line{ + color = { r = 1, g = 0.8, b = 0, a = 0.7 }, + width = 2, + from = prev_rail, + to = rail, + surface = surface, + time_to_live = ttl, + draw_on_ground = true, + } + prev_rail = rail for _, rail_dir in ipairs({ defines.rail_direction.front, defines.rail_direction.back }) do local stop = rail.get_rail_segment_stop(rail_dir) if stop and stop.valid and stop.name == "ue_source_trainstop" and not seen[stop.backer_name] then seen[stop.backer_name] = true table.insert(path.path, stop.backer_name) + rendering.draw_circle{ + color = { r = 0, g = 0.6, b = 1, a = 0.9 }, + radius = 2, + width = 3, + filled = false, + target = stop, + surface = surface, + time_to_live = ttl, + draw_on_ground = true, + } end end end From 790cc75e3ea2db9abf6e77166e4a05b1f78ff127 Mon Sep 17 00:00:00 2001 From: ILLISIS Date: Fri, 10 Apr 2026 23:43:09 +1000 Subject: [PATCH 12/15] Add logging to proxy walk in controller --- controller.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/controller.ts b/controller.ts index 8940a53..b7d1590 100644 --- a/controller.ts +++ b/controller.ts @@ -1260,6 +1260,7 @@ export class ControllerPlugin extends BaseControllerPlugin { const edgeId = stopName.split(" ")[0]; const edge = ue.edgeDatastore.get(edgeId); if (!edge) { + this.logger.warn(`[gridworld] create_train_proxy path walk: edge ${edgeId} not in datastore, skipping`); continue; } currentInstanceId = (edge.source.instanceId === currentInstanceId) @@ -1267,6 +1268,7 @@ export class ControllerPlugin extends BaseControllerPlugin { : edge.source.instanceId; } const destinationInstanceId = currentInstanceId; + // this.logger.info(`[gridworld] create_train_proxy path walk: sourceInstance=${event.sourceInstanceId} hops=${event.path.length} destinationInstance=${destinationInstanceId}`); // Parse edge ID and offset from the last path entry const lastStop = event.path[event.path.length - 1]; From 41a60d2d470c3b1c7b4a19e058c9d422f10ada8c Mon Sep 17 00:00:00 2001 From: ILLISIS Date: Sun, 12 Apr 2026 17:59:08 +1000 Subject: [PATCH 13/15] change edge traversal message to show server name and direction --- controller.ts | 3 ++- instance.ts | 9 ++++++--- messages.ts | 1 + module/control.lua | 8 +++++--- 4 files changed, 14 insertions(+), 7 deletions(-) diff --git a/controller.ts b/controller.ts index b7d1590..cfd70ef 100644 --- a/controller.ts +++ b/controller.ts @@ -578,8 +578,9 @@ export class ControllerPlugin extends BaseControllerPlugin { throw new lib.ResponseError(`Host ${hostId} has no public address configured`); } const address = `${host.publicAddress}:${instance.gamePort || instance.config.get("factorio.game_port")}`; + const name = instance.config.get("instance.name") as string; this.logger.info(`Corner teleporting ${playerName} to ${address} (instance ${instanceId})`); - return { address }; + return { address, name }; } private async ensureEdgeBetween(tile: TileRecord, neighbor: TileRecord) { diff --git a/instance.ts b/instance.ts index 9a90e3f..b04c785 100644 --- a/instance.ts +++ b/instance.ts @@ -328,13 +328,16 @@ export class InstancePlugin extends BaseInstancePlugin { this.logger.warn(`No diagonal neighbor for corner ${data.corner}`); return; } - const { address } = await this.instance.sendTo( + const { address, name } = await this.instance.sendTo( "controller", new messages.GridworldCornerTeleportPlayer(data.player_name, targetInstanceId), ); - const escapedName = lib.escapeString(data.player_name); + const escapedPlayerName = lib.escapeString(data.player_name); const escapedAddress = lib.escapeString(address); - await this.sendRcon(`/sc gridworld.corner_teleport_response("${escapedName}", "${escapedAddress}")`); + const escapedServerName = lib.escapeString(name); + const cornerNames: Record = { ne: "northeast", se: "southeast", sw: "southwest", nw: "northwest" }; + const escapedDirection = lib.escapeString(cornerNames[data.corner] || data.corner); + await this.sendRcon(`/sc gridworld.corner_teleport_response("${escapedPlayerName}", "${escapedAddress}", "${escapedServerName}", "${escapedDirection}")`); } private async handleCornerEntityTransferIpc(data: CornerEntityTransferIPC) { diff --git a/messages.ts b/messages.ts index c32acd0..139b0d1 100644 --- a/messages.ts +++ b/messages.ts @@ -548,6 +548,7 @@ export class GridworldCornerTeleportPlayer { static Response = plainJson(Type.Object({ address: Type.String(), + name: Type.String(), })); } diff --git a/module/control.lua b/module/control.lua index 510a024..2bd34fe 100644 --- a/module/control.lua +++ b/module/control.lua @@ -90,7 +90,9 @@ end ---@param player_name string ---@param address string -function gridworld.corner_teleport_response(player_name, address) +---@param server_name string|nil +---@param direction string|nil +function gridworld.corner_teleport_response(player_name, address, server_name, direction) if player_name == nil or address == nil then return end local player = game.players[player_name] if player == nil then @@ -99,8 +101,8 @@ function gridworld.corner_teleport_response(player_name, address) end player.connect_to_server({ address = address, - name = "Diagonal transfer", - description = "Connect to diagonal server", + name = (server_name or "unknown"), + description = "server to the " .. (direction or "unknown"), }) end From ca9055c18033a992b0ed514d6f3b2763512cbd20 Mon Sep 17 00:00:00 2001 From: ILLISIS Date: Sun, 12 Apr 2026 18:01:39 +1000 Subject: [PATCH 14/15] silence server command --- controller.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/controller.ts b/controller.ts index cfd70ef..0d89bbe 100644 --- a/controller.ts +++ b/controller.ts @@ -912,7 +912,7 @@ export class ControllerPlugin extends BaseControllerPlugin { const escapedSurface = lib.escapeString(surface); const x = tile.x * tileSize; const y = tile.y * tileSize; - const command = `/c local force=game.forces.player; local surface=game.surfaces["${escapedSurface}"]; if force and surface then force.set_spawn_position({x=${x}, y=${y}}, surface) end`; + const command = `/sc local force=game.forces.player; local surface=game.surfaces["${escapedSurface}"]; if force and surface then force.set_spawn_position({x=${x}, y=${y}}, surface) end`; await this.controller.sendTo( { instanceId: tile.instanceId }, new lib.InstanceSendRconRequest(command), From f16b7d56efc5530ef1a64c6774f0b7f944d625fe Mon Sep 17 00:00:00 2001 From: ILLISIS Date: Sun, 12 Apr 2026 18:02:06 +1000 Subject: [PATCH 15/15] fix interrupts not pathing over edge --- module/train_path_manager.lua | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/module/train_path_manager.lua b/module/train_path_manager.lua index 99d8fad..da8c7c4 100644 --- a/module/train_path_manager.lua +++ b/module/train_path_manager.lua @@ -63,7 +63,9 @@ function tpm.on_train_changed_state(event) -- Rule 3: skip if current record is a temp edge waypoint we inserted local schedule = LuaTrain.schedule local current_record = schedule and schedule.records and schedule.records[schedule.current] - if current_record and current_record.temporary then return end + local current_station = current_record and current_record.station + local is_gridworld_stop = current_station and string.find(current_station, "gridworld:", 1, true) + if current_record and current_record.temporary and is_gridworld_stop then return end -- Rule 4: skip if request already pending or path already applied if storage.gridworld.train_path_requests[train_id] then return end