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.
130 lines
7.1 KiB
JavaScript
130 lines
7.1 KiB
JavaScript
// 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();
|
|
}
|
|
});
|