feedback5 WI: server toasts and ticker lines carry msgid + params so the client shows them in the player's language

messaging.js: a toast / CUSTOM ticker line is a string (its own msgid) or a shared/i18n.js msg(); frames keep
the Chinese `text` (older clients) and add `msgid` / `params`; config.broadcasts lines carry their `args`
(the client fills the localized broadcast template; the player name {0} is never translated). Templated call
sites send msg(): the grant toast 「X:获得Y」, the summon-range and terrain withdrawals, the 联防 line,
eliminated / left lines, the 远见 / 奇迹 stack payouts and the 维多利亚 hammers; game-data names go as dn().
The protocol stays backward compatible (new fields optional). Tests that captured m.toast's argument render
it with renderMessage. No gameplay change: golden unchanged.
This commit is contained in:
sganggs committed 2026-10-05 20:19:58 +08:00
1 parent a12a539e4d
commit 5398a0cfc5
12 files changed
+166 -18

No files matched your search

+5 -3
View File
@@ -41,6 +41,7 @@ import { boardOrder, parseKey, tileKey } from './board.js';
import { pieceBonds as bondsOfPiece } from './bondsMeta.js';
import { registerAllMeta } from '../sim/content/index.js';
import { registerBuiltins } from './builtinMeta.js';
import { msg, dn } from '../../shared/i18n.js';
export const HOOKS = Object.freeze([
'onRoundStart', 'onIncome', 'onPrepStart', 'onPrepEnd', 'onGain', 'onSold', 'onRefresh', 'onPrice', 'onBuy',
@@ -525,7 +526,7 @@ export function makeCtx(m, ps, source, hook, ev = null) {
const name = got && got.name;
if (name) {
const who = grantSpeaker(gd, source);
m.toast(ps, 'info', who ? `${who}:获得${name}` : `获得${name}`);
m.toast(ps, 'info', who ? msg('{who}:获得{name}', { who: dn(who), name: dn(name) }) : msg('获得{name}', { name: dn(name) }));
}
}
return p ? view(p) : null;
@@ -635,8 +636,9 @@ export function makeCtx(m, ps, source, hook, ev = null) {
addBounty: (card) => m.addBounty(ps, card),
// ---- messaging
toast: (text, kind = 'info') => m.toast(ps, kind, String(text)),
ticker: (text) => m.tickerText(String(text)),
// a string or a shared/i18n.js msg(msgid, params)
toast: (text, kind = 'info') => m.toast(ps, kind, text && typeof text === 'object' ? text : String(text)),
ticker: (text) => m.tickerText(text && typeof text === 'object' ? text : String(text)),
/** CHAR_GIFT broadcast to this player: "{0}博士给你赠送了{1}". */
giftTicker: (fromName, chessId) => { const c = gd.chess(chessId); m.tickerFor('CHAR_GIFT', [String(fromName), c ? c.name : String(chessId)], { to: ps.playerId }); },
+9 -3
View File
@@ -1,9 +1,13 @@
// server/match/match/messaging.js — Match methods: messaging — unicast, broadcast, toasts, the ticker lines
// (config.broadcasts by type; the remake's CUSTOM lines), the dirty marks and flush: m.private per player only when it
// changed (plus the prep scouts of that player's board), m.public throttled (DELAYS.PUBLIC_THROTTLE) and deduplicated.
// i18n (docs/I18N.md): a toast / CUSTOM line is a string (its own msgid) or a shared/i18n.js msg(msgid, params); the frame
// carries `text` (the Chinese rendering, what older clients show) plus `msgid` / `params`; a broadcast line carries its
// `args` so the client can fill the localized config.broadcasts template.
// Installed on Match.prototype by server/match/Match.js (a method container: never instantiated; `this` is the match).
import { DELAYS } from './common.js';
import { wireMessage } from '../../../shared/i18n.js';
export class MatchMessaging {
sendTo(playerId, msg) {
@@ -18,9 +22,10 @@ export class MatchMessaging {
try { this.broadcastFn(msg); } catch (e) { this.reportError('broadcast', e); }
}
/** @param {string|{ msgid: string, params?: object }} text a string or msg() (shared/i18n.js) */
toast(ps, kind, text) {
if (!ps || ps.isBot || ps.left || !ps.connected) return;
this.sendTo(ps.playerId, { t: 'm.toast', kind, text });
this.sendTo(ps.playerId, { t: 'm.toast', kind, ...wireMessage(text) });
}
/** Broadcast ticker from config.broadcasts by type; `param` picks the variant (SHOP_LEVEL level, BOSS_HIT share…). */
@@ -31,7 +36,7 @@ export class MatchMessaging {
const tpl = b && typeof b.text === 'string' ? b.text : null;
if (!tpl) return;
const text = tpl.replace(/\{(\d)\}/g, (_, i) => (args[Number(i)] != null ? String(args[Number(i)]) : ''));
const msg = { t: 'm.ticker', text, id: b.id, type, priority: Number(b.priority) || 0, playerId };
const msg = { t: 'm.ticker', text, id: b.id, type, priority: Number(b.priority) || 0, playerId, args: args.map((a) => String(a ?? '')) };
if (to) this.sendTo(to, msg);
else this.broadcast(msg);
}
@@ -42,7 +47,8 @@ export class MatchMessaging {
*/
tickerText(text, priority = 0) {
if (!text) return;
this.broadcast({ t: 'm.ticker', text: String(text).slice(0, 200), id: null, type: 'CUSTOM', priority: Number(priority) || 0, playerId: null });
const w = wireMessage(text);
this.broadcast({ t: 'm.ticker', ...w, text: w.text.slice(0, 200), id: null, type: 'CUSTOM', priority: Number(priority) || 0, playerId: null });
}
markPublic() { this._pubDirty = true; }
+2 -1
View File
@@ -8,6 +8,7 @@ import { C2S } from '../../../shared/protocol.js';
import { PHASE, ERR } from '../../../shared/constants.js';
import { syntheticResult } from '../fields.js';
import { FLOW_TICKER_PRIORITY, OK, fail } from './common.js';
import { msg } from '../../../shared/i18n.js';
const GAME_TYPES = new Set(Object.keys(C2S).filter((t) => Object.hasOwn(C2S, t) && (t.startsWith('g.') || t.startsWith('b.'))));
@@ -171,7 +172,7 @@ export class MatchPlatform {
}
ps.lp = 0;
ps.eliminate(passedRound);
this.tickerText(`${ps.name}博士中途退出了模拟`, FLOW_TICKER_PRIORITY);
this.tickerText(msg('{name}博士中途退出了模拟', { name: ps.name }), FLOW_TICKER_PRIORITY);
if (this.bossWaves && (phase === PHASE.ROUND_START || phase === PHASE.SP_DRAFT || phase === PHASE.PREP)) {
// before the boss fight: pair the players left again (the prep preview shows the new partner / template); a
// player moved to the other half re-checks its board there at once (recompute → deployMap, marks it private)
+2 -1
View File
@@ -7,6 +7,7 @@ import { PHASE, layerGainRoom } from '../../../shared/constants.js';
import { uniteSurvivors } from '../unite.js';
import { buildResult } from '../results.js';
import { FLOW_TICKER_PRIORITY, DELAYS } from './common.js';
import { msg } from '../../../shared/i18n.js';
export class MatchSettle {
settle(plan, uniteResult) {
@@ -63,7 +64,7 @@ export class MatchSettle {
ps.lp = 0;
ps.eliminate(this.round);
this.toast(ps, 'error', '你的目标生命值耗尽,已被淘汰');
this.tickerText(`${ps.name}博士的目标生命值已耗尽`, FLOW_TICKER_PRIORITY);
this.tickerText(msg('{name}博士的目标生命值已耗尽', { name: ps.name }), FLOW_TICKER_PRIORITY);
}
}
this.fields = [];
+3 -2
View File
@@ -9,6 +9,7 @@ import { uniteBattleOpts, uniteSurvivors, uniteStageId } from '../unite.js';
import { FieldRunner, timelineAt, uniteBillBounds } from '../fields.js';
import { uniteLeft } from '../../sim/spec.js';
import { FLOW_TICKER_PRIORITY, DELAYS } from './common.js';
import { msg } from '../../../shared/i18n.js';
export class MatchUnite {
startUnite(plan) {
@@ -21,7 +22,7 @@ export class MatchUnite {
this.deadline = this.sched.instant ? 0 : this.sched.now() + Math.round((limit / this.gameSpeed) * 1000);
this._defaultWatch();
this.markPublic();
this.tickerText(`联防阶段:${plan.helpers.map((p) => p.name).join('、')} 迎战突破防线的敌人`, FLOW_TICKER_PRIORITY);
this.tickerText(msg('联防阶段:{names} 迎战突破防线的敌人', { names: plan.helpers.map((p) => p.name) }), FLOW_TICKER_PRIORITY);
this._uniteLeftKey = null;
this.runner = new FieldRunner(this, this.fields, {
onTick: (runner) => this._uniteTick(runner),
@@ -79,7 +80,7 @@ export class MatchUnite {
this._sendStart(ps.playerId, f, { watch: !f.players.includes(ps.playerId) });
}
this.markPublic();
this.tickerText(`联防阶段:${plan.helpers.map((p) => p.name).join('、')} 迎战突破防线的敌人`, FLOW_TICKER_PRIORITY);
this.tickerText(msg('联防阶段:{names} 迎战突破防线的敌人', { names: plan.helpers.map((p) => p.name) }), FLOW_TICKER_PRIORITY);
}
_finishUniteClient() {
+2 -1
View File
@@ -7,6 +7,7 @@
// the player state).
import { PHASE } from '../../../shared/constants.js';
import { msg, dn } from '../../../shared/i18n.js';
import { checkLoadout, resolveLoadout } from '../../../shared/protocol.js';
import { tileKey, boardOrder } from '../board.js';
@@ -126,7 +127,7 @@ export class PlayerBasics {
}
}
}
if (names.length) this.m.toast(this, 'warn', `地形变化:${names.join('、')}无法停留在原位置,已撤回整备区`);
if (names.length) this.m.toast(this, 'warn', msg('地形变化:{names}无法停留在原位置,已撤回整备区', { names: names.map(dn) }));
return moved;
}
+3 -2
View File
@@ -5,6 +5,7 @@
// the player state).
import { ERR } from '../../../shared/constants.js';
import { msg, dn } from '../../../shared/i18n.js';
import { tileKey, parseKey, inField, canPlace, placeClass, freeSlot, pieceDir, parseDir, ownerRangeKeys } from '../board.js';
import { attackRangeGrid, loadoutRecord, resolveRecordLoadout } from '../../../shared/loadoutRecord.js';
import { OK, fail } from './common.js';
@@ -91,8 +92,8 @@ export class PlayerPlacement {
this.board.delete(k);
(this._returnToken(p, null, { allowTemp: true }) ? back : gone).push(this.gd.token(p.id)?.name || p.id);
}
if (back.length) this.m.toast(this, 'warn', `${back.join('、')}只能部署在召唤者攻击范围内,已退回整备区`);
if (gone.length) this.m.toast(this, 'warn', `${gone.join('、')}只能部署在召唤者攻击范围内,整备区已满,下回合返还`);
if (back.length) this.m.toast(this, 'warn', msg('{names}只能部署在召唤者攻击范围内,已退回整备区', { names: back.map(dn) }));
if (gone.length) this.m.toast(this, 'warn', msg('{names}只能部署在召唤者攻击范围内,整备区已满,下回合返还', { names: gone.map(dn) }));
return back.length + gone.length;
}
+4 -2
View File
@@ -19,6 +19,8 @@
// assumed 1).
// Latched state lives in player counters (prefix `bondaddon:`); onPrice only reads them (pure).
import { msg, dn } from '../../../../../shared/i18n.js';
const C_VISI_PAID = 'bondaddon:visi:paid';
const C_VISI_DISC = 'bondaddon:visi:disc';
const C_MIRA_PAID = 'bondaddon:mira:paid';
@@ -50,10 +52,10 @@ function settleCoins(ctx, bondId, counterKey, label) {
const gain = (due - paid) * count;
if (prepEnded(ctx)) {
ctx.addPendingFunds(gain);
ctx.toast(`【${label}】层数达成,下回合开始时获得${gain}资金`, 'info');
ctx.toast(msg('【{label}】层数达成,下回合开始时获得{gain}资金', { label: dn(label), gain }), 'info');
} else {
ctx.addFunds(gain, `bond:${bondId}`);
ctx.toast(`【${label}】层数达成,获得${gain}资金`, 'info');
ctx.toast(msg('【{label}】层数达成,获得{gain}资金', { label: dn(label), gain }), 'info');
}
return gain;
}
+2 -1
View File
@@ -42,6 +42,7 @@ import { spawnYanyou } from '../tokens.js';
import { kjeragColdWind } from '../devices.js';
import * as items from '../items.js';
import { FORCED_EXIT } from '../../constants.js';
import { msg } from '../../../../shared/i18n.js';
const num = S.num;
@@ -637,7 +638,7 @@ export function payHammers(ctx) {
if (id && ctx.grantItem(id, { source: 'bond:victoriaShip' })) granted++;
}
}
if (granted && typeof ctx.toast === 'function') ctx.toast(`【维多利亚】获得${granted}件维式重锤`, 'info');
if (granted && typeof ctx.toast === 'function') ctx.toast(msg('【维多利亚】获得{n}件维式重锤', { n: granted }), 'info');
return granted;
}
+129
View File
@@ -0,0 +1,129 @@
// Server → client texts in the player's language (docs/I18N.md): m.toast / m.ticker frames carry the Chinese `text`
// (older clients) plus `msgid` + `params`, a config.broadcasts line its `args`; the client renders them with
// translateWire / ui/lang.js tickerText; error codes through ui/toasts.js describeError.
import { test, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync, existsSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { MatchMessaging } from '../server/match/match/messaging.js';
import { addMessages, setLang, setMessages, translateWire, msg, dn } from '../shared/i18n.js';
import { makeMatch, give, DATA } from './match/harness.js';
import { createRegistry } from '../server/match/effectsMeta.js';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const EN_UI = JSON.parse(readFileSync(path.join(ROOT, 'public/i18n/en.json'), 'utf8'));
const realFetch = globalThis.fetch;
let lang;
let data;
let toasts;
before(async () => {
// the client data store (singleton) reads data/ through fetch: serve it from the repo
globalThis.fetch = async (url) => {
const abs = path.join(ROOT, String(url).replace(/^\//, ''));
if (!existsSync(abs)) return { ok: false, status: 404, json: async () => null };
const text = readFileSync(abs, 'utf8');
return { ok: true, status: 200, json: async () => JSON.parse(text) };
};
lang = await import('../public/js/ui/lang.js');
({ data } = await import('../public/js/data.js'));
toasts = await import('../public/js/ui/toasts.js');
await data.loadAll('config', 'chess', 'tokens', 'bonds');
addMessages('en', EN_UI);
});
after(() => { globalThis.fetch = realFetch; setLang('zh'); setMessages('en', {}); });
/** A Match-shaped stub for the messaging methods. */
function stubMatch() {
const sent = [];
const broadcast = [];
return {
sent, bc: broadcast,
disposed: false,
players: new Map([['p1', { playerId: 'p1', connected: true }]]),
spectators: new Map(),
gd: { config: DATA.config },
sendTo(id, m) { sent.push([id, m]); return true; },
broadcast(m) { broadcast.push(m); },
};
}
test('m.toast frames: a string stays { text }; a msg() adds msgid + params beside its Chinese text', () => {
const m = stubMatch();
const ps = m.players.get('p1');
MatchMessaging.prototype.toast.call(m, ps, 'warn', '整备区已满,获得的装备已销毁');
MatchMessaging.prototype.toast.call(m, ps, 'warn', msg('{names}只能部署在召唤者攻击范围内,已退回整备区', { names: ['医疗探机', '狼影'].map(dn) }));
assert.deepEqual(m.sent[0][1], { t: 'm.toast', kind: 'warn', text: '整备区已满,获得的装备已销毁' });
assert.deepEqual(m.sent[1][1], {
t: 'm.toast', kind: 'warn', text: '医疗探机、狼影只能部署在召唤者攻击范围内,已退回整备区',
msgid: '{names}只能部署在召唤者攻击范围内,已退回整备区', params: { names: [{ dn: '医疗探机' }, { dn: '狼影' }] },
});
});
test('m.ticker frames: broadcast lines carry their args; CUSTOM lines a msgid', () => {
const m = stubMatch();
MatchMessaging.prototype.tickerFor.call(m, 'GOLDEN_CHAR', ['阿米娅', '琳琅诗怀雅'], { playerId: 'p1' });
MatchMessaging.prototype.tickerText.call(m, msg('{name}博士中途退出了模拟', { name: '阿米娅' }), 5);
const [gold, custom] = m.bc;
assert.equal(gold.type, 'GOLDEN_CHAR');
assert.deepEqual(gold.args, ['阿米娅', '琳琅诗怀雅']);
assert.ok(gold.text.includes('阿米娅') && gold.text.includes('琳琅诗怀雅'), gold.text);
assert.deepEqual({ ...custom }, { t: 'm.ticker', text: '阿米娅博士中途退出了模拟', msgid: '{name}博士中途退出了模拟', params: { name: '阿米娅' }, id: null, type: 'CUSTOM', priority: 5, playerId: null });
});
test('client: the same frames in English — msgid + params, broadcast template + args (player names never translated)', async () => {
setLang('en');
await data.setLocale('en');
try {
const toastFrame = { text: '医疗探机只能部署在召唤者攻击范围内,已退回整备区', msgid: '{names}只能部署在召唤者攻击范围内,已退回整备区', params: { names: [{ dn: '医疗探机' }] } };
assert.equal(translateWire(toastFrame), "Medic Drone can only be deployed within the summoner's attack range and returned to the Bench");
assert.equal(translateWire({ text: '整备区已满,获得的装备已销毁' }), EN_UI['整备区已满,获得的装备已销毁'], 'a static server text is its own msgid');
const m = stubMatch();
MatchMessaging.prototype.tickerFor.call(m, 'GOLDEN_CHAR', ['阿米娅', '琳琅诗怀雅'], { playerId: 'p1' });
const line = lang.tickerText(m.bc[0]);
assert.ok(line.includes('阿米娅'), `player name kept: ${line}`);
assert.ok(line.includes('Swire the Elegant Wit'), `operator name translated: ${line}`);
assert.ok(!/[一-鿿]/.test(line.replace('阿米娅', '')), line);
// an older server (no args): the Chinese line as it came
assert.equal(lang.tickerText({ text: m.bc[0].text, id: m.bc[0].id }), m.bc[0].text);
// error codes: ERR_TEXT / net.js CLIENT_ERR_TEXT are msgids
assert.equal(toasts.describeError({ code: 'HAND_FULL', msg: '整备区已满' }), 'Bench is full');
assert.equal(toasts.describeError('NO_FUNDS'), EN_UI['资金不足']);
} finally {
setLang('zh');
await data.setLocale('zh');
}
});
test('a real match: 歌蕾蒂娅 grants a chess → 「歌蕾蒂娅:获得X」 in Chinese, "Gladiia: obtained …" in English', async () => {
const h = makeMatch({ mode: 'solo', seed: 11, registry: createRegistry({ log: { warn() {}, error() {}, info() {} } }), fake: true }).start();
h.toPrep(1);
const m = h.m;
const ps = h.ps('p_0');
for (const p of [...ps.board.values(), ...ps.hand.filter(Boolean)]) if (p.kind === 'chess') ps.returnCopies(p);
ps.board.clear(); ps.hand.fill(null); ps.temp.fill(null); ps.offers.length = 0; ps.bandId = null; ps.recompute();
const prepFree = (c) => (c.garrisonIds || []).every((g) => DATA.garrisons[g].eventType === 'IN_BATTLE');
const row = Object.values(DATA.chess).filter((c) => c.visible && !c.isGolden && prepFree(c)).map((c) => c.chessId).sort().slice(0, 2);
give(m, ps, 'chess_char_4_12_a', 'board', [10, 4]);
give(m, ps, row[0], 'board', [10, 6]);
give(m, ps, row[1], 'board', [10, 8]);
const n0 = h.allTo('p_0', 'm.toast').length;
m.dispatch(ps, 'onRoundStart', { round: m.round });
const frame = h.allTo('p_0', 'm.toast').slice(n0).find((t) => t.msgid === '{who}:获得{name}');
assert.ok(frame, JSON.stringify(h.allTo('p_0', 'm.toast').slice(n0)));
assert.ok(frame.text.startsWith('歌蕾蒂娅:获得'), frame.text);
assert.deepEqual(frame.params.who, { dn: '歌蕾蒂娅' });
setLang('en');
await data.setLocale('en');
try {
const en = translateWire(JSON.parse(JSON.stringify(frame)));
assert.match(en, /^Gladiia: obtained [A-Z]/, en);
assert.ok(!/[一-鿿]/.test(en), en);
} finally {
setLang('zh');
await data.setLocale('zh');
m.dispose();
}
});
+2 -1
View File
@@ -23,6 +23,7 @@ import { DATA, makeMatch, give, giveItem, checkInvariants, chessOfTier } from '.
import { botPrep, planLayout, fieldModel } from '../../server/match/bot.js';
import { placementContext, canPlace as clientCanPlace, boardTargets, deployMap as clientDeployMap } from '../../public/js/ui/gameLogic.js';
import { makeBattle } from '../helpers/battleHarness.js';
import { renderMessage } from '../../shared/i18n.js';
const STAGE = 'act2autochess_m04';
const WATER = ['10,6', '11,6', '12,6'];
@@ -187,7 +188,7 @@ test('#9 hand and temp full: a re-orientation that would push 狼群 out is refu
assert.equal(stackOf(ps, WOLF), null, 'its only copy is placed: no stack left to return onto');
const toasts = [];
const toast = m.toast.bind(m);
m.toast = (p, kind, text) => { toasts.push(text); return toast(p, kind, text); };
m.toast = (p, kind, text) => { toasts.push(renderMessage(text)); return toast(p, kind, text); }; // string or msg()
const golden = Object.keys(DATA.items).find((id) => DATA.items[id].isGolden && DATA.items[id].itemType === 'EQUIP');
while (ps.hand.includes(null)) giveItem(m, ps, golden);
while (ps.temp.includes(null)) giveItem(m, ps, golden, 'temp');
+3 -1
View File
@@ -23,6 +23,7 @@ import {
import { planLayout } from '../../server/match/bot.js';
import { collectViolations } from '../../server/match/invariants.js';
import { DATA, makeMatch, give, chessOfTier, checkInvariants } from './harness.js';
import { renderMessage } from '../../shared/i18n.js';
const STAGE = 'act2autochess_m01';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '../..');
@@ -115,7 +116,8 @@ describe('#7 the Final Assault prep deploys on the boss field', () => {
h.drive(() => m.phase === PHASE.PREP && m.round === m.gd.bossRound);
const toasts = [];
const toast = m.toast.bind(m);
m.toast = (ps, kind, text) => { toasts.push(`${ps.playerId}:${text}`); return toast(ps, kind, text); };
// a toast is a string or a shared/i18n.js msg(): compare its Chinese rendering
m.toast = (ps, kind, text) => { toasts.push(`${ps.playerId}:${renderMessage(text)}`); return toast(ps, kind, text); };
const fields = [];
m.deployFieldOf = ((orig) => function (ps) { const f = orig.call(this, ps); if (this.round === this.gd.hiddenRound) fields.push(f); return f; })(m.deployFieldOf);
const placed = {};