166 lines
9.5 KiB
JavaScript
166 lines
9.5 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();
|
||
}
|
||
});
|
||
|
||
test('server-sent game texts in English: 机变 cards, the draft name, effect entries and offer labels come from the localized data; a reworded text stays as sent; Chinese unchanged', async () => {
|
||
await data.loadAll('choices', 'effects', 'items', 'bands', 'garrisons');
|
||
const { resolveSpCard } = await import('../public/js/ui/choiceOverlay.js');
|
||
const { effectDesc } = await import('../public/js/ui/effectsList.js');
|
||
const { offerHeader } = await import('../public/js/ui/gameLogic/shop.js');
|
||
const HAN = /[一-鿿]/;
|
||
const raw = data.getRaw('choices');
|
||
// a 战术决策 card as server/match/choices.js tacticCard sends it (the card's Chinese name and plain text)
|
||
const tac = raw.cards.tactic.find((c) => c.effectId && c.name && c.desc && data.lookupRaw('effects', c.effectId)?.descRaw);
|
||
const card = { kind: 'tactic', id: tac.effectId, name: tac.name, desc: tac.desc };
|
||
const reworded = { ...card, desc: `${tac.desc}(服务器改写)` };
|
||
const zh = resolveSpCard(card, 'tactic');
|
||
assert.equal(zh.name, tac.name, 'Chinese: the name as sent');
|
||
const eff = data.lookupRaw('effects', tac.effectId);
|
||
const entry = { id: tac.effectId, iconId: tac.effectId, iconKind: 'choice', name: eff.name, desc: eff.descRaw };
|
||
assert.equal(effectDesc(entry), eff.descRaw, 'Chinese: the description as sent');
|
||
const band = data.list('bands').find((b) => b && b.effectName);
|
||
const offer = { source: 'special', label: band.effectName, slots: [{ kind: 'chess', id: 'x' }] };
|
||
assert.equal(offerHeader(offer).title, band.effectName);
|
||
setLang('en');
|
||
await data.setLocale('en');
|
||
try {
|
||
const en = resolveSpCard(card, 'tactic');
|
||
assert.ok(en.name && !HAN.test(en.name), `card name: ${en.name}`);
|
||
if (zh.desc === data.lookupRaw('effects', tac.effectId).descRaw) assert.equal(en.desc, data.lookup('effects', tac.effectId).descRaw, 'card text: the localized record');
|
||
assert.equal(resolveSpCard(reworded, 'tactic').desc, reworded.desc, 'a reworded text stays as the server sent it');
|
||
assert.equal(effectDesc(entry), data.lookup('effects', tac.effectId).descRaw);
|
||
assert.equal(effectDesc({ ...entry, desc: `${eff.descRaw}!` }), `${eff.descRaw}!`);
|
||
assert.ok(!HAN.test(offerHeader(offer).title), offerHeader(offer).title);
|
||
assert.equal(lang.sentText(raw.families.bounty.name, raw.families.bounty.name, data.get('choices').families.bounty.name), 'Bounty Decision');
|
||
} finally {
|
||
setLang('zh');
|
||
await data.setLocale('zh');
|
||
}
|
||
});
|