Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 85 additions & 31 deletions controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -29,6 +29,7 @@ type EdgeTargetSpec = {

type UniversalEdgesController = {
edgeDatastore?: Map<string, any>;
storageDirty?: boolean;
handleSetEdgeConfigRequest?: (request: { edge: any }) => Promise<void> | void;
};

Expand Down Expand Up @@ -177,6 +178,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);
Expand All @@ -200,9 +202,8 @@ 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") {
// Skip pathworld — it doesn't need daytime sync
if (instance.config.get("instance.name") === "pathworld") {
return;
}
Expand All @@ -213,6 +214,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);
}
}
}

Expand All @@ -227,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;
}
Expand Down Expand Up @@ -387,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,
Expand Down Expand Up @@ -419,6 +425,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;
}
Expand All @@ -441,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}`);
Expand Down Expand Up @@ -514,6 +521,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) {
Expand Down Expand Up @@ -698,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;
Expand Down Expand Up @@ -814,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}`,
Expand Down Expand Up @@ -1046,13 +1114,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,
Expand Down Expand Up @@ -1256,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(
Expand All @@ -1276,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}`,
Expand Down Expand Up @@ -1365,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;
}
Expand Down Expand Up @@ -1499,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;
}

Expand All @@ -1515,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(),
},
});
}
}
}
5 changes: 4 additions & 1 deletion index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -138,6 +138,9 @@ export const plugin: lib.PluginDeclaration = {
messages.GridworldForwardClearTrainPath,
messages.GridworldRemoveTrainProxy,
messages.GridworldForwardRemoveTrainProxy,
messages.GridworldCornerNeighbors,
messages.GridworldCornerTeleportPlayer,
messages.GridworldDiagonalEntityTransfer,
],

webEntrypoint: "./web",
Expand Down
88 changes: 88 additions & 0 deletions instance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -29,8 +38,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<string, unknown>;
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));
Expand Down Expand Up @@ -80,6 +110,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) {
Expand Down Expand Up @@ -268,4 +313,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}")`);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Send the diagonal payload before reconnecting the player

This reconnects the client before the actual diagonal transfer is emitted from corner_scanner.on_player_left_game() on the source server. The target side only consumes that payload in corner_scanner.on_player_joined_game() (module/corner_scanner.lua:149-175), so when the reconnect finishes before the instance-to-instance request is processed, the join hook runs first and the later payload is never applied. In that case diagonal transfers leave players at the normal spawn, and vehicle riders never get reattached.

Useful? React with 👍 / 👎.

}

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 };
}
}
Loading