mirror of
https://github.com/clusterio/clusterio.git
synced 2026-11-11 23:04:52 +09:00
When a player connects with a Factorio version and set of built in mods no lobby runs, the lobby plugin now creates one on a connected host that has the version installed or can download it, with a mod pack matching the player's built in mods. This lets a cluster accept players on several Factorio versions on the same port without any setup. It can be turned off with lobby.auto_create. To pick the mod pack the proxy now waits for the whole connection confirm before routing and passes the mods listed in it to the playerProxyRoute hook. Hosts answer the new HostFactorioVersionsRequest with the versions they have installed. The server list in lobbies only shows instances on the Factorio version of the lobby, with a checkbox to also show the others greyed out.
483 lines
19 KiB
JavaScript
483 lines
19 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import fs from "node:fs/promises";
|
|
import path from "node:path";
|
|
import * as lib from "@clusterio/lib";
|
|
import { Controller, HostRecord, InstanceRecord } from "@clusterio/controller";
|
|
|
|
import * as mock from "../../../test/mock.js";
|
|
import { testRoundTripJsonSerialisable, testMatrix } from "../../../test/common.js";
|
|
import { plugin as info } from "../dist/node/index.js";
|
|
import * as instancePlugin from "../dist/node/instance.js";
|
|
import { LobbyController, parseAddress } from "../dist/node/controller.js";
|
|
import { MatchingServerClient } from "../dist/node/matching_server.js";
|
|
import {
|
|
LobbyServer, TransferRequest, TransferStatus, TransferStatusEvent, ServerListRequest,
|
|
} from "../dist/node/messages.js";
|
|
|
|
const v2_1_19 = { major: 2, minor: 1, sub: 19, build: 84000 };
|
|
const v2_0_72 = { major: 2, minor: 0, sub: 72, build: 83000 };
|
|
|
|
/** Fake of multiplayer.factorio.com recording the requests made to it */
|
|
function fakeMatchingServer() {
|
|
const server = {
|
|
games: new Map(),
|
|
requests: [],
|
|
nextId: 100,
|
|
fetch: async (url, options) => {
|
|
const uri = new URL(url).pathname.slice(1);
|
|
const body = options.body ? JSON.parse(options.body) : undefined;
|
|
server.requests.push({ uri, body });
|
|
const [operation, id] = uri.split("/");
|
|
const reply = (status, json) => new Response(JSON.stringify(json), { status });
|
|
if (operation === "create-game") {
|
|
const gameId = server.nextId;
|
|
server.nextId += 1;
|
|
server.games.set(gameId, body.game);
|
|
return reply(200, { game_id: gameId, server_id: `id-${gameId}`, server_secret: `secret-${gameId}` });
|
|
}
|
|
const game = server.games.get(Number(id));
|
|
if (!game || game.game_secret !== body.game_secret) {
|
|
return reply(404, { message: "game not found" });
|
|
}
|
|
if (operation === "update-game") {
|
|
server.games.set(Number(id), body.game);
|
|
} else if (operation === "remove-game") {
|
|
server.games.delete(Number(id));
|
|
}
|
|
return reply(200, { status: "ok" });
|
|
},
|
|
};
|
|
return server;
|
|
}
|
|
|
|
const listing = (fields = {}) => ({
|
|
name: "Cluster",
|
|
description: "",
|
|
tags: [],
|
|
maxPlayers: 0,
|
|
hostAddress: "203.0.113.1",
|
|
hostPort: 34197,
|
|
version: { version: "2.1.19", build: 84000 },
|
|
players: [],
|
|
...fields,
|
|
});
|
|
|
|
describe("lobby plugin", function () {
|
|
before(function () {
|
|
lib.addPluginConfigFields([info]);
|
|
try {
|
|
lib.registerPluginMessages([info]);
|
|
} catch (err) {
|
|
// Already registered by the full test suite
|
|
}
|
|
});
|
|
|
|
describe("messages", function () {
|
|
it("should round trip LobbyServer", function () {
|
|
testRoundTripJsonSerialisable(LobbyServer, testMatrix(
|
|
[1], ["Server"], ["running", "stopped"], [null, "2.1.19"], [0, 5],
|
|
));
|
|
});
|
|
it("should round trip TransferStatus", function () {
|
|
testRoundTripJsonSerialisable(TransferStatus, testMatrix(
|
|
["starting", "ready", "failed"], ["Server"], [undefined, "example.com:34197"], [undefined, "error"],
|
|
));
|
|
});
|
|
it("should round trip TransferRequest and TransferStatusEvent", function () {
|
|
testRoundTripJsonSerialisable(TransferRequest, testMatrix(["Player"], [1, 2]));
|
|
testRoundTripJsonSerialisable(TransferStatusEvent, testMatrix(
|
|
["Player"], [1], [new TransferStatus("ready", "Server", "127.0.0.1:1")],
|
|
));
|
|
});
|
|
});
|
|
|
|
describe("parseAddress()", function () {
|
|
it("should split host and port", function () {
|
|
assert.deepEqual(parseAddress("example.com:34197"), { host: "example.com", port: 34197 });
|
|
assert.deepEqual(parseAddress("1.2.3.4"), { host: "1.2.3.4", port: undefined });
|
|
assert.deepEqual(parseAddress("[::1]:5"), { host: "::1", port: 5 });
|
|
});
|
|
});
|
|
|
|
describe("MatchingServerClient", function () {
|
|
let server;
|
|
let client;
|
|
let statePath;
|
|
const credentials = { username: "user", token: "token" };
|
|
|
|
beforeEach(async function () {
|
|
server = fakeMatchingServer();
|
|
statePath = path.join("temp", "test", "lobby", `listings-${Date.now()}-${Math.random()}.json`);
|
|
client = new MatchingServerClient(new mock.MockLogger(), statePath, "https://matching.test", server.fetch);
|
|
});
|
|
|
|
afterEach(async function () {
|
|
await fs.rm(statePath, { force: true });
|
|
});
|
|
|
|
it("should create, heartbeat, update and remove games", async function () {
|
|
await client.sync(new Map([["a", listing()]]), credentials);
|
|
assert.equal(server.games.size, 1);
|
|
const [gameId] = server.games.keys();
|
|
assert.equal(server.games.get(gameId).host_port, 34197);
|
|
assert.equal(server.requests[0].body.username, "user");
|
|
|
|
await client.sync(new Map([["a", listing()]]), credentials);
|
|
assert.equal(server.requests.at(-1).uri, `post-game-heartbeat/${gameId}`);
|
|
|
|
await client.sync(new Map([["a", listing({ players: ["alice", "bob"] })]]), credentials);
|
|
assert.equal(server.requests.at(-1).uri, `update-game/${gameId}`);
|
|
assert.deepEqual(server.games.get(gameId).players, ["alice", "bob"]);
|
|
|
|
await client.sync(new Map(), credentials);
|
|
assert.equal(server.games.size, 0);
|
|
});
|
|
|
|
it("should register a game again if the matching server forgot it", async function () {
|
|
await client.sync(new Map([["a", listing()]]), credentials);
|
|
server.games.clear();
|
|
await client.sync(new Map([["a", listing()]]), credentials);
|
|
assert.equal(server.games.size, 1);
|
|
const create = server.requests.filter(r => r.uri === "create-game");
|
|
assert.equal(create.length, 2);
|
|
assert.equal(create[1].body.server_secret, "secret-100", "should keep server_id between games");
|
|
});
|
|
|
|
it("should take over listings from before a restart", async function () {
|
|
await client.sync(new Map([["a", listing()], ["b", listing()]]), credentials);
|
|
const restarted = new MatchingServerClient(
|
|
new mock.MockLogger(), statePath, "https://matching.test", server.fetch
|
|
);
|
|
server.requests = [];
|
|
await restarted.sync(new Map([["a", listing({ name: "Renamed" })]]), credentials);
|
|
assert.equal(server.games.size, 1);
|
|
assert.deepEqual(server.requests.map(r => r.uri.split("/")[0]).sort(), ["remove-game", "update-game"]);
|
|
});
|
|
|
|
it("should not create games without credentials", async function () {
|
|
await client.sync(new Map([["a", listing()]]));
|
|
assert.equal(server.games.size, 0);
|
|
});
|
|
});
|
|
|
|
describe("LobbyController", function () {
|
|
let controller;
|
|
let lobby;
|
|
let started;
|
|
let sent;
|
|
let proxy;
|
|
|
|
function addInstance(id, fields = {}, status = "running", factorioVersion = "2.1.19") {
|
|
const config = new lib.InstanceConfig("controller", {
|
|
"instance.id": id,
|
|
"instance.name": `Instance ${id}`,
|
|
"instance.assigned_host": 1,
|
|
"lobby.load_plugin": true,
|
|
...fields,
|
|
});
|
|
const instance = new InstanceRecord(config, status, 30000 + id, factorioVersion);
|
|
controller.instances.records.set(instance);
|
|
return instance;
|
|
}
|
|
|
|
function setOnline(name, instanceId) {
|
|
const user = controller.users.getOrCreateUser(name);
|
|
user.instances = new Set(instanceId === undefined ? [] : [instanceId]);
|
|
controller.users.records.set(user);
|
|
}
|
|
|
|
beforeEach(function () {
|
|
const config = new lib.ControllerConfig("controller", {
|
|
"controller.name": "Test Cluster",
|
|
"controller.public_url": "https://play.example.com/",
|
|
});
|
|
controller = new Controller(lib.logger, [], config);
|
|
controller.hosts.set(new HostRecord(1, "Host", "2.0.0", new Map(), true, "10.0.0.2", "198.51.100.2"));
|
|
proxy = {
|
|
port: 34197,
|
|
serverVersions: new Map(),
|
|
instanceVersion: async id => proxy.serverVersions.get(id),
|
|
};
|
|
controller.playerProxyServer = proxy;
|
|
started = [];
|
|
sent = [];
|
|
controller.sendTo = async (dst, message) => {
|
|
sent.push({ dst, message });
|
|
if (message instanceof lib.InstanceStartRequest) {
|
|
started.push(dst.instanceId);
|
|
}
|
|
};
|
|
lobby = new LobbyController(controller, new mock.MockLogger(), new MatchingServerClient(
|
|
new mock.MockLogger(), undefined, "https://matching.test", fakeMatchingServer().fetch,
|
|
));
|
|
|
|
addInstance(1, { "lobby.is_lobby": true });
|
|
addInstance(2, { "lobby.is_lobby": true }, "running", "2.0.72");
|
|
addInstance(3);
|
|
addInstance(4, { "instance.player_proxy_port": 34200 }, "stopped", "latest");
|
|
addInstance(5, { "lobby.listed": false });
|
|
});
|
|
|
|
const route = (fields = {}) => lobby.onPlayerProxyRoute({
|
|
username: "Player", version: v2_1_19, clientAddress: "1.2.3.4", clientPort: 1, listenPort: 34197, ...fields,
|
|
});
|
|
|
|
it("should list instances that are not lobbies", function () {
|
|
setOnline("alice", 3);
|
|
const servers = lobby.serverList();
|
|
assert.deepEqual(servers.map(s => s.id), [3, 4]);
|
|
assert.equal(servers[0].players, 1);
|
|
assert.equal(servers[0].version, "2.1.19");
|
|
assert.equal(servers[1].version, null, "stopped instance with unknown version");
|
|
});
|
|
|
|
it("should send players to the lobby for their version", async function () {
|
|
assert.equal(await route(), 1);
|
|
assert.equal(await route({ version: v2_0_72 }), 2);
|
|
assert.equal(await route({ version: { ...v2_1_19, sub: 1 } }), undefined);
|
|
});
|
|
|
|
it("should skip lobbies running a different build", async function () {
|
|
proxy.serverVersions.set(1, { ...v2_1_19, build: 1 });
|
|
assert.equal(await route(), undefined);
|
|
});
|
|
|
|
it("should transfer a player to a running instance", async function () {
|
|
const status = await lobby.handleTransferRequest(
|
|
new TransferRequest("Player", 3), lib.Address.fromShorthand({ instanceId: 1 })
|
|
);
|
|
assert.equal(status.state, "ready");
|
|
assert.equal(status.address, "play.example.com:34197");
|
|
assert.equal(await route(), 3, "both connection attempts should go to the target");
|
|
assert.equal(await route(), 3);
|
|
|
|
await lobby.onPlayerEvent(controller.instances.getMutable(3), { type: "join", name: "player" });
|
|
assert.equal(await route(), 1, "should go back to the lobby after joining");
|
|
});
|
|
|
|
it("should start a stopped instance and tell the lobby when it's ready", async function () {
|
|
controller.instances.getMutable(4).config.set("factorio.version", "2.1.19");
|
|
const status = await lobby.handleTransferRequest(
|
|
new TransferRequest("Player", 4), lib.Address.fromShorthand({ instanceId: 1 })
|
|
);
|
|
assert.equal(status.state, "starting");
|
|
await lib.wait(0);
|
|
assert.deepEqual(started, [4]);
|
|
assert.equal(await route(), 1, "should wait in the lobby while starting");
|
|
|
|
const instance = controller.instances.getMutable(4);
|
|
instance.status = "running";
|
|
await lobby.onInstanceStatusChanged(instance);
|
|
const event = sent.find(s => s.message instanceof TransferStatusEvent);
|
|
assert.deepEqual(event.dst, { instanceId: 1 });
|
|
assert.equal(event.message.status.state, "ready");
|
|
assert.equal(await route(), 4);
|
|
});
|
|
|
|
it("should report a transfer failing when the instance stops", async function () {
|
|
await lobby.handleTransferRequest(
|
|
new TransferRequest("Player", 4), lib.Address.fromShorthand({ instanceId: 1 })
|
|
);
|
|
await lobby.onInstanceStatusChanged(controller.instances.getMutable(4));
|
|
const event = sent.find(s => s.message instanceof TransferStatusEvent);
|
|
assert.equal(event.message.status.state, "failed");
|
|
assert.equal(lobby.transfers.size, 0);
|
|
});
|
|
|
|
it("should hold players for a stopped dedicated port instance in the lobby", async function () {
|
|
assert.equal(await route({ portInstanceId: 4 }), 1);
|
|
await lib.wait(0);
|
|
assert.deepEqual(started, [4]);
|
|
await lobby.onPlayerEvent(controller.instances.getMutable(1), { type: "join", name: "Player" });
|
|
const event = sent.find(s => s.message instanceof TransferStatusEvent);
|
|
assert.equal(event.message.status.state, "starting");
|
|
|
|
assert.equal(
|
|
await route({ username: "Other", portInstanceId: 3 }), undefined,
|
|
"running instances are routed by the proxy",
|
|
);
|
|
});
|
|
|
|
it("should build server browser listings", async function () {
|
|
controller.config.set("lobby.server_browser_listing", true);
|
|
controller.config.set("controller.player_proxy_public_address", "203.0.113.5:40000");
|
|
controller.instances.getMutable(4).config.set("lobby.server_browser_listing", true);
|
|
proxy.serverVersions.set(1, v2_1_19);
|
|
proxy.serverVersions.set(2, v2_0_72);
|
|
setOnline("alice", 3);
|
|
setOnline("bob", 1);
|
|
|
|
let listings = await lobby.desiredListings();
|
|
assert.deepEqual([...listings.keys()], ["cluster:2.1.19:84000", "cluster:2.0.72:83000"]);
|
|
const cluster = listings.get("cluster:2.1.19:84000");
|
|
assert.equal(cluster.name, "Test Cluster");
|
|
assert.equal(cluster.hostAddress, "203.0.113.5");
|
|
assert.equal(cluster.hostPort, 40000);
|
|
assert.deepEqual(cluster.players, ["alice", "bob"]);
|
|
|
|
proxy.serverVersions.set(4, v2_1_19);
|
|
listings = await lobby.desiredListings();
|
|
const own = listings.get("instance:4");
|
|
assert.equal(own.hostPort, 34200);
|
|
assert.equal(own.name, "Instance 4");
|
|
});
|
|
|
|
it("should not list anything without the proxy", async function () {
|
|
controller.config.set("lobby.server_browser_listing", true);
|
|
controller.playerProxyServer = null;
|
|
assert.equal((await lobby.desiredListings()).size, 0);
|
|
});
|
|
|
|
describe("lobby creation", function () {
|
|
const spaceAge = ["base", "elevated-rails", "quality", "space-age"]
|
|
.map(name => ({ name, version: "2.0.77" }));
|
|
const vanilla = [{ name: "base", version: "2.0.77" }, { name: "some-mod", version: "1.0.0" }];
|
|
const v2_0_77 = { major: 2, minor: 0, sub: 77, build: 83500 };
|
|
let hostVersions;
|
|
let assigned;
|
|
|
|
beforeEach(function () {
|
|
for (const pack of lib.ModPack.defaultModPacks) {
|
|
controller.modPacks.set(lib.ModPack.fromJSON(pack.toJSON()));
|
|
}
|
|
hostVersions = new Map([[1, { versions: ["2.1.19", "2.0.77"], canDownload: false }]]);
|
|
controller.hosts.set(new HostRecord(2, "Linux", "2.0.0", new Map(), true, "10.0.0.3"));
|
|
hostVersions.set(2, { versions: [], canDownload: true });
|
|
const sendTo = controller.sendTo;
|
|
controller.sendTo = async (dst, message) => {
|
|
if (message instanceof lib.HostFactorioVersionsRequest) {
|
|
return hostVersions.get(dst.hostId);
|
|
}
|
|
return await sendTo(dst, message);
|
|
};
|
|
controller.factorioVersions.get = async () => [{ version: "2.0.60" }];
|
|
assigned = [];
|
|
controller.instances.assignInstance = async (instanceId, hostId) => {
|
|
assigned.push([instanceId, hostId]);
|
|
const instance = controller.instances.getMutable(instanceId);
|
|
instance.config.set("instance.assigned_host", hostId);
|
|
instance.status = "stopped";
|
|
};
|
|
});
|
|
|
|
it("should create a lobby on a host with the version", async function () {
|
|
const id = await route({ version: v2_0_77, mods: spaceAge });
|
|
const instance = controller.instances.get(id);
|
|
assert.equal(instance.config.get("instance.name"), "Lobby 2.0.77");
|
|
assert.equal(instance.config.get("lobby.is_lobby"), true);
|
|
assert.equal(instance.config.get("factorio.version"), "2.0.77");
|
|
assert.equal(instance.config.get("instance.auto_start"), true);
|
|
assert.equal(instance.config.get("factorio.settings").visibility.public, false);
|
|
assert.deepEqual(assigned, [[id, 1]]);
|
|
const pack = controller.modPacks.get(instance.config.get("factorio.mod_pack_id"));
|
|
assert.equal(pack.name, "Space Age 2.0", "should reuse the default mod pack");
|
|
|
|
assert.equal(await route({ version: v2_0_77, mods: spaceAge }), id, "should reuse the lobby");
|
|
});
|
|
|
|
it("should create one lobby for players connecting at the same time", async function () {
|
|
const ids = await Promise.all([
|
|
route({ version: v2_0_77, mods: spaceAge }),
|
|
route({ username: "Other", version: v2_0_77, mods: spaceAge }),
|
|
]);
|
|
assert.equal(ids[0], ids[1]);
|
|
assert.equal(assigned.length, 1);
|
|
});
|
|
|
|
it("should create a separate lobby for other built in mods", async function () {
|
|
const expansion = await route({ version: v2_0_77, mods: spaceAge });
|
|
const base = await route({ version: v2_0_77, mods: vanilla });
|
|
assert.notEqual(base, expansion);
|
|
const instance = controller.instances.get(base);
|
|
assert.equal(instance.config.get("instance.name"), "Lobby 2.0.77 (base)");
|
|
const pack = controller.modPacks.get(instance.config.get("factorio.mod_pack_id"));
|
|
assert.equal(pack.name, "Base Game 2.0");
|
|
});
|
|
|
|
it("should create a mod pack if none matches", async function () {
|
|
const quality = [{ name: "base", version: "2.0.77" }, { name: "quality", version: "2.0.77" }];
|
|
const id = await route({ version: v2_0_77, mods: quality });
|
|
const pack = controller.modPacks.get(controller.instances.get(id).config.get("factorio.mod_pack_id"));
|
|
assert.equal(pack.name, "Lobby 2.0 (quality)");
|
|
assert.equal(pack.factorioVersion, "2.0");
|
|
assert.deepEqual(
|
|
[...pack.mods.values()].filter(mod => mod.enabled && mod.name !== "base").map(mod => mod.name),
|
|
["quality"],
|
|
);
|
|
});
|
|
|
|
it("should use a host that can download the version", async function () {
|
|
const id = await route({ version: { major: 2, minor: 0, sub: 60, build: 1 }, mods: [] });
|
|
assert.deepEqual(assigned, [[id, 2]]);
|
|
assert.equal(await route({ version: { major: 2, minor: 0, sub: 1, build: 1 } }), undefined);
|
|
});
|
|
|
|
it("should not create lobbies when disabled", async function () {
|
|
controller.config.set("lobby.auto_create", false);
|
|
assert.equal(await route({ version: v2_0_77, mods: spaceAge }), undefined);
|
|
assert.equal(assigned.length, 0);
|
|
});
|
|
|
|
it("should only pick lobbies with the same built in mods", async function () {
|
|
controller.config.set("controller.default_mod_pack_id", [...controller.modPacks.values()]
|
|
.find(pack => pack.name === "Space Age 2.1").id);
|
|
const mods = ["base", "elevated-rails", "quality", "recycler", "space-age"]
|
|
.map(name => ({ name, version: "2.1.19" }));
|
|
assert.equal(await route({ mods }), 1);
|
|
controller.config.set("lobby.auto_create", false);
|
|
assert.equal(await route({ mods: [{ name: "base", version: "2.1.19" }] }), undefined);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("instance plugin", function () {
|
|
let instance;
|
|
|
|
beforeEach(async function () {
|
|
({ instance } = await mock.loadInstancePlugin(instancePlugin.default, info));
|
|
instance.mockConfigEntries.set("lobby.is_lobby", true);
|
|
instance.sendTo = async (dst, message) => {
|
|
if (message instanceof ServerListRequest) {
|
|
return [new LobbyServer(3, "Instance \"3\"", "running", "2.1.19", 0)];
|
|
}
|
|
if (message instanceof TransferRequest) {
|
|
return new TransferStatus("ready", "Instance 3", "play.example.com:34197");
|
|
}
|
|
throw new Error("unexpected message");
|
|
};
|
|
});
|
|
|
|
afterEach(async function () {
|
|
await instance?.hooks.stop.invoke();
|
|
});
|
|
|
|
it("should configure the module and send the server list on start", async function () {
|
|
await instance.hooks.start.invoke();
|
|
const commands = instance.server.rconCommands;
|
|
assert.equal(commands[0], "/sc lobby.configure(true, 7357, false)");
|
|
assert(commands[1].startsWith("/sc lobby.set_servers(\""), commands[1]);
|
|
assert(commands[1].includes("Instance \\\\\\\"3\\\\\\\""), commands[1]);
|
|
});
|
|
|
|
it("should enable the web login button when player_auth is loaded", async function () {
|
|
instance.loadedPlugins.add({ name: "player_auth" });
|
|
await instance.hooks.start.invoke();
|
|
assert.equal(instance.server.rconCommands[0], "/sc lobby.configure(true, 7357, true)");
|
|
});
|
|
|
|
it("should move players once their transfer is ready", async function () {
|
|
instance.server.emit("ipc-lobby:connect", { player: "Player", instance_id: 3 });
|
|
await lib.wait(10);
|
|
assert.deepEqual(instance.server.rconCommands, [
|
|
"/sc lobby.connect_player(\"Player\", \"play.example.com:34197\", \"Instance 3\")",
|
|
]);
|
|
});
|
|
|
|
it("should show the status of a transfer that is starting", async function () {
|
|
const handler = mock.getHandler(instance, TransferStatusEvent);
|
|
await handler(new TransferStatusEvent("Player", 3, new TransferStatus("starting", "Instance 3")));
|
|
assert.match(instance.server.rconCommands[0], /^\/sc lobby.set_player_status\("Player", "Starting/);
|
|
});
|
|
});
|
|
});
|