Files
ark-stronghold-protocol/public/js/ui/gameLogic/bonds.js
T
sganggs 0def7babfd feedback5 WK: 自选编队 in the browser — the 干员调配 overlay's third tab 自选编队 (5阶 ×2, 6阶 ×2 slots; the slot picker lists the kitted picks of the tier with avatar, name, class, derived bonds and a 原型 / 已持有 tag; an owned pick chooses its skill and module — 集成战略 modules left out —, a prototype shows its locked skill; sp.pref.diy, export / import, 全部清空, synced as room.diy through the shared engine, welcome.diyKitted kept), the operator on shop / reward cards, own pieces (「自选」 tag, its model on bench and board), the detail card (pick's skill and module), placement and range previews, bond popups (own and a teammate's 自选 member), the result screen; new UI strings through t() with English; the e2e fast server's SP_START_LEVEL hook
Bonds of 自选 pieces checked everywhere membership is decided (the owner's reminder of 2026-10-05): the match's counts through the player's data view (煌 in 炎 and 维多利亚, one member per slot, 绝技 elites), the sim's bond effects through def.bonds (叙拉古 on deployment, 协防 ×1.2 / elite ×1.4), the client's strip, popups, cards and detail card. Golden unchanged.
Tests: test/ui/diy-ui.test.js (15), test/match/diy-bonds.test.js (4), test/i18n.test.js pins screens/diy.js; test/ui/diy.e2e.test.js (SP_E2E=1, run once: pass).
2026-10-06 00:13:31 +08:00

319 lines
18 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).
* 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`).
* `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)
* @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);
/** base id → { on, hand, rec } — the player's 自选 pieces whose operator carries this bond */
const diy = new Map();
const diyOf = (p, on, hand) => {
const rec = pieceRecord ? pieceRecord(p) : getChess(p.id);
if (!rec || typeof rec.diyFor !== 'string' || !Array.isArray(rec.bonds) || !rec.bonds.includes(bond?.bondId)) return false;
const g = diy.get(rec.diyFor);
if (!g) diy.set(rec.diyFor, { on, hand, rec });
else { g.on = g.on || on; g.hand = g.hand || hand; }
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;
}
}
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: true, inHand: g.hand, banned: false, diy: true, rec: g.rec });
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('')}盟约,此策略效果可能无法发挥` : '';
}