From ebe7504c006feb6f2f76c89131384e2dfd640817 Mon Sep 17 00:00:00 2001 From: bbassie <17990055+bbassie@users.noreply.github.com> Date: Wed, 14 Oct 2026 21:48:50 +0000 Subject: [PATCH] Show the language of players on their user page exp_legacy recorded each player's locale in PlayerData statistics which nothing read. The lua side now sends one ipc event with the player's locale when they join and when they change language, the instance forwards it, and the controller stores it on the player record. The Scenario section of the user page shows the language by name with a flag made from regional indicator symbols. Locales with a region use it directly, the rest map to a country through a small table covering Factorio's locale list. Arabic, Catalan, Basque, and Esperanto have no single country and get no flag. Co-Authored-By: Claude Opus 5.5 --- exp_legacy/module/config/_file_loader.lua | 1 - exp_legacy/module/locale/en.cfg | 2 -- exp_legacy/module/locale/zh-CN.cfg | 2 -- exp_legacy/module/locale/zh-TW.cfg | 2 -- exp_legacy/module/modules/data/language.lua | 21 ------------ exp_scenario/controller.ts | 12 ++++--- exp_scenario/index.ts | 1 + exp_scenario/instance.ts | 6 ++++ exp_scenario/locales.ts | 32 +++++++++++++++++ exp_scenario/messages.ts | 34 +++++++++++++++++++ exp_scenario/module/control.lua | 1 + .../module/control/player_settings.lua | 20 +++++++++++ exp_scenario/test/controller.test.js | 12 +++++++ exp_scenario/test/instance.test.js | 9 ++++- exp_scenario/test/locales.test.js | 26 ++++++++++++++ exp_scenario/tsconfig.browser.json | 2 +- .../web/components/UserPlayerSettings.tsx | 6 ++++ 17 files changed, 155 insertions(+), 34 deletions(-) delete mode 100644 exp_legacy/module/modules/data/language.lua create mode 100644 exp_scenario/locales.ts create mode 100644 exp_scenario/module/control/player_settings.lua create mode 100644 exp_scenario/test/locales.test.js diff --git a/exp_legacy/module/config/_file_loader.lua b/exp_legacy/module/config/_file_loader.lua index b9e10b1d..79e8688f 100644 --- a/exp_legacy/module/config/_file_loader.lua +++ b/exp_legacy/module/config/_file_loader.lua @@ -8,5 +8,4 @@ return { --- Data "modules.data.statistics", - "modules.data.language", } diff --git a/exp_legacy/module/locale/en.cfg b/exp_legacy/module/locale/en.cfg index 9e1a6de2..39224f9f 100644 --- a/exp_legacy/module/locale/en.cfg +++ b/exp_legacy/module/locale/en.cfg @@ -91,8 +91,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] diff --git a/exp_legacy/module/locale/zh-CN.cfg b/exp_legacy/module/locale/zh-CN.cfg index d7a7e1bc..69a4bfad 100644 --- a/exp_legacy/module/locale/zh-CN.cfg +++ b/exp_legacy/module/locale/zh-CN.cfg @@ -91,8 +91,6 @@ NetPlayTime=淨遊玩時間 NetPlayTime-tooltip=遊玩時間減掛機時間 AFKTimeRatio=掛機時間比 AFKTimeRatio-tooltip=掛機時間除以遊玩時間 -Locale=語言 -Locale-tooltip=用戶介面語言 # expcore [expcore-data] diff --git a/exp_legacy/module/locale/zh-TW.cfg b/exp_legacy/module/locale/zh-TW.cfg index d7a7e1bc..69a4bfad 100644 --- a/exp_legacy/module/locale/zh-TW.cfg +++ b/exp_legacy/module/locale/zh-TW.cfg @@ -91,8 +91,6 @@ NetPlayTime=淨遊玩時間 NetPlayTime-tooltip=遊玩時間減掛機時間 AFKTimeRatio=掛機時間比 AFKTimeRatio-tooltip=掛機時間除以遊玩時間 -Locale=語言 -Locale-tooltip=用戶介面語言 # expcore [expcore-data] diff --git a/exp_legacy/module/modules/data/language.lua b/exp_legacy/module/modules/data/language.lua deleted file mode 100644 index 5a188b65..00000000 --- a/exp_legacy/module/modules/data/language.lua +++ /dev/null @@ -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) diff --git a/exp_scenario/controller.ts b/exp_scenario/controller.ts index abc856cb..530a7fd9 100644 --- a/exp_scenario/controller.ts +++ b/exp_scenario/controller.ts @@ -25,14 +25,14 @@ export default async function (context: ControllerPluginContext) { ).bootstrap() ); - type PlayerSettings = Pick; + type PlayerSettings = Pick; /** 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) { + const settings: PlayerSettings = { joinMessage: old?.joinMessage, locale: old?.locale, ...changes }; + const record = new messages.PlayerRecord(name, settings.joinMessage || undefined, settings.locale || undefined); + if (record.joinMessage || record.locale) { playerStore.set(record); } else if (old) { playerStore.delete(old); @@ -230,6 +230,10 @@ export default async function (context: ControllerPluginContext) { updatePlayer(request.playerName, { joinMessage: request.message?.trim() }); }); + controller.handle(messages.PlayerLocaleEvent, async (event: messages.PlayerLocaleEvent) => { + updatePlayer(event.playerName, { locale: event.locale }); + }); + controller.hooks.save.attach(plugin.name, async () => { await playerStore.save(); }); diff --git a/exp_scenario/index.ts b/exp_scenario/index.ts index 5fc959bc..9ec4a65c 100644 --- a/exp_scenario/index.ts +++ b/exp_scenario/index.ts @@ -27,6 +27,7 @@ export const plugin: lib.PluginDeclaration = { messages.FeatureUpdateRequest, messages.PlayerGetRequest, messages.JoinMessageSetRequest, + messages.PlayerLocaleEvent, ], permissions: [ diff --git a/exp_scenario/instance.ts b/exp_scenario/instance.ts index ec36a730..2cc50908 100644 --- a/exp_scenario/instance.ts +++ b/exp_scenario/instance.ts @@ -7,6 +7,8 @@ import { features, FeatureValue } from "./features.js"; 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 }; /** Json as a lua expression for a table, inside a long bracket the json does not contain */ export function luaJson(value: unknown) { @@ -89,6 +91,10 @@ export default async function (context: InstancePluginContext) { ); }); + instance.server.handle("exp_scenario:locale", async (event: IpcLocale) => { + instance.sendTo("controller", new messages.PlayerLocaleEvent(event.player_name, event.locale)); + }); + instance.hooks.playerEvent.attach(plugin.name, async event => { if (event.type === "join") { await onPlayerJoin(event.name).catch(err => logger.error( diff --git a/exp_scenario/locales.ts b/exp_scenario/locales.ts new file mode 100644 index 00000000..a0701c0f --- /dev/null +++ b/exp_scenario/locales.ts @@ -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 = { + 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; + } +} diff --git a/exp_scenario/messages.ts b/exp_scenario/messages.ts index 5c6f91e5..a76a9250 100644 --- a/exp_scenario/messages.ts +++ b/exp_scenario/messages.ts @@ -134,6 +134,8 @@ export class PlayerRecord { 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, public updatedAtMs: number = 0, public isDeleted: boolean = false, ) {} @@ -141,6 +143,7 @@ export class PlayerRecord { static jsonSchema = Type.Object({ id: Type.String(), join_message: Type.Optional(Type.String()), + locale: Type.Optional(Type.String()), updated_at_ms: Type.Optional(Type.Number()), is_deleted: Type.Optional(Type.Boolean()), }); @@ -150,6 +153,9 @@ export class PlayerRecord { if (this.joinMessage) { json.join_message = this.joinMessage; } + if (this.locale) { + json.locale = this.locale; + } if (this.updatedAtMs) { json.updated_at_ms = this.updatedAtMs; } @@ -163,6 +169,7 @@ export class PlayerRecord { return new this( json.id, json.join_message, + json.locale, json.updated_at_ms ?? 0, json.is_deleted ?? false, ); @@ -242,3 +249,30 @@ export class JoinMessageSetRequest { 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) { + return new this(json.player_name, json.locale); + } +} diff --git a/exp_scenario/module/control.lua b/exp_scenario/module/control.lua index 255806e9..6e6f8e83 100644 --- a/exp_scenario/module/control.lua +++ b/exp_scenario/module/control.lua @@ -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")) diff --git a/exp_scenario/module/control/player_settings.lua b/exp_scenario/module/control/player_settings.lua new file mode 100644 index 00000000..7329bc1b --- /dev/null +++ b/exp_scenario/module/control/player_settings.lua @@ -0,0 +1,20 @@ +--[[-- Control - Player Settings +Sends settings of players to the controller, which keeps them between servers +]] + +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 + +local e = defines.events + +return { + events = { + [e.on_player_joined_game] = send_locale, + [e.on_player_locale_changed] = send_locale, + }, +} diff --git a/exp_scenario/test/controller.test.js b/exp_scenario/test/controller.test.js index a08f51d6..5960ca59 100644 --- a/exp_scenario/test/controller.test.js +++ b/exp_scenario/test/controller.test.js @@ -278,6 +278,18 @@ t.test("player settings", t2 => { 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("permissions", async t3 => { const user = (name, granted = []) => ({ name, diff --git a/exp_scenario/test/instance.test.js b/exp_scenario/test/instance.test.js index c2569b19..90725f4b 100644 --- a/exp_scenario/test/instance.test.js +++ b/exp_scenario/test/instance.test.js @@ -153,7 +153,7 @@ t.test("instance plugin", t2 => { t2.end(); }); -t.test("join messages", t2 => { +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 => { @@ -195,6 +195,13 @@ t.test("join messages", t2 => { t3.strictSame(sent.map(request => [request.playerName, request.message]), [["Alice", "Hi"], ["Alice", null]]); }); + 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"); diff --git a/exp_scenario/test/locales.test.js b/exp_scenario/test/locales.test.js new file mode 100644 index 00000000..1b375e1b --- /dev/null +++ b/exp_scenario/test/locales.test.js @@ -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"); +}); diff --git a/exp_scenario/tsconfig.browser.json b/exp_scenario/tsconfig.browser.json index c60f963b..fea6f6d7 100644 --- a/exp_scenario/tsconfig.browser.json +++ b/exp_scenario/tsconfig.browser.json @@ -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" ], } diff --git a/exp_scenario/web/components/UserPlayerSettings.tsx b/exp_scenario/web/components/UserPlayerSettings.tsx index a09b1d52..d6646cd3 100644 --- a/exp_scenario/web/components/UserPlayerSettings.tsx +++ b/exp_scenario/web/components/UserPlayerSettings.tsx @@ -4,6 +4,7 @@ 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">) { @@ -48,6 +49,11 @@ export default function UserPlayerSettings(props: PluginExtensionProps<"UserView return <> + + {record.locale + ? `${localeFlag(record.locale)} ${localeName(record.locale)} (${record.locale})`.trim() + : Unknown} + {draft !== undefined ?