mirror of
https://github.com/PHIDIAS0303/ExpCluster.git
synced 2026-11-15 00:24:52 +09:00
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>
This commit is contained in:
1 parent
4d3c4de5b1
commit
6821d62c10
17 files changed
+491
-40
No files matched your search
@@ -15,9 +15,6 @@ lhd=All trains must be LHD! This is a long standing rule on our servers, please
|
||||
|
||||
# data
|
||||
[exp-settings]
|
||||
JoinMessage=Join Message
|
||||
JoinMessage-tooltip=The message displayed when you join
|
||||
JoinMessage-value-tooltip=Change by using /join-message
|
||||
UsesServerUps=Server UPS
|
||||
UsesServerUps-tooltip=Whether the current server UPS is shown
|
||||
UsesServerUps-value-tooltip=Change by using /server-ups
|
||||
|
||||
@@ -15,9 +15,6 @@ lhd=列車必須是左則通行。這是本服務器長久以來的規則。
|
||||
|
||||
# data
|
||||
[exp-settings]
|
||||
JoinMessage=加入信息
|
||||
JoinMessage-tooltip=你加入時所顯示的信息
|
||||
JoinMessage-value-tooltip=使用 /join-message 來更換加入信息
|
||||
UsesServerUps=服務器更新數 (SUPS)
|
||||
UsesServerUps-tooltip=現在是否顯示服務器更新數 (SUPS)
|
||||
UsesServerUps-value-tooltip=使用 /server-ups 來更換顯示
|
||||
|
||||
@@ -15,9 +15,6 @@ lhd=列車必須是左則通行。這是本服務器長久以來的規則。
|
||||
|
||||
# data
|
||||
[exp-settings]
|
||||
JoinMessage=加入信息
|
||||
JoinMessage-tooltip=你加入時所顯示的信息
|
||||
JoinMessage-value-tooltip=使用 /join-message 來更換加入信息
|
||||
UsesServerUps=服務器更新數 (SUPS)
|
||||
UsesServerUps-tooltip=現在是否顯示服務器更新數 (SUPS)
|
||||
UsesServerUps-value-tooltip=使用 /server-ups 來更換顯示
|
||||
|
||||
@@ -18,6 +18,26 @@ export default async function (context: ControllerPluginContext) {
|
||||
messages.FeatureRecord.fromJSON.bind(messages.FeatureRecord),
|
||||
).bootstrap()
|
||||
);
|
||||
const playerStore = new lib.SubscribableDatastore(
|
||||
...await new lib.JsonIdDatastoreProvider(
|
||||
path.join(databaseDirectory, "exp_scenario", "players.json"),
|
||||
messages.PlayerRecord.fromJSON.bind(messages.PlayerRecord),
|
||||
).bootstrap()
|
||||
);
|
||||
|
||||
type PlayerSettings = Pick<messages.PlayerRecord, "joinMessage">;
|
||||
|
||||
/** Change some settings of a player, empty values are removed and so is a record left empty */
|
||||
function updatePlayer(name: string, changes: PlayerSettings) {
|
||||
const old = playerStore.get(name);
|
||||
const settings: PlayerSettings = { joinMessage: old?.joinMessage, ...changes };
|
||||
const record = new messages.PlayerRecord(name, settings.joinMessage || undefined);
|
||||
if (record.joinMessage) {
|
||||
playerStore.set(record);
|
||||
} else if (old) {
|
||||
playerStore.delete(old);
|
||||
}
|
||||
}
|
||||
|
||||
/** Add new features, delete removed ones, and drop stored values which no longer fit a field */
|
||||
function reconcileFeatures() {
|
||||
@@ -202,8 +222,21 @@ export default async function (context: ControllerPluginContext) {
|
||||
return feature;
|
||||
});
|
||||
|
||||
controller.handle(messages.PlayerGetRequest, async (request: messages.PlayerGetRequest) => (
|
||||
playerStore.get(request.playerName) ?? new messages.PlayerRecord(request.playerName)
|
||||
));
|
||||
|
||||
controller.handle(messages.JoinMessageSetRequest, async (request: messages.JoinMessageSetRequest) => {
|
||||
updatePlayer(request.playerName, { joinMessage: request.message?.trim() });
|
||||
});
|
||||
|
||||
controller.hooks.save.attach(plugin.name, async () => {
|
||||
await playerStore.save();
|
||||
});
|
||||
|
||||
controller.hooks.shutdown.attach(plugin.name, async () => {
|
||||
await featureStore.save();
|
||||
await playerStore.save();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -174,6 +174,12 @@ export const features: Feature[] = [
|
||||
num("rocket_launch_display_rate", "Rocket log interval", "After the first few launches, log every this many rockets", 500, 1, "rockets"),
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "greetings",
|
||||
title: "Greetings",
|
||||
description: "Greet players when they join and show the join message they set, the messages are edited on the user page",
|
||||
fields: [],
|
||||
},
|
||||
{
|
||||
name: "help_bubbles",
|
||||
title: "Help Bubbles",
|
||||
|
||||
@@ -8,6 +8,8 @@ declare module "@clusterio/lib" {
|
||||
"exp_scenario.config.view": never;
|
||||
"exp_scenario.config.edit": never;
|
||||
"exp_scenario.seed": never;
|
||||
"exp_scenario.join_message.set": never;
|
||||
"exp_scenario.join_message.set_other": never;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +25,8 @@ export const plugin: lib.PluginDeclaration = {
|
||||
messages.FeatureUpdatedEvent,
|
||||
messages.FeatureListRequest,
|
||||
messages.FeatureUpdateRequest,
|
||||
messages.PlayerGetRequest,
|
||||
messages.JoinMessageSetRequest,
|
||||
],
|
||||
|
||||
permissions: [
|
||||
@@ -42,6 +46,16 @@ export const plugin: lib.PluginDeclaration = {
|
||||
title: "Seed ExpScenario roles and groups",
|
||||
description: "Create the roles and permission groups the scenario shipped with",
|
||||
},
|
||||
{
|
||||
name: "exp_scenario.join_message.set",
|
||||
title: "Set own join message",
|
||||
description: "Set the message shown to everyone when you join, in game this is /set-join-message",
|
||||
},
|
||||
{
|
||||
name: "exp_scenario.join_message.set_other",
|
||||
title: "Set join messages of others",
|
||||
description: "Set the message shown to everyone when another user joins",
|
||||
},
|
||||
],
|
||||
|
||||
webEntrypoint: "./web",
|
||||
|
||||
@@ -3,11 +3,28 @@ import * as lib from "@clusterio/lib";
|
||||
import * as messages from "./messages.js";
|
||||
import { features, FeatureValue } from "./features.js";
|
||||
|
||||
/** Sends feature config to the lua side, see module/features.lua. */
|
||||
/** 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, plugin } = context;
|
||||
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() {
|
||||
@@ -25,29 +42,61 @@ export default async function (context: InstancePluginContext) {
|
||||
}
|
||||
|
||||
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 };
|
||||
});
|
||||
const json = JSON.stringify(updates);
|
||||
// Pick a long bracket the json does not contain
|
||||
let level = "=";
|
||||
while (json.includes(`]${level}]`)) {
|
||||
level += "=";
|
||||
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 exp_scenario.features.receive_update(helpers.json_to_table[${level}[${json}]${level}])`, true,
|
||||
`/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();
|
||||
|
||||
@@ -126,3 +126,119 @@ export class FeatureUpdateRequest {
|
||||
return new this(json.id, json.enabled, json.values);
|
||||
}
|
||||
}
|
||||
|
||||
/** Settings of a player kept by the controller, by player name. Only set values are stored. */
|
||||
export class PlayerRecord {
|
||||
constructor(
|
||||
/** Name of the player */
|
||||
public id: string,
|
||||
/** Shown to everyone when the player joins */
|
||||
public joinMessage?: string,
|
||||
public updatedAtMs: number = 0,
|
||||
public isDeleted: boolean = false,
|
||||
) {}
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
id: Type.String(),
|
||||
join_message: Type.Optional(Type.String()),
|
||||
updated_at_ms: Type.Optional(Type.Number()),
|
||||
is_deleted: Type.Optional(Type.Boolean()),
|
||||
});
|
||||
|
||||
toJSON() {
|
||||
const json: Static<typeof PlayerRecord.jsonSchema> = { id: this.id };
|
||||
if (this.joinMessage) {
|
||||
json.join_message = this.joinMessage;
|
||||
}
|
||||
if (this.updatedAtMs) {
|
||||
json.updated_at_ms = this.updatedAtMs;
|
||||
}
|
||||
if (this.isDeleted) {
|
||||
json.is_deleted = true;
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
return new this(
|
||||
json.id,
|
||||
json.join_message,
|
||||
json.updated_at_ms ?? 0,
|
||||
json.is_deleted ?? false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Users may always read their own settings, otherwise they need to be able to see users. */
|
||||
function selfOrPermission(permission: lib.PermissionName) {
|
||||
return (user: lib.IUser, message: lib.MessageRequest) => {
|
||||
const playerName = (message.data as { player_name: string }).player_name;
|
||||
if (user.name.toLowerCase() !== playerName.toLowerCase()) {
|
||||
user.checkPermission(permission);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** Get the settings of a player, an empty record when nothing is stored. */
|
||||
export class PlayerGetRequest {
|
||||
declare ["constructor"]: typeof PlayerGetRequest;
|
||||
static plugin = "exp_scenario" as const;
|
||||
static type = "request" as const;
|
||||
static src = ["control", "instance"] as const;
|
||||
static dst = "controller" as const;
|
||||
static permission = selfOrPermission("core.user.get");
|
||||
static Response = PlayerRecord;
|
||||
|
||||
constructor(
|
||||
public playerName: string,
|
||||
) {}
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
player_name: Type.String(),
|
||||
});
|
||||
|
||||
toJSON() {
|
||||
return { player_name: this.playerName };
|
||||
}
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
return new this(json.player_name);
|
||||
}
|
||||
}
|
||||
|
||||
export const joinMessageMaxLength = 255;
|
||||
|
||||
/** Set or clear (null or empty) the join message of a player. */
|
||||
export class JoinMessageSetRequest {
|
||||
declare ["constructor"]: typeof JoinMessageSetRequest;
|
||||
static plugin = "exp_scenario" as const;
|
||||
static type = "request" as const;
|
||||
static src = ["control", "instance"] as const;
|
||||
static dst = "controller" as const;
|
||||
static permission(user: lib.IUser, message: lib.MessageRequest) {
|
||||
const playerName = (message.data as { player_name: string }).player_name;
|
||||
if (user.name.toLowerCase() === playerName.toLowerCase()) {
|
||||
user.checkPermission("exp_scenario.join_message.set");
|
||||
} else {
|
||||
user.checkPermission("exp_scenario.join_message.set_other");
|
||||
}
|
||||
}
|
||||
|
||||
constructor(
|
||||
public playerName: string,
|
||||
public message: string | null,
|
||||
) {}
|
||||
|
||||
static jsonSchema = Type.Object({
|
||||
player_name: Type.String(),
|
||||
message: Type.Union([Type.String({ maxLength: joinMessageMaxLength }), Type.Null()]),
|
||||
});
|
||||
|
||||
toJSON() {
|
||||
return { player_name: this.playerName, message: this.message };
|
||||
}
|
||||
|
||||
static fromJSON(json: Static<typeof this.jsonSchema>) {
|
||||
return new this(json.player_name, json.message);
|
||||
}
|
||||
}
|
||||
@@ -1,46 +1,50 @@
|
||||
--[[-- Control - Greetings
|
||||
Greets players when they join, players can set a message shown to everyone instead
|
||||
Greets players when they join, and lets players set a message shown to everyone when they join
|
||||
|
||||
The join messages are kept by the controller, the instance plugin prints them when a player joins
|
||||
]]
|
||||
|
||||
local Commands = require("modules/exp_commands")
|
||||
local Feature = require("modules/exp_scenario/features")
|
||||
local clusterio_api = require("modules/clusterio/api")
|
||||
local community_details = require("modules/exp_scenario/config/community_details").config
|
||||
|
||||
local PlayerData = require("modules.exp_legacy.expcore.player_data")
|
||||
local CustomMessages = PlayerData.Settings:combine("JoinMessage")
|
||||
CustomMessages:set_metadata{
|
||||
permission = "exp_scenario.command.set_join_message",
|
||||
}
|
||||
local feature = Feature.register("greetings")
|
||||
|
||||
--- Show the message of a player once their data loads, or greet them
|
||||
CustomMessages:on_load(function(player_name, player_message)
|
||||
local player = assert(game.get_player(player_name))
|
||||
if player_message then
|
||||
game.print(player_message, { color = player.color })
|
||||
else
|
||||
player.print{ "exp_greetings.greet", community_details.community_name, community_details.discord }
|
||||
end
|
||||
end)
|
||||
--- @param event EventData.on_player_joined_game
|
||||
local function on_player_joined_game(event)
|
||||
local player = assert(game.get_player(event.player_index))
|
||||
player.print{ "exp_greetings.greet", community_details.community_name, community_details.discord }
|
||||
end
|
||||
|
||||
--- Set your custom join message
|
||||
--- Set your custom join message, or show it when no message is given
|
||||
Commands.new("set-join-message", { "exp_greetings.description-set" })
|
||||
:optional("message", { "exp_greetings.arg-message" }, Commands.types.string_max_length(255))
|
||||
:enable_auto_concatenation()
|
||||
:add_aliases{ "join-message" }
|
||||
:add_flags{ feature = feature }
|
||||
:register(function(player, message)
|
||||
--- @cast message string?
|
||||
if message then
|
||||
CustomMessages:set(player, message)
|
||||
clusterio_api.send_json("exp_scenario:join_message_set", { player_name = player.name, message = message })
|
||||
return Commands.status.success{ "exp_greetings.message-set" }
|
||||
else
|
||||
return Commands.status.success{ "exp_greetings.message-get", CustomMessages:get(player) or "" }
|
||||
end
|
||||
-- The instance plugin prints the message
|
||||
clusterio_api.send_json("exp_scenario:join_message_get", { player_name = player.name })
|
||||
end)
|
||||
|
||||
--- Removes your custom join message
|
||||
Commands.new("remove-join-message", { "exp_greetings.description-remove" })
|
||||
:add_flags{ feature = feature }
|
||||
:register(function(player)
|
||||
CustomMessages:remove(player)
|
||||
clusterio_api.send_json("exp_scenario:join_message_set", { player_name = player.name })
|
||||
return Commands.status.success{ "exp_greetings.message-cleared" }
|
||||
end)
|
||||
|
||||
return {}
|
||||
local e = defines.events
|
||||
|
||||
return feature:guard{
|
||||
events = {
|
||||
[e.on_player_joined_game] = on_player_joined_game,
|
||||
},
|
||||
}
|
||||
@@ -594,6 +594,7 @@ greet=[color=0,1,0] Welcome to __1__ community server! If you like the server jo
|
||||
message-set=Your join message has been updated.
|
||||
message-get=Your join message is: __1__
|
||||
message-cleared=Your join message has been cleared.
|
||||
message-none=You have not set a join message.
|
||||
|
||||
[exp_nuke-protection]
|
||||
chat-found=You cannot have __1__ in your inventory, so it was placed into the chests at spawn.
|
||||
|
||||
@@ -586,6 +586,7 @@ greet=[color=0,1,0] 歡迎來到 __1__ 的服務器! 若果你喜歡本服務器
|
||||
message-set=你的加入信息已更新。
|
||||
message-get=你的加入信息為: __1__
|
||||
message-cleared=你的加入信息已清除。
|
||||
message-none=你沒有設定加入信息。
|
||||
|
||||
[exp_nuke-protection]
|
||||
chat-found=你的用戶組不允許你有 __1__ ,所以該物品已放在出生點的箱子。
|
||||
|
||||
@@ -586,6 +586,7 @@ greet=[color=0,1,0] 歡迎來到 __1__ 的服務器! 若果你喜歡本服務器
|
||||
message-set=你的加入信息已更新。
|
||||
message-get=你的加入信息為: __1__
|
||||
message-cleared=你的加入信息已清除。
|
||||
message-none=你沒有設定加入信息。
|
||||
|
||||
[exp_nuke-protection]
|
||||
chat-found=你的用戶組不允許你有 __1__ ,所以該物品已放在出生點的箱子。
|
||||
|
||||
@@ -87,6 +87,7 @@ export const seedRoles: SeedRole[] = [
|
||||
"exp_scenario.command.tag_clear.always",
|
||||
"exp_scenario.command.spawn.always",
|
||||
"exp_scenario.command.clear_reports",
|
||||
"exp_scenario.join_message.set_other",
|
||||
"exp_scenario.command.clear_inventory",
|
||||
"exp_scenario.command.kill_enemies",
|
||||
"exp_scenario.command.remove_enemies",
|
||||
@@ -193,6 +194,7 @@ export const seedRoles: SeedRole[] = [
|
||||
"exp_scenario.command.unjail",
|
||||
"exp_scenario.command.set_join_message",
|
||||
"exp_scenario.command.remove_join_message",
|
||||
"exp_scenario.join_message.set",
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -66,6 +66,9 @@ async function startPlugin(t2, { withPlugins = true, databaseDirectory = t2.test
|
||||
updateFeature: request => handlers.get(messages.FeatureUpdateRequest)(request),
|
||||
featureSubscription: request => handlers.get(messages.FeatureUpdatedEvent)(request),
|
||||
shutdown: () => controller.hooks.shutdown.invoke(),
|
||||
save: () => controller.hooks.save.invoke(),
|
||||
/** Call the handler of a message as it would be called for a request or event */
|
||||
send: message => handlers.get(message.constructor)(message),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -226,3 +229,82 @@ t.test("controller plugin", t2 => {
|
||||
|
||||
t2.end();
|
||||
});
|
||||
|
||||
/** Read players.json as the next run would. */
|
||||
async function loadPlayers(databaseDirectory) {
|
||||
return new lib.JsonIdDatastoreProvider(
|
||||
path.join(databaseDirectory, "exp_scenario", "players.json"),
|
||||
messages.PlayerRecord.fromJSON.bind(messages.PlayerRecord),
|
||||
).load();
|
||||
}
|
||||
|
||||
t.test("player settings", t2 => {
|
||||
t2.test("PlayerGetRequest returns an empty record for unknown players", async t3 => {
|
||||
const { send } = await startPlugin(t3, { withPlugins: false });
|
||||
const record = await send(new messages.PlayerGetRequest("Alice"));
|
||||
t3.equal(record.id, "Alice");
|
||||
t3.strictSame(record.toJSON(), { id: "Alice" }, "nothing is set");
|
||||
});
|
||||
|
||||
t2.test("JoinMessageSetRequest sets, trims, and clears the message", async t3 => {
|
||||
const { send } = await startPlugin(t3, { withPlugins: false });
|
||||
await send(new messages.JoinMessageSetRequest("Alice", " Hello there "));
|
||||
t3.equal((await send(new messages.PlayerGetRequest("Alice"))).joinMessage, "Hello there");
|
||||
|
||||
await send(new messages.JoinMessageSetRequest("Alice", null));
|
||||
t3.equal((await send(new messages.PlayerGetRequest("Alice"))).joinMessage, undefined, "null clears it");
|
||||
|
||||
await send(new messages.JoinMessageSetRequest("Alice", "Hi"));
|
||||
await send(new messages.JoinMessageSetRequest("Alice", " "));
|
||||
t3.equal((await send(new messages.PlayerGetRequest("Alice"))).joinMessage, undefined, "blank clears it");
|
||||
});
|
||||
|
||||
t2.test("players are saved on the save hook and empty records are removed", async t3 => {
|
||||
const databaseDirectory = t3.testdir();
|
||||
const { send, save, shutdown } = await startPlugin(t3, { withPlugins: false, databaseDirectory });
|
||||
await send(new messages.JoinMessageSetRequest("Alice", "Hi"));
|
||||
await send(new messages.JoinMessageSetRequest("Bob", "Hey"));
|
||||
await send(new messages.JoinMessageSetRequest("Bob", null));
|
||||
await save();
|
||||
|
||||
const stored = await loadPlayers(databaseDirectory);
|
||||
t3.strictSame([...stored.keys()], ["Alice"], "only players with a setting are stored");
|
||||
t3.equal(stored.get("Alice").joinMessage, "Hi");
|
||||
|
||||
await send(new messages.JoinMessageSetRequest("Alice", "Bye"));
|
||||
await shutdown();
|
||||
const restarted = await startPlugin(t3, { withPlugins: false, databaseDirectory });
|
||||
const record = await restarted.send(new messages.PlayerGetRequest("Alice"));
|
||||
t3.equal(record.joinMessage, "Bye", "kept across restarts");
|
||||
});
|
||||
|
||||
t2.test("permissions", async t3 => {
|
||||
const user = (name, granted = []) => ({
|
||||
name,
|
||||
checkPermission(permission) {
|
||||
if (!granted.includes(permission)) {
|
||||
throw new lib.PermissionError("denied");
|
||||
}
|
||||
},
|
||||
});
|
||||
const request = name => ({ data: { player_name: name } });
|
||||
|
||||
const set = messages.JoinMessageSetRequest.permission;
|
||||
t3.throws(() => set(user("Alice"), request("alice")), "own message needs the set permission");
|
||||
t3.doesNotThrow(() => set(user("Alice", ["exp_scenario.join_message.set"]), request("alice")), "own message");
|
||||
t3.throws(
|
||||
() => set(user("Alice", ["exp_scenario.join_message.set"]), request("Bob")),
|
||||
"someone else's message needs set_other",
|
||||
);
|
||||
t3.doesNotThrow(
|
||||
() => set(user("Alice", ["exp_scenario.join_message.set_other"]), request("Bob")), "with set_other",
|
||||
);
|
||||
|
||||
const get = messages.PlayerGetRequest.permission;
|
||||
t3.doesNotThrow(() => get(user("Alice"), request("alice")), "own settings");
|
||||
t3.throws(() => get(user("Alice"), request("Bob")), "someone else's");
|
||||
t3.doesNotThrow(() => get(user("Alice", ["core.user.get"]), request("Bob")), "with core.user.get");
|
||||
});
|
||||
|
||||
t2.end();
|
||||
});
|
||||
@@ -25,7 +25,9 @@ class TestConnector extends lib.BaseConnector {
|
||||
}
|
||||
|
||||
/** Run the plugin on a real instance with spies on what leaves it, returns the event handler it registered. */
|
||||
async function startPlugin(t2, features = [new messages.FeatureRecord("death_markers", false, { show_map_markers: false })]) {
|
||||
async function startPlugin(
|
||||
t2, features = [new messages.FeatureRecord("death_markers", false, { show_map_markers: false })], players = [],
|
||||
) {
|
||||
const instanceConfig = new lib.InstanceConfig("host");
|
||||
instanceConfig.set("instance.id", 1);
|
||||
instanceConfig.set("instance.name", "test");
|
||||
@@ -35,8 +37,9 @@ async function startPlugin(t2, features = [new messages.FeatureRecord("death_mar
|
||||
);
|
||||
|
||||
const state = { sent: [], rcons: [] };
|
||||
const ipcHandlers = new Map();
|
||||
instance.server = {
|
||||
handle: () => {},
|
||||
handle: (channel, handler) => ipcHandlers.set(channel, handler),
|
||||
sendRcon: async command => {
|
||||
state.rcons.push(command);
|
||||
return "";
|
||||
@@ -44,6 +47,10 @@ async function startPlugin(t2, features = [new messages.FeatureRecord("death_mar
|
||||
};
|
||||
instance.sendTo = async (dst, request) => {
|
||||
state.sent.push(request);
|
||||
if (request instanceof messages.PlayerGetRequest) {
|
||||
const found = players.find(record => record.id === request.playerName);
|
||||
return found ?? new messages.PlayerRecord(request.playerName);
|
||||
}
|
||||
return request instanceof messages.FeatureListRequest ? features : undefined;
|
||||
};
|
||||
|
||||
@@ -58,6 +65,9 @@ async function startPlugin(t2, features = [new messages.FeatureRecord("death_mar
|
||||
exit: () => hooks.exit.invoke(),
|
||||
connectionEvent: event => hooks.controllerConnectionEvent.invoke(event),
|
||||
featureUpdated: handlers.get(messages.FeatureUpdatedEvent),
|
||||
ipc: (channel, event) => ipcHandlers.get(channel)(event),
|
||||
playerEvent: event => hooks.playerEvent.invoke(event),
|
||||
setStatus: status => { instance._status = status; },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -67,6 +77,14 @@ function decodeRcon(command) {
|
||||
return JSON.parse(match[2]);
|
||||
}
|
||||
|
||||
/** The player data and code of a recorded rcon command made by luaWithPlayer. */
|
||||
function decodePlayerRcon(command) {
|
||||
const match = command.match(
|
||||
/^\/sc local d = helpers\.json_to_table\[(=+)\[(.*)\]\1\] local p = game\.get_player\(d\.name\) if p then (.*) end$/s,
|
||||
);
|
||||
return { data: JSON.parse(match[2]), code: match[3] };
|
||||
}
|
||||
|
||||
t.test("instance plugin", t2 => {
|
||||
t2.test("start subscribes and sends every feature to lua", async t3 => {
|
||||
const { start, state } = await startPlugin(t3);
|
||||
@@ -134,3 +152,59 @@ t.test("instance plugin", t2 => {
|
||||
|
||||
t2.end();
|
||||
});
|
||||
|
||||
t.test("join messages", t2 => {
|
||||
const alice = new messages.PlayerRecord("Alice", "Alice has arrived ]=] ]==]");
|
||||
|
||||
t2.test("a joining player's message is printed to everyone", async t3 => {
|
||||
const { start, playerEvent, setStatus, state } = await startPlugin(t3, [], [alice]);
|
||||
await start();
|
||||
setStatus("running");
|
||||
state.rcons.length = 0;
|
||||
|
||||
await playerEvent({ type: "join", name: "Alice" });
|
||||
t3.equal(state.rcons.length, 1);
|
||||
const { data, code } = decodePlayerRcon(state.rcons[0]);
|
||||
t3.strictSame(data, { message: alice.joinMessage, name: "Alice" }, "the message survives the long bracket");
|
||||
t3.equal(code, "game.print(d.message, { color = p.color })");
|
||||
|
||||
await playerEvent({ type: "join", name: "Bob" });
|
||||
await playerEvent({ type: "leave", name: "Alice" });
|
||||
t3.equal(state.rcons.length, 1, "players without a message and leaves print nothing");
|
||||
});
|
||||
|
||||
t2.test("nothing is printed while the instance is not running or greetings are disabled", async t3 => {
|
||||
const { start, playerEvent, setStatus, featureUpdated, state } = await startPlugin(t3, [], [alice]);
|
||||
await start();
|
||||
state.rcons.length = 0;
|
||||
await playerEvent({ type: "join", name: "Alice" });
|
||||
t3.strictSame(state.rcons, [], "not running");
|
||||
|
||||
setStatus("running");
|
||||
await featureUpdated(new messages.FeatureUpdatedEvent([new messages.FeatureRecord("greetings", false)]));
|
||||
state.rcons.length = 0;
|
||||
await playerEvent({ type: "join", name: "Alice" });
|
||||
t3.strictSame(state.rcons, [], "greetings disabled");
|
||||
});
|
||||
|
||||
t2.test("ipc from the set and remove commands is forwarded to the controller", async t3 => {
|
||||
const { ipc, state } = await startPlugin(t3);
|
||||
await ipc("exp_scenario:join_message_set", { player_name: "Alice", message: "Hi" });
|
||||
await ipc("exp_scenario:join_message_set", { player_name: "Alice" });
|
||||
const sent = state.sent.filter(request => request instanceof messages.JoinMessageSetRequest);
|
||||
t3.strictSame(sent.map(request => [request.playerName, request.message]), [["Alice", "Hi"], ["Alice", null]]);
|
||||
});
|
||||
|
||||
t2.test("ipc asking for the message prints it to the player", async t3 => {
|
||||
const { ipc, setStatus, state } = await startPlugin(t3, [], [alice]);
|
||||
setStatus("running");
|
||||
await ipc("exp_scenario:join_message_get", { player_name: "Alice" });
|
||||
await ipc("exp_scenario:join_message_get", { player_name: "Bob" });
|
||||
const [found, missing] = state.rcons.map(decodePlayerRcon);
|
||||
t3.equal(found.data.message, alice.joinMessage);
|
||||
t3.equal(missing.data.message, undefined, "no message key, so the lua side prints message-none");
|
||||
t3.match(found.code, "exp_greetings.message-none");
|
||||
});
|
||||
|
||||
t2.end();
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import React, { useContext, useEffect, useState } from "react";
|
||||
import { Button, Descriptions, Input, Space } from "antd";
|
||||
|
||||
import { ControlContext, PluginExtensionProps, SectionHeader, notifyErrorHandler, useAccount } from "@clusterio/web_ui";
|
||||
|
||||
import { JoinMessageSetRequest, PlayerGetRequest, PlayerRecord, joinMessageMaxLength } from "../../messages.js";
|
||||
|
||||
/** The settings the scenario keeps for a player, shown on their user page. */
|
||||
export default function UserPlayerSettings(props: PluginExtensionProps<"UserViewPage">) {
|
||||
const control = useContext(ControlContext);
|
||||
const account = useAccount();
|
||||
const name = props.user.name;
|
||||
const own = account.name.toLowerCase() === name.toLowerCase();
|
||||
const canGet = own || Boolean(account.hasPermission("core.user.get"));
|
||||
const canEdit = Boolean(account.hasPermission(
|
||||
own ? "exp_scenario.join_message.set" : "exp_scenario.join_message.set_other",
|
||||
));
|
||||
const [record, setRecord] = useState<PlayerRecord>();
|
||||
/** The message being typed, undefined while not editing */
|
||||
const [draft, setDraft] = useState<string>();
|
||||
|
||||
function load() {
|
||||
control.send(new PlayerGetRequest(name))
|
||||
.then(setRecord)
|
||||
.catch(notifyErrorHandler("Error loading player settings"));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (canGet) {
|
||||
load();
|
||||
}
|
||||
}, [name, canGet]);
|
||||
|
||||
if (!canGet || !record) {
|
||||
return null;
|
||||
}
|
||||
|
||||
async function save(message: string) {
|
||||
await control.send(new JoinMessageSetRequest(name, message.trim() || null));
|
||||
setDraft(undefined);
|
||||
load();
|
||||
}
|
||||
|
||||
function onSave() {
|
||||
save(draft ?? "").catch(notifyErrorHandler("Error saving join message"));
|
||||
}
|
||||
|
||||
return <>
|
||||
<SectionHeader title="Scenario" />
|
||||
<Descriptions bordered size="small" column={1}>
|
||||
<Descriptions.Item label="Join message">
|
||||
{draft !== undefined
|
||||
? <Space.Compact style={{ width: "100%", maxWidth: 600 }}>
|
||||
<Input
|
||||
value={draft}
|
||||
maxLength={joinMessageMaxLength}
|
||||
placeholder="Shown to everyone when they join, empty for none"
|
||||
onChange={e => setDraft(e.target.value)}
|
||||
onPressEnter={onSave}
|
||||
/>
|
||||
<Button type="primary" onClick={onSave}>Save</Button>
|
||||
<Button onClick={() => setDraft(undefined)}>Cancel</Button>
|
||||
</Space.Compact>
|
||||
: <>
|
||||
{record.joinMessage ?? <span style={{ color: "#888" }}>None</span>}
|
||||
{canEdit && <Button
|
||||
size="small"
|
||||
style={{ marginLeft: 8 }}
|
||||
onClick={() => setDraft(record.joinMessage ?? "")}
|
||||
>Edit</Button>}
|
||||
</>}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</>;
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import * as messages from "../messages.js";
|
||||
|
||||
import FeaturesPage from "./components/FeaturesPage";
|
||||
import Seed from "./components/Seed";
|
||||
import UserPlayerSettings from "./components/UserPlayerSettings";
|
||||
|
||||
let features: lib.MapSubscriber<messages.FeatureUpdatedEvent> | undefined;
|
||||
|
||||
@@ -17,6 +18,7 @@ export function useFeatures() {
|
||||
export default function registerPlugin(context: WebPluginContext) {
|
||||
context.control.hooks.extensionComponents.attach("exp_scenario", () => ({
|
||||
RolesPage: Seed,
|
||||
UserViewPage: UserPlayerSettings,
|
||||
}));
|
||||
|
||||
context.control.hooks.pages.attach("exp_scenario", () => [
|
||||
|
||||
Reference in new issue
Block a user