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.
302 lines
16 KiB
JavaScript
302 lines
16 KiB
JavaScript
// 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('')}盟约,此策略效果可能无法发挥` : '';
|
||
}
|