diff --git a/server/match/effectsMeta.js b/server/match/effectsMeta.js index 86f65b5f..4687419b 100644 --- a/server/match/effectsMeta.js +++ b/server/match/effectsMeta.js @@ -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 }); }, diff --git a/server/match/match/messaging.js b/server/match/match/messaging.js index 6532cd7c..17ec73d9 100644 --- a/server/match/match/messaging.js +++ b/server/match/match/messaging.js @@ -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; } diff --git a/server/match/match/platform.js b/server/match/match/platform.js index 96b4b233..f9878d9f 100644 --- a/server/match/match/platform.js +++ b/server/match/match/platform.js @@ -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) diff --git a/server/match/match/settle.js b/server/match/match/settle.js index 97cfcc2d..1902c549 100644 --- a/server/match/match/settle.js +++ b/server/match/match/settle.js @@ -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 = []; diff --git a/server/match/match/unitePhase.js b/server/match/match/unitePhase.js index ff0f17d7..4f1093af 100644 --- a/server/match/match/unitePhase.js +++ b/server/match/match/unitePhase.js @@ -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() { diff --git a/server/match/player/basics.js b/server/match/player/basics.js index 8603d50c..b9b8582f 100644 --- a/server/match/player/basics.js +++ b/server/match/player/basics.js @@ -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; } diff --git a/server/match/player/placement.js b/server/match/player/placement.js index 25263187..bca6f9f3 100644 --- a/server/match/player/placement.js +++ b/server/match/player/placement.js @@ -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; } diff --git a/server/sim/content/bonds/addon/meta.js b/server/sim/content/bonds/addon/meta.js index 6801d064..43e62ca3 100644 --- a/server/sim/content/bonds/addon/meta.js +++ b/server/sim/content/bonds/addon/meta.js @@ -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; } diff --git a/server/sim/content/bonds/core.js b/server/sim/content/bonds/core.js index 6eea7878..f07ffa6d 100644 --- a/server/sim/content/bonds/core.js +++ b/server/sim/content/bonds/core.js @@ -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; } diff --git a/test/i18n-server.test.js b/test/i18n-server.test.js new file mode 100644 index 00000000..2ea7f864 --- /dev/null +++ b/test/i18n-server.test.js @@ -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(); + } +}); diff --git a/test/match/feedback1-placement.test.js b/test/match/feedback1-placement.test.js index 69320418..f7b9fe56 100644 --- a/test/match/feedback1-placement.test.js +++ b/test/match/feedback1-placement.test.js @@ -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'); diff --git a/test/match/playtest5-deploy.test.js b/test/match/playtest5-deploy.test.js index a63f4aef..a96e174a 100644 --- a/test/match/playtest5-deploy.test.js +++ b/test/match/playtest5-deploy.test.js @@ -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 = {};