mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-11 23:04:52 +09:00
The player proxy now listens on 34197 by default. New hosts default to the port range 34200-34299 so they don't hand that port to instances, and the controller logs an error and runs without the proxy if the port is taken, as hosts with the old range only skip it when the controller already holds it. Lobby instances start the save lobby.zip, created on first start as a one tile map with no autoplace and with the freeplay scenario replaced by an empty one. This uses the new chooseSave instance hook, which lets plugins pick or create the save to start, and setSaveScenario from @clusterio/host, which sets the scenario the save patcher keeps loading. Map gen settings can't use a width and height of 0 for this, as Factorio takes 0 to mean an unlimited map. The address players reach the proxy on moves from lobby.public_address to controller.player_proxy_public_address. When it's set the player_auth login page lists it once for the instances behind the proxy instead of their own addresses, and lobbies with player_auth loaded get a button that opens the web interface login dialog.
145 lines
4.6 KiB
TypeScript
145 lines
4.6 KiB
TypeScript
import fs from "node:fs/promises";
|
|
|
|
import * as lib from "@clusterio/lib";
|
|
import { type InstancePluginContext, setSaveScenario } from "@clusterio/host";
|
|
|
|
import { ServerListRequest, TransferRequest, TransferStatus, TransferStatusEvent } from "./messages.js";
|
|
|
|
const SERVER_LIST_INTERVAL_MS = 5_000;
|
|
|
|
/** Save lobby instances start, created if it doesn't exist */
|
|
export const LOBBY_SAVE = "lobby.zip";
|
|
|
|
/**
|
|
* Map gen settings for an empty world
|
|
*
|
|
* A width or height of 0 means unlimited to Factorio, so the map is made
|
|
* a single tile with everything outside it out of map.
|
|
*/
|
|
export const lobbyMapGenSettings = {
|
|
width: 1,
|
|
height: 1,
|
|
starting_points: [{ x: 0, y: 0 }],
|
|
peaceful_mode: true,
|
|
no_enemies_mode: true,
|
|
default_enable_all_autoplace_controls: false,
|
|
autoplace_controls: {},
|
|
autoplace_settings: {
|
|
entity: { treat_missing_as_default: false, settings: {} },
|
|
decorative: { treat_missing_as_default: false, settings: {} },
|
|
},
|
|
cliff_settings: { name: "cliff", richness: 0 },
|
|
};
|
|
|
|
// ./module/lobby.lua
|
|
type IpcConnect = {
|
|
player: string,
|
|
instance_id: number,
|
|
};
|
|
|
|
export default async function loadInstancePlugin(context: InstancePluginContext) {
|
|
const { instance, logger, plugin } = context;
|
|
let serverListTimer: ReturnType<typeof setInterval> | undefined;
|
|
let lastServerListJson = "";
|
|
|
|
// Commands are sent one at a time so they execute in order
|
|
let rconQueue: Promise<unknown> = Promise.resolve();
|
|
function sendOrderedRcon(command: string) {
|
|
const result = rconQueue.then(() => instance.sendRcon(command, true, plugin.name));
|
|
rconQueue = result.catch(() => {});
|
|
return result;
|
|
}
|
|
|
|
function running() {
|
|
return ["starting", "running"].includes(instance.status);
|
|
}
|
|
|
|
async function configure() {
|
|
const isLobby = instance.config.get("lobby.is_lobby");
|
|
// The player_auth module opens its login dialog from the same IPC message as its /web-login command
|
|
const webLogin = [...instance.loadedPlugins].some(info => info.name === "player_auth");
|
|
await sendOrderedRcon(`/sc lobby.configure(${isLobby}, ${instance.id}, ${webLogin})`);
|
|
}
|
|
|
|
async function updateServerList() {
|
|
if (!running()) {
|
|
return;
|
|
}
|
|
const servers = await instance.sendTo("controller", new ServerListRequest());
|
|
const json = JSON.stringify(servers);
|
|
if (json === lastServerListJson) {
|
|
return;
|
|
}
|
|
lastServerListJson = json;
|
|
await sendOrderedRcon(`/sc lobby.set_servers("${lib.escapeString(json)}")`);
|
|
}
|
|
|
|
async function applyStatus(player: string, status: TransferStatus) {
|
|
if (!running()) {
|
|
return;
|
|
}
|
|
const name = lib.escapeString(player);
|
|
if (status.state === "ready" && status.address) {
|
|
await sendOrderedRcon(
|
|
`/sc lobby.connect_player("${name}", "${lib.escapeString(status.address)}", ` +
|
|
`"${lib.escapeString(status.name)}")`
|
|
);
|
|
return;
|
|
}
|
|
const text = status.state === "starting"
|
|
? `Starting ${status.name}, you will be moved there once it's up.`
|
|
: `Unable to join ${status.name}: ${status.message ?? "unknown error"}`;
|
|
await sendOrderedRcon(`/sc lobby.set_player_status("${name}", "${lib.escapeString(text)}")`);
|
|
}
|
|
|
|
instance.hooks.chooseSave.attach(plugin.name, async () => {
|
|
if (!instance.config.get("lobby.is_lobby")) {
|
|
return undefined;
|
|
}
|
|
const savePath = instance.path("saves", LOBBY_SAVE);
|
|
try {
|
|
await fs.access(savePath);
|
|
} catch {
|
|
logger.info("Creating empty lobby save");
|
|
await instance.server.create(LOBBY_SAVE, undefined, lobbyMapGenSettings);
|
|
// Saves are created with the freeplay scenario, replace it with one that does nothing
|
|
await setSaveScenario(savePath, new lib.ModuleInfo("lobby", "0.0.0"));
|
|
}
|
|
return LOBBY_SAVE;
|
|
});
|
|
|
|
instance.hooks.start.attach(plugin.name, async () => {
|
|
await configure();
|
|
lastServerListJson = "";
|
|
await updateServerList();
|
|
serverListTimer = setInterval(() => {
|
|
updateServerList().catch(err => logger.error(`Failed to update server list:\n${err.stack}`));
|
|
}, SERVER_LIST_INTERVAL_MS);
|
|
});
|
|
|
|
instance.hooks.stop.attach(plugin.name, async () => {
|
|
clearInterval(serverListTimer);
|
|
serverListTimer = undefined;
|
|
});
|
|
|
|
instance.hooks.instanceConfigFieldChanged.attach(plugin.name, async field => {
|
|
if (field === "lobby.is_lobby" && running()) {
|
|
await configure();
|
|
}
|
|
});
|
|
|
|
instance.server.handle("lobby:connect", async (data: IpcConnect) => {
|
|
let status: TransferStatus;
|
|
try {
|
|
status = await instance.sendTo("controller", new TransferRequest(data.player, data.instance_id));
|
|
} catch (err: any) {
|
|
status = new TransferStatus("failed", String(data.instance_id), undefined, err.message);
|
|
}
|
|
await applyStatus(data.player, status);
|
|
});
|
|
|
|
instance.handle(TransferStatusEvent, async event => {
|
|
await applyStatus(event.player, event.status);
|
|
});
|
|
}
|