Merge pull request #499 from bbassie/feature/player-settings

Sync join messages, locale and toolbar through exp_scenario
This commit is contained in:
Cooldude2606 authored and GitHub committed 2026-10-14 23:42:15 +01:00
commit 420506451e
29 files changed
+864 -109

No files matched your search

@@ -8,5 +8,4 @@ return {
--- Data
"modules.data.statistics",
"modules.data.language",
}
-8
View File
@@ -15,15 +15,9 @@ 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
Bonus=Player Bonus
Bonus-tooltip=The bonus given to your character
Bonus-value-tooltip=Change by using /bonus
ToolbarState=Toolbox
ToolbarState-tooltip=The order and favourites in your toolbox
ToolbarState-value-tooltip=This value is calculated automatically when you leave the game
[exp-statistics]
MapsPlayed=Maps Played
@@ -90,8 +84,6 @@ NetPlayTime=Net Play Time
NetPlayTime-tooltip=Net Play Time
AFKTimeRatio=AFK Time Ratio
AFKTimeRatio-tooltip=AFK Time over Play Time
Locale=Locale
Locale-tooltip=The language of player interface
# expcore
[expcore-data]
-8
View File
@@ -15,15 +15,9 @@ lhd=列車必須是左則通行。這是本服務器長久以來的規則。
# data
[exp-settings]
JoinMessage=加入信息
JoinMessage-tooltip=你加入時所顯示的信息
JoinMessage-value-tooltip=使用 /join-message 來更換加入信息
Bonus=人物附加屬性
Bonus-tooltip=你的人物附加屬性
Bonus-value-tooltip=使用 /bonus 來更改
ToolbarState=工具箱
ToolbarState-tooltip=次序和喜愛項目
ToolbarState-value-tooltip=此值在離開時自動計算
[exp-statistics]
MapsPlayed=地圖遊玩次數
@@ -90,8 +84,6 @@ NetPlayTime=淨遊玩時間
NetPlayTime-tooltip=遊玩時間減掛機時間
AFKTimeRatio=掛機時間比
AFKTimeRatio-tooltip=掛機時間除以遊玩時間
Locale=語言
Locale-tooltip=用戶介面語言
# expcore
[expcore-data]
-8
View File
@@ -15,15 +15,9 @@ lhd=列車必須是左則通行。這是本服務器長久以來的規則。
# data
[exp-settings]
JoinMessage=加入信息
JoinMessage-tooltip=你加入時所顯示的信息
JoinMessage-value-tooltip=使用 /join-message 來更換加入信息
Bonus=人物附加屬性
Bonus-tooltip=你的人物附加屬性
Bonus-value-tooltip=使用 /bonus 來更改
ToolbarState=工具箱
ToolbarState-tooltip=次序和喜愛項目
ToolbarState-value-tooltip=此值在離開時自動計算
[exp-statistics]
MapsPlayed=地圖遊玩次數
@@ -90,8 +84,6 @@ NetPlayTime=淨遊玩時間
NetPlayTime-tooltip=遊玩時間減掛機時間
AFKTimeRatio=掛機時間比
AFKTimeRatio-tooltip=掛機時間除以遊玩時間
Locale=語言
Locale-tooltip=用戶介面語言
# expcore
[expcore-data]
@@ -1,21 +0,0 @@
--- Stores the language used to join the server
-- @data Language
local Event = require("modules/exp_legacy/utils/event") --- @dep utils.event
local PlayerData = require("modules.exp_legacy.expcore.player_data") --- @dep expcore.player_data
local LocalLanguage = PlayerData.Statistics:combine("LocalLanguage")
LocalLanguage:set_default("Unknown")
LocalLanguage:set_metadata{
name = { "exp-statistics.Locale" },
tooltip = { "exp-statistics.Locale-tooltip" },
}
local function set_locale(event)
local player = game.players[event.player_index]
LocalLanguage:set(player, player.locale)
end
--- Set the players language when they join and change language
Event.add(defines.events.on_player_created, set_locale)
Event.add(defines.events.on_player_joined_game, set_locale)
Event.add(defines.events.on_player_locale_changed, set_locale)
@@ -1,31 +0,0 @@
local Gui = require("modules/exp_gui")
local PlayerData = require("modules.exp_legacy.expcore.player_data")
-- Used to store the state of the toolbar when a player leaves
local ToolbarState = PlayerData.Settings:combine("ToolbarState")
ToolbarState:set_metadata{
stringify = function()
return "Toolbar is saved on exit"
end,
}
--- Uncompress the data to be more useable
ToolbarState:on_load(function(player_name, value)
-- If there is no value, do nothing
if value == nil then return end
-- Old format, we discard it [ string[], string[], string[], boolean ]
if type(value) ~= "string" then return end
local decompressed = helpers.json_to_table(assert(helpers.decode_string(value), "Failed String Decode"))
local player = assert(game.get_player(player_name))
Gui.toolbar.set_state(player, decompressed --[[@as Gui.ToolbarState]])
return nil -- We don't save the state, use Gui.toolbar.get_state
end)
--- Save the current state of the players toolbar menu
ToolbarState:on_save(function(player_name, _)
local player = assert(game.get_player(player_name))
local value = Gui.toolbar.get_state(player)
return helpers.encode_string(helpers.table_to_json(value))
end)
+45
View File
@@ -18,6 +18,30 @@ 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" | "locale" | "toolbar">;
/** 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, locale: old?.locale, toolbar: old?.toolbar, ...changes,
};
const record = new messages.PlayerRecord(
name, settings.joinMessage || undefined, settings.locale || undefined, settings.toolbar || undefined,
);
if (record.joinMessage || record.locale || record.toolbar) {
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 +226,29 @@ 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.handle(messages.PlayerLocaleEvent, async (event: messages.PlayerLocaleEvent) => {
updatePlayer(event.playerName, { locale: event.locale });
});
controller.handle(messages.PlayerToolbarEvent, async (event: messages.PlayerToolbarEvent) => {
updatePlayer(event.playerName, { toolbar: event.toolbar });
});
controller.hooks.save.attach(plugin.name, async () => {
await playerStore.save();
});
controller.hooks.shutdown.attach(plugin.name, async () => {
await featureStore.save();
await playerStore.save();
});
}
+6
View File
@@ -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",
+16
View File
@@ -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,10 @@ export const plugin: lib.PluginDeclaration = {
messages.FeatureUpdatedEvent,
messages.FeatureListRequest,
messages.FeatureUpdateRequest,
messages.PlayerGetRequest,
messages.JoinMessageSetRequest,
messages.PlayerLocaleEvent,
messages.PlayerToolbarEvent,
],
permissions: [
@@ -42,6 +48,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",
+74 -8
View File
@@ -3,11 +3,32 @@ 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 };
/** Sent by the lua side when a player joins or changes language, see module/control/player_settings.lua */
export type IpcLocale = { player_name: string, locale: string };
/** Sent by the lua side when a player leaves */
export type IpcToolbar = { player_name: string, toolbar: 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 +46,74 @@ 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 })");
}
if (record.toolbar) {
await luaWithPlayer(
name, { toolbar: record.toolbar }, "exp_scenario.player_settings.receive_toolbar(d.name, d.toolbar)",
);
}
}
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.server.handle("exp_scenario:locale", async (event: IpcLocale) => {
instance.sendTo("controller", new messages.PlayerLocaleEvent(event.player_name, event.locale));
});
instance.server.handle("exp_scenario:toolbar", async (event: IpcToolbar) => {
instance.sendTo("controller", new messages.PlayerToolbarEvent(event.player_name, event.toolbar));
});
instance.hooks.playerEvent.attach(plugin.name, async event => {
if (event.type === "join") {
await onPlayerJoin(event.name).catch(err => logger.error(
`Error sending the settings of ${event.name}:\n${err.stack ?? err.message}`,
));
}
});
instance.hooks.start.attach(plugin.name, async () => {
started = true;
await syncFeatures();
+32
View File
@@ -0,0 +1,32 @@
/**
* Country of each Factorio locale without a region in its code, used for the
* flag. Languages spoken across many countries or without one, such as ar,
* ca, eo, and eu, are left out and get no flag.
*/
const localeCountries: Record<string, string> = {
af: "ZA", be: "BY", bg: "BG", cs: "CZ", da: "DK", de: "DE", el: "GR", en: "GB", et: "EE",
fa: "IR", fi: "FI", fil: "PH", fr: "FR", he: "IL", hr: "HR", hu: "HU", id: "ID", is: "IS",
it: "IT", ja: "JP", ka: "GE", kk: "KZ", ko: "KR", lt: "LT", lv: "LV", nl: "NL", no: "NO",
pl: "PL", ro: "RO", ru: "RU", sk: "SK", sl: "SI", sq: "AL", sr: "RS", th: "TH", tr: "TR",
uk: "UA", vi: "VN",
};
/** Flag emoji for a Factorio locale such as "de" or "pt-BR", empty when it has no obvious country. */
export function localeFlag(locale: string) {
const [language, region] = locale.split("-");
const country = region ?? localeCountries[language];
if (!country || !/^[A-Z]{2}$/.test(country)) {
return "";
}
// Regional indicator symbols start at U+1F1E6 for A
return String.fromCodePoint(...[...country].map(letter => 0x1F1E6 + letter.charCodeAt(0) - 65));
}
/** English name of a locale, the code itself when it is not known. */
export function localeName(locale: string) {
try {
return new Intl.DisplayNames(["en"], { type: "language" }).of(locale) ?? locale;
} catch {
return locale;
}
}
+184
View File
@@ -126,3 +126,187 @@ 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,
/** The language of their game, such as "en" or "pt-BR" */
public locale?: string,
/** Toolbar state from Gui.toolbar.get_state, json compressed with helpers.encode_string */
public toolbar?: string,
public updatedAtMs: number = 0,
public isDeleted: boolean = false,
) {}
static jsonSchema = Type.Object({
id: Type.String(),
join_message: Type.Optional(Type.String()),
locale: Type.Optional(Type.String()),
toolbar: 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.locale) {
json.locale = this.locale;
}
if (this.toolbar) {
json.toolbar = this.toolbar;
}
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.locale,
json.toolbar,
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);
}
}
/** The language a player's game uses, sent when they join and when they change it. */
export class PlayerLocaleEvent {
declare ["constructor"]: typeof PlayerLocaleEvent;
static plugin = "exp_scenario" as const;
static type = "event" as const;
static src = "instance" as const;
static dst = "controller" as const;
constructor(
public playerName: string,
public locale: string,
) {}
static jsonSchema = Type.Object({
player_name: Type.String(),
locale: Type.String(),
});
toJSON() {
return { player_name: this.playerName, locale: this.locale };
}
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(json.player_name, json.locale);
}
}
/** The toolbar of a player, sent when they leave. */
export class PlayerToolbarEvent {
declare ["constructor"]: typeof PlayerToolbarEvent;
static plugin = "exp_scenario" as const;
static type = "event" as const;
static src = "instance" as const;
static dst = "controller" as const;
constructor(
public playerName: string,
public toolbar: string,
) {}
static jsonSchema = Type.Object({
player_name: Type.String(),
toolbar: Type.String(),
});
toJSON() {
return { player_name: this.playerName, toolbar: this.toolbar };
}
static fromJSON(json: Static<typeof this.jsonSchema>) {
return new this(json.player_name, json.toolbar);
}
}
+1
View File
@@ -58,6 +58,7 @@ add(require("modules/exp_scenario/control/inserter_pickup"))
add(require("modules/exp_scenario/control/inventory_clear"))
add(require("modules/exp_scenario/control/mine_depletion"))
add(require("modules/exp_scenario/control/nuke_protection"))
add(require("modules/exp_scenario/control/player_settings"))
add(require("modules/exp_scenario/control/pollution_grading"))
add(require("modules/exp_scenario/control/popups"))
add(require("modules/exp_scenario/control/protection"))
+25 -21
View File
@@ -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,
},
}
@@ -0,0 +1,68 @@
--[[-- Control - Player Settings
Sends settings of players to the controller, which keeps them between servers
The toolbar is sent compressed when a player leaves, the instance plugin
calls receive_toolbar with it when they join again
]]
local Gui = require("modules/exp_gui")
local clusterio_api = require("modules/clusterio/api")
--- @param event EventData.on_player_joined_game | EventData.on_player_locale_changed
local function send_locale(event)
local player = assert(game.get_player(event.player_index))
clusterio_api.send_json("exp_scenario:locale", { player_name = player.name, locale = player.locale })
end
--- @param event EventData.on_player_left_game
local function send_toolbar(event)
local player = assert(game.get_player(event.player_index))
local success, state = pcall(Gui.toolbar.get_state, player)
if not success then
log("[WARNING] Could not read the toolbar of " .. player.name .. ": " .. tostring(state))
return
end
local toolbar = helpers.encode_string(helpers.table_to_json(state))
clusterio_api.send_json("exp_scenario:toolbar", { player_name = player.name, toolbar = toolbar })
end
--- @param toolbar string
--- @return Gui.ToolbarState?
local function decode_toolbar(toolbar)
local json = helpers.decode_string(toolbar)
local state = json and helpers.json_to_table(json)
if type(state) == "table" and type(state.order) == "table" and type(state.open) == "table"
and type(state.visible) == "boolean" then
return state --[[@as Gui.ToolbarState]]
end
end
--- Restore the toolbar of a player, called over rcon by the instance plugin
--- @param player_name string
--- @param toolbar string From send_toolbar
local function receive_toolbar(player_name, toolbar)
local player = game.get_player(player_name)
if not player or not player.connected then
return
end
-- Stored states can name buttons which no longer exist
local success, err = pcall(function()
local state = assert(decode_toolbar(toolbar), "invalid toolbar state")
Gui.toolbar.set_state(player, state)
end)
if not success then
log("[WARNING] Could not restore the toolbar of " .. player_name .. ": " .. tostring(err))
end
end
local e = defines.events
return {
events = {
[e.on_player_joined_game] = send_locale,
[e.on_player_locale_changed] = send_locale,
[e.on_player_left_game] = send_toolbar,
},
receive_toolbar = receive_toolbar,
}
+1
View File
@@ -3,4 +3,5 @@
-- Access using `/sc exp_scenario.features.foo()`
exp_scenario = {
features = require("modules/exp_scenario/features"),
player_settings = require("modules/exp_scenario/control/player_settings"),
}
+1
View File
@@ -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.
+1
View File
@@ -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__ ,所以該物品已放在出生點的箱子。
+1
View File
@@ -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__ ,所以該物品已放在出生點的箱子。
+2
View File
@@ -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",
],
},
{
+106
View File
@@ -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,106 @@ 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("PlayerLocaleEvent stores the locale next to the join message", async t3 => {
const { send } = await startPlugin(t3, { withPlugins: false });
await send(new messages.JoinMessageSetRequest("Alice", "Hi"));
await send(new messages.PlayerLocaleEvent("Alice", "pt-BR"));
const record = await send(new messages.PlayerGetRequest("Alice"));
t3.strictSame([record.joinMessage, record.locale], ["Hi", "pt-BR"]);
await send(new messages.JoinMessageSetRequest("Alice", null));
const cleared = await send(new messages.PlayerGetRequest("Alice"));
t3.equal(cleared.locale, "pt-BR", "clearing the message keeps the locale");
});
t2.test("PlayerToolbarEvent stores the toolbar and it is saved", async t3 => {
const databaseDirectory = t3.testdir();
const { send, save } = await startPlugin(t3, { withPlugins: false, databaseDirectory });
await send(new messages.PlayerToolbarEvent("Alice", "abc"));
t3.equal((await send(new messages.PlayerGetRequest("Alice"))).toolbar, "abc");
await save();
const stored = await loadPlayers(databaseDirectory);
const alice = stored.get("Alice");
t3.strictSame(alice.toJSON(), { id: "Alice", toolbar: "abc", updated_at_ms: alice.updatedAtMs });
});
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();
});
+104 -2
View File
@@ -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,87 @@ t.test("instance plugin", t2 => {
t2.end();
});
t.test("player settings", 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("a joining player's stored toolbar is sent to the lua side", async t3 => {
const bob = new messages.PlayerRecord("Bob", undefined, undefined, "eNqrVkrLz1eyUlBKSixSqgUAGQwD8g==");
const { start, playerEvent, setStatus, state } = await startPlugin(t3, [], [bob]);
await start();
setStatus("running");
state.rcons.length = 0;
await playerEvent({ type: "join", name: "Bob" });
t3.equal(state.rcons.length, 1, "only the toolbar, Bob has no join message");
const { data, code } = decodePlayerRcon(state.rcons[0]);
t3.strictSame(data, { toolbar: bob.toolbar, name: "Bob" });
t3.equal(code, "exp_scenario.player_settings.receive_toolbar(d.name, d.toolbar)");
});
t2.test("ipc with a toolbar is forwarded to the controller", async t3 => {
const { ipc, state } = await startPlugin(t3);
await ipc("exp_scenario:toolbar", { player_name: "Alice", toolbar: "abc" });
const [event] = state.sent.filter(request => request instanceof messages.PlayerToolbarEvent);
t3.strictSame([event.playerName, event.toolbar], ["Alice", "abc"]);
});
t2.test("ipc with a locale is forwarded to the controller", async t3 => {
const { ipc, state } = await startPlugin(t3);
await ipc("exp_scenario:locale", { player_name: "Alice", locale: "de" });
const [event] = state.sent.filter(request => request instanceof messages.PlayerLocaleEvent);
t3.strictSame([event.playerName, event.locale], ["Alice", "de"]);
});
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();
});
+26
View File
@@ -0,0 +1,26 @@
import t from "tap";
import { localeFlag, localeName } from "../dist/node/locales.js";
// The locale folders of Factorio 2.1
const factorioLocales = [
"af", "ar", "be", "bg", "ca", "cs", "da", "de", "el", "en", "eo", "es-ES", "et", "eu", "fa", "fi", "fil", "fr",
"fy-NL", "ga-IE", "he", "hr", "hu", "id", "is", "it", "ja", "ka", "kk", "ko", "lt", "lv", "nl", "no", "pl",
"pt-BR", "pt-PT", "ro", "ru", "sk", "sl", "sq", "sr", "sv-SE", "th", "tr", "uk", "vi", "zh-CN", "zh-TW",
];
const noFlag = ["ar", "ca", "eo", "eu"];
t.test("localeFlag()", async t2 => {
t2.equal(localeFlag("de"), "\u{1F1E9}\u{1F1EA}", "language codes use the table");
t2.equal(localeFlag("pt-BR"), "\u{1F1E7}\u{1F1F7}", "regions are used directly");
t2.equal(localeFlag("zh-TW"), "\u{1F1F9}\u{1F1FC}");
for (const locale of factorioLocales) {
t2.equal(localeFlag(locale) === "", noFlag.includes(locale), `${locale} has a flag unless it has no country`);
}
t2.equal(localeFlag("xx"), "", "unknown languages have no flag");
t2.equal(localeFlag(""), "");
});
t.test("localeName()", async t2 => {
t2.equal(localeName("de"), "German");
t2.equal(localeName("not a locale"), "not a locale", "invalid codes are shown as they are");
});
+3
View File
@@ -6,3 +6,6 @@ const envFile = path.join(import.meta.dirname, "module", "env.lua");
// Each file runs in its own lua state, and each test in a fresh environment
t.test("features.lua", t2 => reportLuaTests(t2, envFile, path.join(import.meta.dirname, "module", "features.lua")));
t.test("player_settings.lua", t2 => reportLuaTests(
t2, envFile, path.join(import.meta.dirname, "module", "player_settings.lua"),
));
+5
View File
@@ -17,6 +17,7 @@ local Framework = assert(loadfile(shared_root .. "/framework.lua"))() --- @type
--- @field storage table The table registered with storage, as on_init would store it
--- @field on_load fun(tbl: table) Act out on_load with the given storage table
--- @field load_config fun(name: string): ExpScenario.Feature Load module/config/<name>.lua
--- @field load_module fun(name: string): any Load module/<name>.lua, such as "control/player_settings"
--- @field logged string[] Messages passed to log
--- @field events table[] Raised events, name and tick filled in
@@ -51,5 +52,9 @@ return Framework.suite(function(env)
return assert(loadfile(plugin_root .. "/module/config/" .. name .. ".lua"))()
end
function env.load_module(name)
return assert(loadfile(plugin_root .. "/module/" .. name .. ".lua"))()
end
return env
end)
@@ -0,0 +1,79 @@
local Suite = ... --- @type Suite<ExpScenario.TestEnv>
local valid_state = { order = { { name = "readme", favourite = true } }, open = {}, visible = true }
--- Load control/player_settings.lua with a stubbed toolbar, encoding is a "enc:" prefix
--- @param env ExpScenario.TestEnv
--- @param toolbar table? Replaces the Gui.toolbar stub
--- @return table PlayerSettings
--- @return table restored Calls to set_state as { player name, state }
local function load(env, toolbar)
local restored = {}
env.extend_defines{ events = { on_player_locale_changed = 3, on_player_left_game = 4 } }
env.extend_requires{
["modules/exp_gui"] = {
toolbar = toolbar or {
get_state = function() return valid_state end,
set_state = function(player, state) restored[#restored + 1] = { player.name, state } end,
},
},
}
local json_values = { ['{"valid"}'] = valid_state, ['{"wrong shape"}'] = { order = "x" } }
helpers = {
encode_string = function(str) return "enc:" .. str end,
decode_string = function(str) return str:match("^enc:(.*)$") end,
table_to_json = function(tbl) return tbl == valid_state and '{"valid"}' or "{}" end,
json_to_table = function(json) return json_values[json] end,
}
return env.load_module("control/player_settings"), restored
end
Suite.test("send_locale() sends the locale of the player", function(env)
local PlayerSettings = load(env)
rawset(env.add_player{ name = "Alice" }, "locale", "pt-BR")
PlayerSettings.events[defines.events.on_player_joined_game]{ player_index = 1 }
Suite.eq(env.sent, { { channel = "exp_scenario:locale", data = { player_name = "Alice", locale = "pt-BR" } } }, "sent")
end)
Suite.test("send_toolbar() sends the compressed toolbar when a player leaves", function(env)
local PlayerSettings = load(env)
env.add_player{ name = "Alice" }
PlayerSettings.events[defines.events.on_player_left_game]{ player_index = 1 }
Suite.eq(env.sent, { { channel = "exp_scenario:toolbar", data = { player_name = "Alice", toolbar = 'enc:{"valid"}' } } }, "sent")
end)
Suite.test("send_toolbar() sends nothing when the toolbar can not be read", function(env)
local PlayerSettings = load(env, { get_state = function() error("no toolbar") end })
env.add_player{ name = "Alice" }
PlayerSettings.events[defines.events.on_player_left_game]{ player_index = 1 }
Suite.eq(env.sent, {}, "nothing sent")
Suite.eq(#env.logged, 1, "a warning is logged")
end)
Suite.test("receive_toolbar() restores the toolbar of online players", function(env)
local PlayerSettings, restored = load(env)
env.add_player{ name = "Alice" }
env.add_player{ name = "Bob", connected = false }
PlayerSettings.receive_toolbar("Alice", 'enc:{"valid"}')
PlayerSettings.receive_toolbar("Bob", 'enc:{"valid"}')
PlayerSettings.receive_toolbar("Unknown", 'enc:{"valid"}')
Suite.eq(restored, { { "Alice", valid_state } }, "only Alice is online")
end)
Suite.test("receive_toolbar() logs malformed toolbars instead of raising", function(env)
local PlayerSettings, restored = load(env)
env.add_player{ name = "Alice" }
for _, toolbar in ipairs{ "not encoded", "enc:not json", 'enc:{"wrong shape"}' } do
PlayerSettings.receive_toolbar("Alice", toolbar)
end
Suite.eq(restored, {}, "nothing is restored")
Suite.eq(#env.logged, 3, "each one is logged")
end)
Suite.test("receive_toolbar() logs errors from set_state", function(env)
local PlayerSettings = load(env, { set_state = function() error("Missing toolbox item for removed") end })
env.add_player{ name = "Alice" }
PlayerSettings.receive_toolbar("Alice", 'enc:{"valid"}')
Suite.eq(#env.logged, 1, "a warning is logged")
end)
return Suite.run()
+1 -1
View File
@@ -1,4 +1,4 @@
{
"extends": "../tsconfig.browser.json",
"include": [ "web/**/*.tsx", "web/**/*.ts", "index.ts", "messages.ts", "features.ts", "permissions.ts", "package.json" ],
"include": [ "web/**/*.tsx", "web/**/*.ts", "index.ts", "messages.ts", "features.ts", "locales.ts", "permissions.ts", "package.json" ],
}
@@ -0,0 +1,81 @@
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";
import { localeFlag, localeName } from "../../locales.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="Language">
{record.locale
? `${localeFlag(record.locale)} ${localeName(record.locale)} (${record.locale})`.trim()
: <span style={{ color: "#888" }}>Unknown</span>}
</Descriptions.Item>
<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>
</>;
}
+2
View File
@@ -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", () => [