diff --git a/public/js/data.js b/public/js/data.js index e4c519ae..71630939 100644 --- a/public/js/data.js +++ b/public/js/data.js @@ -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). */ diff --git a/public/js/screens/result.js b/public/js/screens/result.js index bf0575fa..04e0b7c4 100644 --- a/public/js/screens/result.js +++ b/public/js/screens/result.js @@ -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); diff --git a/public/js/ui/choiceOverlay.js b/public/js/ui/choiceOverlay.js index 19e44c6e..11207e84 100644 --- a/public/js/ui/choiceOverlay.js +++ b/public/js/ui/choiceOverlay.js @@ -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,
<${MicroLabel} tone="mint">${t('CONTINGENCY // 机变阶段')} -

${sp.name || fam?.name || t('机变')}|<${RichText} text=${sp.desc || fam?.desc || t('选择一项')} />

+

${sentText(sp.name, rawFam?.name, fam?.name) || fam?.name || t('机变')}|<${RichText} text=${sentText(sp.desc, rawFam?.desc, fam?.desc) || fam?.desc || t('选择一项')} />

${timed ? t('倒计时结束后仍未选定将自动分配') : t('选择一项(无时间限制)')}${mine == null && myTurn ? t(' · 点击卡牌选中,再次点击确认') : ''}

diff --git a/public/js/ui/effectsList.js b/public/js/ui/effectsList.js index b7d121d6..d65939d6 100644 --- a/public/js/ui/effectsList.js +++ b/public/js/ui/effectsList.js @@ -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`
<${MicroLabel}>EFFECTS ${list.slice(0, 10).map((e, i) => html`<${Tooltip} key=${e.id ?? i} placement="bottom" text=${html`
- ${e.name || t('效果')}${t(KIND[e.iconKind] || '')}${e.counterText ? ` · ${bountyCounterText(e)}` : e.counter != null ? ` · ${e.counter}` : ''} - <${RichText} as="p" text=${e.desc || ''} /> + ${tName(e.name) || t('效果')}${t(KIND[e.iconKind] || '')}${e.counterText ? ` · ${bountyCounterText(e)}` : e.counter != null ? ` · ${e.counter}` : ''} + <${RichText} as="p" text=${effectDesc(e)} />
`}> <${Img} src=${effectIconUrl(m, e)} fallback=${html`<${GIcon} name="bolt" />`} /> diff --git a/public/js/ui/gameLogic/shop.js b/public/js/ui/gameLogic/shop.js index a139c285..3e1e4590 100644 --- a/public/js/ui/gameLogic/shop.js +++ b/public/js/ui/gameLogic/shop.js @@ -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) { diff --git a/public/js/ui/lang.js b/public/js/ui/lang.js index 0d4fa044..d080a251 100644 --- a/public/js/ui/lang.js +++ b/public/js/ui/lang.js @@ -152,6 +152,20 @@ export function LangToggle({ class: cls }) {
`; } +/** + * 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 diff --git a/test/i18n-server.test.js b/test/i18n-server.test.js index 2ea7f864..d74c97dd 100644 --- a/test/i18n-server.test.js +++ b/test/i18n-server.test.js @@ -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'); + } +});