mirror of
https://github.com/PHIDIAS0303/ExpCluster.git
synced 2026-11-12 15:54:52 +09:00
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:
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",
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -91,8 +91,6 @@ NetPlayTime=淨遊玩時間
|
||||
NetPlayTime-tooltip=遊玩時間減掛機時間
|
||||
AFKTimeRatio=掛機時間比
|
||||
AFKTimeRatio-tooltip=掛機時間除以遊玩時間
|
||||
Locale=語言
|
||||
Locale-tooltip=用戶介面語言
|
||||
|
||||
# expcore
|
||||
[expcore-data]
|
||||
|
||||
@@ -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)
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -27,6 +27,7 @@ export const plugin: lib.PluginDeclaration = {
|
||||
messages.FeatureUpdateRequest,
|
||||
messages.PlayerGetRequest,
|
||||
messages.JoinMessageSetRequest,
|
||||
messages.PlayerLocaleEvent,
|
||||
],
|
||||
|
||||
permissions: [
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,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 }}>
|
||||
|
||||
Reference in new issue
Block a user