feedback5 W5S: i18n phase 2, match texts the server sends — a 机变 card's and an effect entry's name and description, the draft's name, a reward offer's label and the result title are shown from the localized data when the server's Chinese text is the data record's own (data.lookupRaw + ui/lang.js sentText; names through tName), a text the server reworded (a multi-round 悬赏) stays as sent; Chinese output unchanged by construction (in Chinese the localized record is the raw one); test/i18n-server.test.js covers it
This commit is contained in:
1 parent
7697b4ff40
commit
0025ceac16
7 files changed
+111
-15
No files matched your search
@@ -305,6 +305,17 @@ export function createDataStore(opts = {}) {
|
||||
if (id == null) return null;
|
||||
return index(name)?.get(String(id)) ?? null;
|
||||
},
|
||||
/**
|
||||
* Record by id from a loaded file exactly as downloaded (its Chinese texts), whatever the locale — what the server
|
||||
* copied into a message, to compare with what it sent (ui/lang.js sentText).
|
||||
*/
|
||||
lookupRaw(name, id) {
|
||||
if (id == null) return null;
|
||||
const e = entries.get(name);
|
||||
if (!e || e.status !== 'ready') return null;
|
||||
if (!e.index) e.index = buildIndex(name, e.value);
|
||||
return e.index.get(String(id)) ?? null;
|
||||
},
|
||||
/** All records of a loaded file as an array (empty when not loaded). */
|
||||
list: (name) => [...(index(name)?.values() ?? [])],
|
||||
/** Drop a cached file and refetch it now (subscribers are notified when it settles). */
|
||||
|
||||
@@ -23,6 +23,7 @@ import { data } from '../data.js';
|
||||
import { enemyIconUrl, titleIconUrl, uiUrl } from '../ui/assetUrls.js';
|
||||
import { store, useStore, emptyMatch } from '../store.js';
|
||||
import { audio } from '../audio.js';
|
||||
import { sentText } from '../ui/lang.js';
|
||||
import { t, tParts, N_ } from '../../../shared/i18n.js';
|
||||
|
||||
const cx = (...p) => p.flat().filter(Boolean).join(' ');
|
||||
@@ -49,8 +50,10 @@ export function LineupThumb({ u, gd }) {
|
||||
|
||||
function PlayerCard({ p, myId, titles, best, solo = false }) {
|
||||
const gd = useGameData();
|
||||
const titleRec = p.title ? titles.find((t) => t.id === p.title.id) || null : null;
|
||||
const titleName = p.title?.name || titleRec?.name || null;
|
||||
const titleRec = p.title ? titles.find((x) => x.id === p.title.id) || null : null;
|
||||
// (the server sends the title's Chinese name: the localized config.titles record when it is that record's)
|
||||
const rawTitle = p.title ? (data.getRaw('config')?.titles || []).find((x) => x && x.id === p.title.id) || null : null;
|
||||
const titleName = sentText(p.title?.name, rawTitle?.name, titleRec?.name) || titleRec?.name || null;
|
||||
const stats = STAT_ROWS.filter(([k]) => Number.isFinite(p.stats[k])).slice(0, 7);
|
||||
const band = p.bandId ? gd.band(p.bandId) : null;
|
||||
const lineup = p.lineup.slice(0, 10);
|
||||
|
||||
@@ -23,7 +23,8 @@ import { itemIconUrl, enemyIconUrl, uiUrl } from './assetUrls.js';
|
||||
import { richTextPlain } from './richText.js';
|
||||
import { sortedPlayers } from './gameLogic.js';
|
||||
import { data } from '../data.js';
|
||||
import { t } from '../../../shared/i18n.js';
|
||||
import { t, tName } from '../../../shared/i18n.js';
|
||||
import { sentText } from './lang.js';
|
||||
|
||||
const cx = (...p) => p.flat().filter(Boolean).join(' ');
|
||||
|
||||
@@ -32,12 +33,14 @@ const bare = (t) => String(t || '').replace(/\s+/g, '');
|
||||
/**
|
||||
* A card's effect text: the card's own rich text, else the data's rich text (highlighted numbers, 下场作战, set bonuses,
|
||||
* line breaks — the detail card's text) while it says what the server's plain `desc` says, else that plain text (a
|
||||
* server-side wording the data does not have wins).
|
||||
* server-side wording the data does not have wins). The server's texts are Chinese: `rawRich` is the record's Chinese
|
||||
* rich text they are compared with, `rich` the same record in the current language (the two are one text in Chinese).
|
||||
* @param {{ descRaw?: string, desc?: string }} card @param {string} rich items.json / effects.json descRaw
|
||||
* @param {string} [rawRich] its Chinese text (data.lookupRaw)
|
||||
*/
|
||||
export function cardText(card, rich) {
|
||||
if (card.descRaw) return card.descRaw;
|
||||
if (rich && (!card.desc || bare(richTextPlain(rich)) === bare(card.desc))) return rich;
|
||||
export function cardText(card, rich, rawRich = rich) {
|
||||
if (card.descRaw) return rich && card.descRaw === rawRich ? rich : card.descRaw;
|
||||
if (rich && (!card.desc || bare(richTextPlain(rawRich || rich)) === bare(card.desc))) return rich;
|
||||
return card.desc || rich || '';
|
||||
}
|
||||
|
||||
@@ -53,6 +56,7 @@ export function resolveSpCard(card, family) {
|
||||
const eff = effectId ? data.lookup('effects', effectId) : null;
|
||||
const itemId = card.itemId || (!effectId && typeof card.id === 'string' && data.lookup('items', card.id) ? card.id : null);
|
||||
const item = itemId ? data.lookup('items', itemId) : null;
|
||||
const rawRich = (itemId && data.lookupRaw('items', itemId)?.descRaw) || (effectId && data.lookupRaw('effects', effectId)?.descRaw) || '';
|
||||
const bounty = byEffect(choices?.cards?.bounty, effectId);
|
||||
const tactic = byEffect(choices?.cards?.tactic, effectId);
|
||||
const m = data.get('assets');
|
||||
@@ -67,8 +71,8 @@ export function resolveSpCard(card, family) {
|
||||
else icon = uiUrl(m, `buffIcon/${team ? 'icon_team_buff' : 'icon_player_buff'}`);
|
||||
return {
|
||||
kind,
|
||||
name: card.name || item?.name || eff?.name || bounty?.name || tactic?.name || t('机变'),
|
||||
desc: cardText(card, item?.descRaw || eff?.descRaw || '') || item?.desc || eff?.desc || bounty?.desc || tactic?.desc || '',
|
||||
name: tName(card.name) || item?.name || eff?.name || bounty?.name || tactic?.name || t('机变'),
|
||||
desc: cardText(card, item?.descRaw || eff?.descRaw || '', rawRich) || item?.desc || eff?.desc || bounty?.desc || tactic?.desc || '',
|
||||
tier: Number.isFinite(card.tier) ? card.tier : item?.tier ?? bounty?.tier ?? null,
|
||||
icon,
|
||||
team: !!team,
|
||||
@@ -156,6 +160,7 @@ export function ChoiceOverlay(props) {
|
||||
export function ChoiceView({ pub, sp, myId, solo, busyIdx = null, total = null, armed = null, onTap = () => {}, onConfirm = () => {}, onDisarm = () => {} }) {
|
||||
if (!sp) return null;
|
||||
const fam = data.get('choices')?.families?.[sp.family] || null;
|
||||
const rawFam = data.getRaw('choices')?.families?.[sp.family] || null;
|
||||
const players = new Map(sortedPlayers(pub).map((p) => [p.playerId, p]));
|
||||
const myTurn = solo || sp.turnPid === myId;
|
||||
const mine = sp.pickOf.get(myId);
|
||||
@@ -178,7 +183,7 @@ export function ChoiceView({ pub, sp, myId, solo, busyIdx = null, total = null,
|
||||
<header class="spov__head">
|
||||
<div class="spov__titles">
|
||||
<${MicroLabel} tone="mint">${t('CONTINGENCY // 机变阶段')}</${MicroLabel}>
|
||||
<h2 class=${cx('spov__title', special && 'is-special')}>${sp.name || fam?.name || t('机变')}<span class="spov__bar">|</span><span class="spov__desc"><${RichText} text=${sp.desc || fam?.desc || t('选择一项')} /></span></h2>
|
||||
<h2 class=${cx('spov__title', special && 'is-special')}>${sentText(sp.name, rawFam?.name, fam?.name) || fam?.name || t('机变')}<span class="spov__bar">|</span><span class="spov__desc"><${RichText} text=${sentText(sp.desc, rawFam?.desc, fam?.desc) || fam?.desc || t('选择一项')} /></span></h2>
|
||||
<p class="spov__sub">${timed ? t('倒计时结束后仍未选定将自动分配') : t('选择一项(无时间限制)')}${mine == null && myTurn ? t(' · 点击卡牌选中,再次点击确认') : ''}</p>
|
||||
</div>
|
||||
<div class="spov__turn">
|
||||
|
||||
@@ -6,7 +6,8 @@ import { html, Tooltip, MicroLabel } from './components.js';
|
||||
import { Img, RichText, GIcon } from './gameComponents.js';
|
||||
import { effectIconUrl } from './assetUrls.js';
|
||||
import { data } from '../data.js';
|
||||
import { t, N_ } from '../../../shared/i18n.js';
|
||||
import { t, tName, N_ } from '../../../shared/i18n.js';
|
||||
import { sentText } from './lang.js';
|
||||
|
||||
const KIND = { band: N_('策略'), choice: N_('机变'), team: N_('团队增益'), item: N_('道具'), garrison: N_('特质') };
|
||||
|
||||
@@ -17,6 +18,31 @@ const KIND = { band: N_('策略'), choice: N_('机变'), team: N_('团队增益'
|
||||
*/
|
||||
export const bountyCounterText = (e) => (e.counter == null ? t('之后的每场作战') : t('还剩 {left} 场作战', { left: e.counter }));
|
||||
|
||||
/** Data files whose records an effect entry copies its name / description from (by its id or icon id). */
|
||||
const SOURCES = ['effects', 'bands', 'items', 'garrisons'];
|
||||
|
||||
/**
|
||||
* An effect entry's description in the current language (the server copies a record's Chinese text: an effect's, a
|
||||
* strategy's, an item's, a 特质's — sentText; a reworded one, such as a multi-round 悬赏's, stays as sent).
|
||||
* @param {{ id?: string, iconId?: string, desc?: string }} e
|
||||
*/
|
||||
export function effectDesc(e) {
|
||||
const desc = e.desc || '';
|
||||
if (!desc) return desc;
|
||||
for (const file of SOURCES) {
|
||||
for (const id of [e.id, e.iconId]) {
|
||||
const raw = typeof id === 'string' && id ? data.lookupRaw(file, id) : null;
|
||||
if (!raw) continue;
|
||||
const loc = data.lookup(file, id) || raw;
|
||||
for (const k of ['descRaw', 'desc']) {
|
||||
const v = sentText(desc, raw[k], loc[k]);
|
||||
if (v !== desc) return v;
|
||||
}
|
||||
}
|
||||
}
|
||||
return desc;
|
||||
}
|
||||
|
||||
/** @param {{ effects: any[] }} props */
|
||||
export function EffectsList({ effects }) {
|
||||
const list = (Array.isArray(effects) ? effects : []).filter((e) => e && (e.name || e.desc));
|
||||
@@ -25,8 +51,8 @@ export function EffectsList({ effects }) {
|
||||
return html`<div class="effects" aria-label=${t('生效中的效果')}>
|
||||
<${MicroLabel}>EFFECTS</${MicroLabel}>
|
||||
${list.slice(0, 10).map((e, i) => html`<${Tooltip} key=${e.id ?? i} placement="bottom" text=${html`<div class="efftip">
|
||||
<b>${e.name || t('效果')}</b><span class="efftip__kind">${t(KIND[e.iconKind] || '')}${e.counterText ? ` · ${bountyCounterText(e)}` : e.counter != null ? ` · ${e.counter}` : ''}</span>
|
||||
<${RichText} as="p" text=${e.desc || ''} />
|
||||
<b>${tName(e.name) || t('效果')}</b><span class="efftip__kind">${t(KIND[e.iconKind] || '')}${e.counterText ? ` · ${bountyCounterText(e)}` : e.counter != null ? ` · ${e.counter}` : ''}</span>
|
||||
<${RichText} as="p" text=${effectDesc(e)} />
|
||||
</div>`}>
|
||||
<span class=${`effect effect--${e.iconKind || 'x'}`}>
|
||||
<${Img} src=${effectIconUrl(m, e)} fallback=${html`<${GIcon} name="bolt" />`} />
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { GEO } from '../../../../shared/constants.js';
|
||||
import { isObj } from './shared.js';
|
||||
import { t } from '../../../../shared/i18n.js';
|
||||
import { t, tName } from '../../../../shared/i18n.js';
|
||||
|
||||
|
||||
// ---- shop ---------------------------------------------------------------------------------------------
|
||||
@@ -119,7 +119,8 @@ export function completesMerge(priv, slot, { getChess = () => null, getItem = ()
|
||||
export function offerHeader(offer) {
|
||||
const slots = isObj(offer) && Array.isArray(offer.slots) ? offer.slots : [];
|
||||
const items = slots.some((s) => isObj(s) && s.kind === 'item');
|
||||
const label = isObj(offer) && typeof offer.label === 'string' && offer.label ? offer.label : null;
|
||||
// (the server names the offer by its source in Chinese: a strategy's effect, an item, an operator — a data name)
|
||||
const label = isObj(offer) && typeof offer.label === 'string' && offer.label ? tName(offer.label) : null;
|
||||
const queued = isObj(offer) && Number.isInteger(offer.queued) && offer.queued > 0 ? offer.queued : 0;
|
||||
const tail = { queued, more: queued ? t('之后还有 {queued} 项', { queued }) : null };
|
||||
if (!items && (!isObj(offer) || offer.source === 'merge' || offer.source == null) && !label) {
|
||||
|
||||
@@ -152,6 +152,20 @@ export function LangToggle({ class: cls }) {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* A game text the server sent in Chinese — a 机变 card's or an effect's description, a draft's name, a result title —
|
||||
* in the current language: the localized record's text when the sent text is that record's own Chinese text
|
||||
* (data.lookupRaw / getRaw), else the sent text as it is (a line the server reworded stays Chinese). In Chinese this is
|
||||
* always the sent text. Names have a shorter way: tName() (every record name of the data is in the overlay's names).
|
||||
* @param {unknown} sent the server's text
|
||||
* @param {unknown} raw the record's Chinese text
|
||||
* @param {unknown} local the record's text in the current language
|
||||
*/
|
||||
export function sentText(sent, raw, local) {
|
||||
if (typeof sent !== 'string' || !sent) return sent;
|
||||
return typeof raw === 'string' && raw === sent && typeof local === 'string' && local ? local : sent;
|
||||
}
|
||||
|
||||
/**
|
||||
* The text of an m.ticker frame in the current language. A config.broadcasts line (`id` + `args`, server ≥ 0.2.0) is
|
||||
* rebuilt from the localized broadcast template — `{0}` is the player's name (never translated), other args are
|
||||
|
||||
@@ -127,3 +127,39 @@ test('a real match: 歌蕾蒂娅 grants a chess → 「歌蕾蒂娅:获得X」
|
||||
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');
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user