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 <noreply@anthropic.com>
This commit is contained in:
bbassieandClaude Opus 5.5 committed 2026-10-14 22:10:31 +00:00
1 parent 6821d62c10
commit ebe7504c00
17 files changed
+155 -34

No files matched your search

@@ -8,5 +8,4 @@ return {
--- Data
"modules.data.statistics",
"modules.data.language",
}
-2
View File
@@ -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]
-2
View File
@@ -91,8 +91,6 @@ NetPlayTime=淨遊玩時間
NetPlayTime-tooltip=遊玩時間減掛機時間
AFKTimeRatio=掛機時間比
AFKTimeRatio-tooltip=掛機時間除以遊玩時間
Locale=語言
Locale-tooltip=用戶介面語言
# expcore
[expcore-data]
-2
View File
@@ -91,8 +91,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)
+8 -4
View File
@@ -25,14 +25,14 @@ export default async function (context: ControllerPluginContext) {
).bootstrap()
);
type PlayerSettings = Pick<messages.PlayerRecord, "joinMessage">;
type PlayerSettings = Pick<messages.PlayerRecord, "joinMessage" | "locale">;
/** 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();
});
+1
View File
@@ -27,6 +27,7 @@ export const plugin: lib.PluginDeclaration = {
messages.FeatureUpdateRequest,
messages.PlayerGetRequest,
messages.JoinMessageSetRequest,
messages.PlayerLocaleEvent,
],
permissions: [
+6
View File
@@ -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(
+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;
}
}
+34
View File
@@ -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<typeof this.jsonSchema>) {
return new this(json.player_name, json.locale);
}
}
+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"))
@@ -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,
},
}
+12
View File
@@ -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,
+8 -1
View File
@@ -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");
+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");
});
+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" ],
}
@@ -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 <>
<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 }}>