Files
ark-stronghold-protocol/public/js/ui/gameLogic/bonds.js
T
sganggs eb04f5dfa4 feedback5 R5: split the in-match UI logic into modules
public/js/ui/gameLogic.js becomes a re-export barrel. The bodies move verbatim into public/js/ui/gameLogic/*.js (shared, phases, camera, watch, bonds, shop, placement, draft, enemies, format, shortcuts, settings, result, loadout, panel). The barrel re-exports the same 96 names in the same order. isObj and int gain export on shared.js so sibling modules can import them; they stay off the barrel. No importer changes.
2026-10-05 17:06:45 +08:00

302 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ui/gameLogic/bonds.js — bond counts, members, harmony, 本局禁用. Re-exported from ../gameLogic.js.
import { int, isObj } from './shared.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<string|{id:string}>|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<string|{id:string}>|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<any>} itemRecs items.json records (data.list('items'))
* @param {Iterable<any>} bondRecs bonds.json records in data order (data.list('bonds'))
* @param {{ off?: Set<string>|null, carried?: Array<string|{id:string}>|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).
* `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) — or a teammate's field operators (ui/watchBonds.js ownerBoard)
* @param {Set<string>|string[]} [banned] banned base chess ids
* @param {(id:string)=>any} [getChess]
* @param {(id:string)=>any} [getItem] items.json lookup (the 变形同构体 pairings)
*/
export function bondMembers(bond, priv, banned = [], getChess = () => null, getItem = () => 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 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 (!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 (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;
}
}
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 });
}
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<string, number>}
*/
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<string>, off: Set<string> }}
*/
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 `${name}:本局禁用(该盟约不会激活)`;
if (state === 'drawn' || bannedN > 0) return `${name}:部分盟约所含干员阵容不完整${bannedN ? `(${bannedN} 名干员无法出现)` : ''}`;
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<string>}
*/
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<string>|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 ? `本局禁用${list.map((n) => `【${n}】`).join('')}盟约,此策略效果可能无法发挥` : '';
}