// ui/gameLogic/bonds.js — bond counts, members, harmony, 本局禁用. Re-exported from ../gameLogic.js. import { int, isObj } from './shared.js'; import { diyPicks } from './diy.js'; import { t } from '../../../../shared/i18n.js'; // ---- bonds ------------------------------------------------------------------------------------------- /** * Sort bonds for the strip: active first, then layers desc, count desc, tier desc, core first, id; the mode-off bonds * (`off`: the server's entries for the bonds this mode never activates that the player has members of — * server/match/bondsMeta.js offBondCounts) after all the others. * @template {{bondId:string, active?:boolean, layers?:number, count?:number, tier?:number, off?:boolean}} B * @param {B[]} bonds * @param {(id:string)=>any} [getBond] * @returns {B[]} */ export function sortBonds(bonds, getBond = () => null) { const list = (Array.isArray(bonds) ? bonds : []).filter((b) => isObj(b) && typeof b.bondId === 'string'); const n = (v) => (Number.isFinite(v) ? v : 0); return [...list].sort((a, b) => ( (a.off ? 1 : 0) - (b.off ? 1 : 0) || (b.active ? 1 : 0) - (a.active ? 1 : 0) || n(b.layers) - n(a.layers) || n(b.count) - n(a.count) || n(b.tier) - n(a.tier) || (getBond(b.bondId)?.isCore ? 1 : 0) - (getBond(a.bondId)?.isCore ? 1 : 0) || (a.bondId < b.bondId ? -1 : a.bondId > b.bondId ? 1 : 0) )); } /** * Tier reached for a member count against ascending thresholds (downward bonds: active while count ≤ max). * @param {number} count * @param {number[]} thresholds * @param {number|null} [maxCount] */ export function bondTier(count, thresholds, maxCount = null) { const c = Number.isFinite(count) ? count : 0; if (Number.isFinite(maxCount) && c > maxCount) return 0; let t = 0; for (const th of Array.isArray(thresholds) ? thresholds : []) if (c >= th) t += 1; return t; } /** Next threshold above the count, or null when maxed. */ export function nextThreshold(count, thresholds) { for (const th of Array.isArray(thresholds) ? thresholds : []) if (count < th) return th; return null; } /** * Bonds granted by 变形同构体: an item with `canGiveBond` worn together with an item that has a `giveBondId` makes the * wearer a member of that other item's bond (band 变形者集群 "与特定装备一同装备时装备者将视为特定盟约成员"; the item's * talent, character_table trap_1073_acarm073, lists the 14 pairings). The same rule as the server's count * (server/match/bondsMeta.js pieceBonds) and the battle's (server/sim/content/support unitBonds). * @param {Array|null|undefined} items the carried items (ids or { id } pieces) * @param {(id:string)=>any} [getItem] items.json lookup * @returns {string[]} */ export function grantedBonds(items, getItem = () => null) { const list = Array.isArray(items) ? items : []; if (list.length < 2) return []; const recs = list.map((it) => getItem(typeof it === 'string' ? it : it?.id)).filter(isObj); if (!recs.some((r) => r.canGiveBond)) return []; const out = []; for (const r of recs) if (!r.canGiveBond && typeof r.giveBondId === 'string' && r.giveBondId && !out.includes(r.giveBondId)) out.push(r.giveBondId); return out; } /** * A chess piece's bonds: its record's + those its 变形同构体 pairing grants (grantedBonds) — the detail card's chips. * @param {any} chess chess.json record @param {Array|null|undefined} items @param {(id:string)=>any} [getItem] * @returns {string[]} */ export function pieceBondIds(chess, items, getItem = () => null) { const out = (Array.isArray(chess?.bonds) ? chess.bonds : []).filter((b) => typeof b === 'string'); for (const b of grantedBonds(items, getItem)) if (!out.includes(b)) out.push(b); return out; } /** * The 变形同构体 pairings — the list its 天赋栏 shows (the item text: "具体对应关系可在模拟中查看本装备天赋栏"; character_table * trap_1073_acarm073 "“炎国短刀”→【炎】盟约 …", 14 bonds; GitHub issue #1: without it the item read as doing nothing). * Built from the same `giveBondId` the server counts with (server/match/bondsMeta.js pieceBonds, grantedBonds here): every * bond an item grants, in bonds.json order, with the items that grant it (one entry per item family — the normal and 进阶 * copies share a name —, by tier, then data order). `off`: the mode never activates the bond (modeOffBonds: 本局禁用 — 标准 * leaves 5 of the 14 off). `worn`: the wearer's items (`carried`, the card shows the 变形同构体 on an operator) pair it with * one of the bond's items — the pairing in effect on that operator (that item `worn` too). * @param {Iterable} itemRecs items.json records (data.list('items')) * @param {Iterable} bondRecs bonds.json records in data order (data.list('bonds')) * @param {{ off?: Set|null, carried?: Array|null }} [opts] * @returns {Array<{ bondId: string, name: string, off: boolean, worn: boolean, items: Array<{ id: string, name: string, worn: boolean }> }>} */ export function morphPairings(itemRecs, bondRecs, { off = null, carried = null } = {}) { const recs = [...(itemRecs || [])].filter(isObj); const byId = new Map(recs.map((r) => [r.id, r])); const getItem = (id) => byId.get(id) || null; const worn = new Set(grantedBonds(carried, getItem)); const carriedIds = new Set((Array.isArray(carried) ? carried : []).map((it) => (typeof it === 'string' ? it : it?.id)).filter((x) => typeof x === 'string')); const famOf = (r) => (typeof r.baseId === 'string' && r.baseId ? r.baseId : r.id); /** bondId → family id → { id, name, tier, order, ids } */ const groups = new Map(); recs.forEach((r, order) => { if (r.canGiveBond || typeof r.giveBondId !== 'string' || !r.giveBondId) return; let g = groups.get(r.giveBondId); if (!g) groups.set(r.giveBondId, (g = new Map())); const fam = famOf(r); const e = g.get(fam); if (e) { e.ids.push(r.id); return; } g.set(fam, { id: fam, name: typeof r.name === 'string' && r.name ? r.name : fam, tier: int(r.tier, 99), order, ids: [r.id] }); }); const out = []; for (const b of bondRecs || []) { const g = isObj(b) ? groups.get(b.bondId) : null; if (!g) continue; const bondWorn = worn.has(b.bondId); const items = [...g.values()].sort((x, y) => (x.tier - y.tier) || (x.order - y.order)) .map((e) => ({ id: e.id, name: e.name, worn: bondWorn && e.ids.some((id) => carriedIds.has(id)) })); out.push({ bondId: b.bondId, name: typeof b.name === 'string' && b.name ? b.name : b.bondId, off: !!(off && typeof off.has === 'function' && off.has(b.bondId)), worn: bondWorn, items }); } return out; } /** * Bond id of 调和 (bonds.json maniShip, "激活时使场上核心盟约的激活人数+1") — server/match/bondsMeta.js HARMONY_BOND; a test * pins the two. Only to name the 调和 operators: whether a bond's count holds the +1 is the server's word (the view * entry's `harmony`, DESIGN §21.26). */ export const HARMONY_BOND = 'maniShip'; /** * The 调和 operators on a board, distinct (normal and elite copies of one operator once): who activates the +1 a bond * entry's `harmony` reports (the bond popup's 调和 row names them — 缪尔赛思, the Pith strategy's 盟约·辅助干员). * @param {any} priv m.private — or a teammate's field operators (ui/watchBonds.js ownerBoard) * @param {(id:string)=>any} [getChess] * @returns {Array<{ id: string, name: string }>} base chess ids */ export function harmonyMembers(priv, getChess = () => null) { const out = []; const seen = new Set(); for (const p of Array.isArray(priv?.board) ? priv.board : []) { if (p?.kind !== 'chess') continue; const c = getChess(p.id); if (!Array.isArray(c?.bonds) || !c.bonds.includes(HARMONY_BOND)) continue; const base = c.baseId || (typeof p.id === 'string' ? p.id.replace(/_b$/, '_a') : p.id); if (seen.has(base)) continue; seen.add(base); out.push({ id: base, name: getChess(base)?.name || c.name || base }); } return out; } /** * Member rows of a bond popup: every visible member with owned / on-board / in-hand / banned state, plus the player's * operators that are members through 变形同构体 (grantedBonds; `granted: true` and `items`: the item ids of the copy that * wears the pair — the card the row opens shows them; one row per operator — normal and elite copies are one member, * like the count's distinct members), so "成员 x/y" agrees with the count the server reports (在场; memberHeadCount). * 0.2.0 自选编队: the 自选 pieces of the bond (a DIY slot filled with an operator, whose bonds come from its factions — the * own pieces through `getChess` (gameLogic/diy.js diyGetter), a teammate's through `pieceRecord` (its UnitInfo `diy` * pick) are rows too (`diy: true`, the operator's name and record `rec`: its normal form, one row per slot like a * member's; `pick`: a teammate's piece's pick — the card the row opens composes the operator from it). Since 0.2.1 * (the owner's report of 2026-10-07 「自选编队的干员在局内对应盟约展开的名单里没有显示出来」) every 自选 pick of the * player (m.private.diy: the V and VI slots) is a row of each bond its operator carries, owned or not — an unowned one * (not bought, or not in the shop yet: the slot's 调度中心 level) dimmed like an unowned member, one whose bonds are all * off this match (m.private.diyBanned: no stock, it never shows in the shop) `banned` like a banned member. A * teammate's board carries no picks (m.public sends none): their 自选 pieces on the field only. * `inHand`: in the 整备区 (hand), not the 5 temporary slots — what BOARD_AND_DECK bonds (投资人 远见 奇迹) count. * @param {any} bond bonds.json record * @param {any} priv m.private (hand/board/temp, the 自选 picks `diy` / `diyBanned`) — or a teammate's field operators * (ui/watchBonds.js ownerBoard) * @param {Set|string[]} [banned] banned base chess ids * @param {(id:string)=>any} [getChess] * @param {(id:string)=>any} [getItem] items.json lookup (the 变形同构体 pairings) * @param {((p: any) => any)|null} [pieceRecord] the record of one of the pieces (default: getChess(p.id)) */ export function bondMembers(bond, priv, banned = [], getChess = () => null, getItem = () => null, pieceRecord = null) { const bannedSet = banned instanceof Set ? banned : new Set(Array.isArray(banned) ? banned : []); const members = Array.isArray(bond?.visibleMembers) && bond.visibleMembers.length ? bond.visibleMembers : (Array.isArray(bond?.members) ? bond.members : []); const baseOf = (id) => getChess(id)?.baseId || (typeof id === 'string' ? id.replace(/_b$/, '_a') : id); const onBoard = new Set(); const owned = new Set(); const inHand = new Set(); const memberSet = new Set(members); /** base id → { on: on the board?, hand: in the hand?, items: the wearer's item ids } — operators of the player that join this bond through 变形同构体 */ const granted = new Map(); const grants = (p) => typeof bond?.bondId === 'string' && grantedBonds(p.items, getItem).includes(bond.bondId); const recOf = (p) => (pieceRecord ? pieceRecord(p) : getChess(p.id)); /** a composed 自选 record whose operator carries this bond */ const diyOfBond = (rec) => isObj(rec) && typeof rec.diyFor === 'string' && Array.isArray(rec.bonds) && rec.bonds.includes(bond?.bondId); /** slot base id → { on, hand, owned, rec, pick } — the player's 自选 pieces and picks whose operator carries this bond */ const diy = new Map(); const diyOf = (p, on, hand) => { const rec = recOf(p); if (!diyOfBond(rec)) return false; const g = diy.get(rec.diyFor); if (g) { g.on = g.on || on; g.hand = g.hand || hand; return true; } // the row draws the normal form, as a member row its base chess (an elite copy is the same member) const pick = isObj(p.diy) && typeof p.diy.charId === 'string' ? p.diy : null; const normal = rec.isGolden ? recOf(pick ? { kind: 'chess', id: rec.diyFor, diy: pick } : { kind: 'chess', id: rec.diyFor }) : rec; diy.set(rec.diyFor, { on, hand, owned: true, rec: diyOfBond(normal) && normal.diyFor === rec.diyFor ? normal : rec, pick }); return true; }; const itemIds = (p) => p.items.map((it) => (typeof it === 'string' ? it : it?.id)).filter((x) => typeof x === 'string'); for (const p of Array.isArray(priv?.board) ? priv.board : []) { if (p?.kind !== 'chess') continue; const base = baseOf(p.id); onBoard.add(base); owned.add(base); if (diyOf(p, true, false)) continue; if (!memberSet.has(base) && !granted.has(base) && grants(p)) granted.set(base, { on: true, hand: false, items: itemIds(p) }); } for (const [list, hand] of [[priv?.hand, true], [priv?.temp, false]]) { for (const p of Array.isArray(list) ? list : []) { if (p?.kind !== 'chess') continue; const base = baseOf(p.id); owned.add(base); if (hand) inHand.add(base); if (diyOf(p, false, hand)) continue; if (memberSet.has(base) || !grants(p)) continue; const g = granted.get(base); if (!g) granted.set(base, { on: false, hand, items: itemIds(p) }); else if (hand) g.hand = true; } } // the player's picks not owned (the slot's record through the own getter: gameLogic/diy.js diyGetter) — rows like an // unowned member's (counted by neither 在场 nor the 成员 header) for (const slotId of Object.keys(diyPicks(priv))) { if (diy.has(slotId)) continue; const rec = recOf({ kind: 'chess', id: slotId }); if (diyOfBond(rec) && rec.diyFor === slotId) diy.set(slotId, { on: false, hand: false, owned: false, rec, pick: null }); } const diyBanned = new Set(Array.isArray(priv?.diyBanned) ? priv.diyBanned : []); const rows = members.map((id) => { const c = getChess(id); return { id, tier: c?.tier ?? 0, name: c?.name ?? id, onBoard: onBoard.has(id), owned: owned.has(id), inHand: inHand.has(id), banned: bannedSet.has(id) }; }); for (const [id, g] of granted) { const c = getChess(id); rows.push({ id, tier: c?.tier ?? 0, name: c?.name ?? id, onBoard: g.on, owned: true, inHand: g.hand, banned: false, granted: true, items: g.items }); } for (const [id, g] of diy) { rows.push({ id, tier: g.rec.tier ?? 0, name: g.rec.name ?? id, onBoard: g.on, owned: g.owned, inHand: g.hand, banned: bannedSet.has(id) || diyBanned.has(id), diy: true, rec: g.rec, ...(g.pick ? { pick: g.pick } : {}) }); } return rows.sort((a, b) => (b.onBoard - a.onBoard) || (b.owned - a.owned) || (a.tier - b.tier) || (a.id < b.id ? -1 : 1)); } /** * The member count of a bond popup's 成员 header: the rows on the board — and, for a bond that counts the hand * (`countsHand`: BOARD_AND_DECK — 投资人 远见 奇迹; the server's 在场 says (含整备区)), those in the hand too (the 5 * temporary slots never count), so the header agrees with 在场 (community report 「投资人等在休整区就能生效的盟约不生效」: * the header said 0/n with the members in the hand while 在场 said 3/3). * @param {Array<{ onBoard?: boolean, inHand?: boolean }>} rows bondMembers rows @param {boolean} [countsHand] * @returns {number} */ export function memberHeadCount(rows, countsHand = false) { let n = 0; for (const r of Array.isArray(rows) ? rows : []) if (r && (r.onBoard || (countsHand && r.inHand))) n++; return n; } /** * Banned-member count per bond (briefing / info drawer red badge). * @param {any[]} bonds bonds.json records * @param {string[]} bannedChess * @returns {Map} */ export function bannedPerBond(bonds, bannedChess) { const banned = new Set(Array.isArray(bannedChess) ? bannedChess : []); const out = new Map(); for (const b of Array.isArray(bonds) ? bonds : []) { if (!isObj(b)) continue; const members = Array.isArray(b.visibleMembers) ? b.visibleMembers : []; out.set(b.bondId, members.filter((id) => banned.has(id)).length); } return out; } /** * The two kinds of greyed bonds of a match (research 01 A2, 06 addendum D): * drawn — the per-match drawn set D (m.public drawnDisabledBonds): only operators whose EVERY bond is in D leave * the pool, so these bonds stay activatable (multi-bond members, 变形同构体, 调和) — "部分盟约所含干员阵容不完整"; * off — the mode's static inactive list (FUNNY): never activates this match — "本局禁用". * Older payloads without drawnDisabledBonds: disabledBonds minus the static list. * @param {any} pub m.public * @param {string[]} [staticInactive] config.modes[modeId].inactiveBondIds * @returns {{ drawn: Set, off: Set }} */ export function disabledBondSets(pub, staticInactive = []) { const off = new Set(Array.isArray(staticInactive) ? staticInactive : []); const src = Array.isArray(pub?.drawnDisabledBonds) ? pub.drawnDisabledBonds : Array.isArray(pub?.disabledBonds) ? pub.disabledBonds : []; const drawn = new Set(src.filter((b) => typeof b === 'string' && !off.has(b))); return { drawn, off }; } /** * Briefing tooltip of a bond disc. * @param {string} name * @param {'off'|'drawn'|null} state from disabledBondSets * @param {number} bannedN banned operators of the bond */ export function briefingBondTip(name, state, bannedN = 0) { if (state === 'off') return t('{name}:本局禁用(该盟约不会激活)', { name }); if (state === 'drawn' || bannedN > 0) return bannedN ? t('{name}:部分盟约所含干员阵容不完整({bannedN} 名干员无法出现)', { name, bannedN }) : t('{name}:部分盟约所含干员阵容不完整', { name }); return name; } /** * The bonds a mode never activates (config.json modes[modeId].inactiveBondIds = the official modeDataDict * inactiveBondIdList: 标准模拟 leaves 拉特兰 阿戈尔 卡西米尔 灵巧 奥术 奇迹 投资人 突袭 独行 绝技 off). Operators that also carry an * enabled bond stay in the pool (server pool.js drawDisabledBonds) — 标准's 深靛 洛洛 阿罗玛 夕 圣聆初雪 still show 奥术 — and * the server left such a bond out of m.private.bonds, so without a mark a card read "奥术 0/2 未激活" with three 奥术 * operators deployed (player report after 0.1.0: "奥术盟约不生效"). The shop / reward cards, the detail card's bond chips * and the bond popup mark these 本局禁用 (briefingBondTip 'off'); since 0.1.3 the server also lists such a bond the player * has members of (`off: true`, server/match/bondsMeta.js offBondCounts) and the strip shows it as a grey 本局禁用 disc. * @param {any} mode config.json modes[modeId] (data.js getMode), or null * @returns {Set} */ export function modeOffBonds(mode) { const list = isObj(mode) && Array.isArray(mode.inactiveBondIds) ? mode.inactiveBondIds : []; return new Set(list.filter((b) => typeof b === 'string')); } /** * The bonds a strategy is built around that this mode switches off: bands.json `bondIds` (shared/bandBonds.js at build * time — the field the server's bot reads too, GameData.bandBondIds) ∩ `off` (modeOffBonds). 标准: 潘格尼尼 → 拉特兰, * 克莱门莎 → 阿戈尔, 玛恩纳 → 卡西米尔; the strategy draft marks such a band 本局禁用 (still selectable, DESIGN §21.26). * @param {any} band bands.json record @param {Set|null} off * @returns {string[]} */ export function bandOffBonds(band, off) { if (!(off instanceof Set) || !off.size || !Array.isArray(band?.bondIds)) return []; return band.bondIds.filter((b) => typeof b === 'string' && off.has(b)); } /** * The strategy draft's note for such a band: "本局禁用【拉特兰】盟约,此策略效果可能无法发挥". * @param {string[]} names the switched-off bonds' names */ export function bandOffLine(names) { const list = (Array.isArray(names) ? names : []).filter((n) => typeof n === 'string' && n); return list.length ? t('本局禁用{names}盟约,此策略效果可能无法发挥', { names: list.map((n) => t('【{name}】', { name: n })).join('') }) : ''; }