Files
factorio-expcluster/exp_scenario/instance.ts
T
bbassieandClaude Opus 5.5 6821d62c10 Keep join messages on the controller and print them from the instance
Join messages were stored in exp_legacy's PlayerData, which nothing
ever synced. The controller now keeps a players.json datastore of
per-player settings keyed by player name, starting with the join
message, and saves it on the save and shutdown hooks. Records with
nothing set are removed.

When a player joins, the instance plugin fetches their record and
prints the message to everyone over rcon in the player's colour, so no
lua is needed to show it. The in-game /set-join-message and
/remove-join-message commands send ipc which the instance forwards to
the controller; /set-join-message without a message asks the instance,
which prints the stored message back to the player.

Greetings is now a feature. Disabling it stops the greeting, the
commands, and the printed join messages. The private greeting with the
community name and Discord link is shown to every joining player, even
those with a join message, since one is a welcome for the player and
the other an announcement to everyone else.

Two controller permissions cover the web interface:
exp_scenario.join_message.set for your own message and .set_other for
anyone's, checked against the requesting user the same way
exp_discord_auth checks unlinking. The seed grants them to Supporter
and Moderator alongside the in-game commands. The user page gets a
Scenario section showing the message with an edit button for users who
may change it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 22:10:31 +00:00

116 lines
4.4 KiB
TypeScript

import type { InstancePluginContext } from "@clusterio/host";
import * as lib from "@clusterio/lib";
import * as messages from "./messages.js";
import { features, FeatureValue } from "./features.js";
/** Sent by the lua side when a player sets or removes their join message, see module/control/greetings.lua */
export type IpcJoinMessageSet = { player_name: string, message?: string };
/** Sent by the lua side when a player asks for their join message */
export type IpcJoinMessageGet = { player_name: string };
/** Json as a lua expression for a table, inside a long bracket the json does not contain */
export function luaJson(value: unknown) {
const json = JSON.stringify(value);
let level = "=";
while (json.includes(`]${level}]`)) {
level += "=";
}
return `helpers.json_to_table[${level}[${json}]${level}]`;
}
/** Sends feature config and player settings to the lua side, see module/features.lua. */
export default async function (context: InstancePluginContext) {
const { instance, logger, plugin } = context;
/** Rcon is up and the lua side can take updates */
let started = false;
/** Enabled state of features which need it on this side, by name */
const featureEnabled = new Map<string, boolean>();
/** Subscribe, then send every feature */
async function syncFeatures() {
await instance.sendTo("controller", new lib.SubscriptionRequest(
`exp_scenario:${messages.FeatureUpdatedEvent.name}`, "subscribe", Date.now(),
));
const records = await instance.sendTo("controller", new messages.FeatureListRequest());
await luaSendFeatures(records);
}
/** Every value of each feature is sent, so the lua side sets them all */
async function luaSendFeatures(records: messages.FeatureRecord[]) {
if (!records.length) {
return;
}
const updates = records.map(record => {
featureEnabled.set(record.id, record.enabled);
const values: Record<string, FeatureValue> = { enabled: record.enabled };
for (const field of features.find(feature => feature.name === record.id)?.fields ?? []) {
values[field.name] = field.name in record.values ? record.values[field.name] : field.default;
}
return { name: record.id, values };
});
await instance.sendRcon(`/sc exp_scenario.features.receive_update(${luaJson(updates)})`, true);
}
/** Run lua with the player as `p` if they are still in the game, the data is available as `d` */
async function luaWithPlayer(name: string, data: Record<string, unknown>, code: string) {
if (instance.status !== "running") {
return;
}
await instance.sendRcon(
`/sc local d = ${luaJson({ ...data, name })} local p = game.get_player(d.name) if p then ${code} end`, true,
);
}
async function onPlayerJoin(name: string) {
const record = await instance.sendTo("controller", new messages.PlayerGetRequest(name));
if (record.joinMessage && featureEnabled.get("greetings") !== false) {
await luaWithPlayer(name, { message: record.joinMessage }, "game.print(d.message, { color = p.color })");
}
}
instance.handle(messages.FeatureUpdatedEvent, async event => {
if (started) {
await luaSendFeatures(event.updates.filter(feature => !feature.isDeleted));
}
});
instance.server.handle("exp_scenario:join_message_set", async (event: IpcJoinMessageSet) => {
const request = new messages.JoinMessageSetRequest(event.player_name, event.message ?? null);
await instance.sendTo("controller", request);
});
instance.server.handle("exp_scenario:join_message_get", async (event: IpcJoinMessageGet) => {
const record = await instance.sendTo("controller", new messages.PlayerGetRequest(event.player_name));
await luaWithPlayer(
event.player_name,
record.joinMessage ? { message: record.joinMessage } : {},
"p.print(d.message and { \"exp_greetings.message-get\", d.message } or { \"exp_greetings.message-none\" })",
);
});
instance.hooks.playerEvent.attach(plugin.name, async event => {
if (event.type === "join") {
await onPlayerJoin(event.name).catch(err => logger.error(
`Error showing the join message of ${event.name}:\n${err.stack ?? err.message}`,
));
}
});
instance.hooks.start.attach(plugin.name, async () => {
started = true;
await syncFeatures();
});
instance.hooks.exit.attach(plugin.name, async () => {
started = false;
});
// A new session means the controller restarted and forgot the subscription
instance.hooks.controllerConnectionEvent.attach(plugin.name, async event => {
if (event === "connect" && started) {
await syncFeatures();
}
});
}