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 ?