Files
ark-stronghold-protocol/server/match/bondsMeta.js
T
sganggs 016fdb3804 feedback1 gaps: 变形同构体 lists its pairings; the bond popup explains 调和's +1
The 变形同构体 item card gets its 天赋 list (gameLogic morphPairings, from
the same giveBondId the server counts with): one line per bond with the
items that pair with it, 本局禁用 marked, and on a wearer's card the pairing
it wears highlighted. A bond item's card says which bond it gives a
变形同构体 wearer.

computeBonds marks a core bond state whose count holds 调和's +1
(harmony: 1) and both bond views send it. The popup reads it: 在场 n
(含调和 +1) and a 调和 row naming the 调和 operators on that board, for
the own strip and a teammate's; the strip and chip titles say it too.
The mock harness gets the funny / morph / harmony variants.
2026-10-03 15:07:14 +08:00

204 lines
9.5 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.
// server/match/bondsMeta.js — bond member counting, activation tiers and persistent layers (DESIGN §6.3,
// research 02 §2.1–§2.2).
//
// Counting modes (data/bonds.json `countMode`):
// BOARD distinct base chess (normal/elite of one operator count once) on the board carrying the bond
// BOARD_AND_DECK BOARD + the hand (整备区; the 5 temporary slots do not count) — 远见 / 奇迹 / 投资人
// BOARD_ALL_CHESS every elite (精锐) chess on the board, duplicates included — 绝技 (thresholdTemplate *_golden)
// Membership = the chess's own `bonds` + bonds granted by 变形同构体 (an item with `canGiveBond`) worn together with
// an item that has a `giveBondId` (research 02 §2.1).
// Special rules:
// 调和 (maniShip): while active, every CORE bond that already has ≥ 1 real member on the board gets count +1
// (one +1 total, research 02 §2.1 [ASSUMED]). Such a state carries `harmony: 1` — the views send it
// (bondList: m.private bonds, m.public players[].bonds), so the client's bond popup can say where the
// extra count comes from without re-deriving the rule (DESIGN §21.26); the battle input does not
// (bondSnapshot).
// 助力 (deputShip): tier 1 needs `thresholds[0]` distinct operators; the upper tiers count operators that differ
// in name OR elite state (PRTS 修正).
// 独行 (soloShip, count_threshold_downward): active iff 1 ≤ count ≤ maxCount (distinct 独行 operators).
// Bonds in the mode's static inactive list (FUNNY) never activate and are omitted.
// `tier` = number of thresholds reached (downward: 1 when active); `active = tier ≥ 1`.
// Layers (`ps.layers[bondId]`) persist the whole match; they are reported for every bond but only matter while active.
import { layerGainRoom } from '../../shared/constants.js';
export const HARMONY_BOND = 'maniShip';
export const DEPUTY_BOND = 'deputShip';
/**
* Bonds of a chess piece: own bonds ∪ bonds granted by 变形同构体 pairings.
* @param {import('./gamedata.js').GameData} gd
* @param {{ id: string, items?: Array<{ id: string }> }} piece
* @returns {string[]}
*/
export function pieceBonds(gd, piece) {
const c = gd.chess(piece.id);
const out = c && Array.isArray(c.bonds) ? c.bonds.slice() : [];
const items = Array.isArray(piece.items) ? piece.items : [];
if (items.length >= 2) {
const recs = items.map((it) => gd.item(it.id)).filter(Boolean);
if (recs.some((r) => r.canGiveBond)) {
for (const r of recs) {
if (r.canGiveBond) continue;
if (typeof r.giveBondId === 'string' && gd.bond(r.giveBondId) && !out.includes(r.giveBondId)) out.push(r.giveBondId);
}
}
}
return out;
}
/**
* Compute every bond's state for a player. `harmony` (only present when it applies) = the +1 that 调和 added to `count`.
* @param {import('./gamedata.js').GameData} gd
* @param {{ board: Map<string, any>, hand: Array<any>, layers: Record<string, number>, bondCountBonus?: Record<string, number> }} ps
* @returns {Record<string, { count: number, active: boolean, tier: number, layers: number, harmony?: number }>}
*/
export function computeBonds(gd, ps) {
const boardChess = [];
for (const p of ps.board.values()) if (p && p.kind === 'chess') boardChess.push(p);
const handChess = ps.hand.filter((p) => p && p.kind === 'chess');
/** bondId → Set(baseId) on board / hand; bondId → Set(baseId|golden) on board */
const onBoard = new Map();
const onBoardVariant = new Map();
const inHand = new Map();
const add = (m, bond, key) => { let s = m.get(bond); if (!s) m.set(bond, (s = new Set())); s.add(key); };
for (const p of boardChess) {
const base = gd.baseIdOf(p.id);
const golden = gd.isGolden(p.id);
for (const b of pieceBonds(gd, p)) { add(onBoard, b, base); add(onBoardVariant, b, `${base}|${golden ? 1 : 0}`); }
}
for (const p of handChess) {
const base = gd.baseIdOf(p.id);
for (const b of pieceBonds(gd, p)) add(inHand, b, base);
}
const goldenOnBoard = boardChess.filter((p) => gd.isGolden(p.id)).length;
/** @type {Record<string, { count: number, active: boolean, tier: number, layers: number, harmony?: number }>} */
const out = {};
const layersOf = (id) => {
const v = ps.layers && ps.layers[id];
return Number.isFinite(v) && v > 0 ? v : 0;
};
const bonus = ps.bondCountBonus || {};
const bonusOf = (id) => (Number.isInteger(bonus[id]) ? bonus[id] : 0);
// first pass: raw counts
const raw = {};
for (const id of gd.bondIds) {
if (gd.modeInactiveBonds.has(id)) continue;
const bond = gd.bond(id);
let count;
if (bond.countMode === 'BOARD_ALL_CHESS' || bond.thresholdTemplate === 'count_threshold_upward_golden') {
count = goldenOnBoard;
} else if (bond.countMode === 'BOARD_AND_DECK') {
const s = new Set([...(onBoard.get(id) || []), ...(inHand.get(id) || [])]);
count = s.size;
} else {
count = (onBoard.get(id) || new Set()).size;
}
raw[id] = Math.max(0, count + bonusOf(id));
}
// 调和: +1 to core bonds with ≥ 1 real board member while it is active
const harmony = gd.bond(HARMONY_BOND);
const harmonyActive = harmony && raw[HARMONY_BOND] != null && tierFor(harmony, raw[HARMONY_BOND]) >= 1;
for (const id of Object.keys(raw)) {
const bond = gd.bond(id);
const harmonyBonus = harmonyActive && bond.isCore && (onBoard.get(id)?.size ?? 0) >= 1 ? 1 : 0;
const count = raw[id] + harmonyBonus;
let tier = tierFor(bond, count);
if (id === DEPUTY_BOND && tier >= 1) {
// upper tiers: operators differing in name OR elite state
const variants = (onBoardVariant.get(id)?.size ?? 0) + bonusOf(id);
const th = thresholdsOf(bond);
let t = 1;
for (let i = 1; i < th.length; i++) if (variants >= th[i]) t = i + 1;
tier = t;
}
out[id] = { count, active: tier >= 1, tier, layers: layersOf(id) };
if (harmonyBonus) out[id].harmony = harmonyBonus;
}
return out;
}
/** Ascending member-count thresholds of a bond (fallback: activeCount). */
export function thresholdsOf(bond) {
if (Array.isArray(bond.thresholds) && bond.thresholds.length) return bond.thresholds.filter((n) => Number.isFinite(n)).sort((a, b) => a - b);
if (Number.isFinite(bond.activeCount)) return [bond.activeCount];
return [2];
}
/** Tier for a member count (downward bonds: 1 while 1 ≤ count ≤ maxCount). */
export function tierFor(bond, count) {
const th = thresholdsOf(bond);
if (bond.thresholdTemplate === 'count_threshold_downward') {
const max = Number.isFinite(bond.maxCount) ? bond.maxCount : th[0];
return count >= th[0] && count <= max ? 1 : 0;
}
let t = 0;
for (const n of th) if (count >= n) t++;
return t;
}
/** Σ layers over active bonds (hidden-core check, 不朽盟约 title). */
export function activatedLayers(bonds) {
let n = 0;
for (const b of Object.values(bonds || {})) if (b.active) n += b.layers || 0;
return n;
}
/**
* View list for m.private / m.public: bonds with members or layers, active first, then layers desc, then data order.
* An entry whose count includes 调和's +1 carries `harmony: 1` (both views; absent otherwise).
* @param {import('./gamedata.js').GameData} gd
* @param {ReturnType<typeof computeBonds>} bonds
* @param {{ full?: boolean }} [opts] full → include thresholds/countsHand (m.private)
*/
export function bondList(gd, bonds, { full = false } = {}) {
const order = new Map(gd.bondIds.map((id, i) => [id, i]));
const list = [];
for (const [bondId, b] of Object.entries(bonds || {})) {
if (!(b.count > 0 || b.layers > 0 || b.active)) continue;
const e = { bondId, count: b.count, active: b.active, tier: b.tier, layers: b.layers };
if (b.harmony > 0) e.harmony = b.harmony;
if (full) {
const bond = gd.bond(bondId);
e.thresholds = bond ? thresholdsOf(bond) : [];
e.countsHand = !!(bond && bond.countMode === 'BOARD_AND_DECK');
}
list.push(e);
}
list.sort((a, b) => (b.active - a.active) || (b.layers - a.layers) || ((order.get(a.bondId) ?? 99) - (order.get(b.bondId) ?? 99)));
return list;
}
/**
* The bond states the views show (m.private / m.public players[].bonds, DESIGN §20.15): `bonds` with this round's
* IN_BATTLE gains of a finished normal battle (`gains` = the result's layerGains, PlayerState.pendingLayerGains, set
* when the COMBAT phase ends) added the way settle() will add them — at most up to BOND_LAYER_CAP — so the strip of a
* player (and of a teammate watching him) keeps the layers his battle reached through the 联防 until the settlement
* makes them persistent. `bonds` itself when there is nothing to add; the persistent state is never touched.
* @param {ReturnType<typeof computeBonds>} bonds
* @param {Record<string, number>|null|undefined} gains
*/
export function bondsWithGains(bonds, gains) {
if (!gains || typeof gains !== 'object') return bonds;
let out = null;
for (const [id, n] of Object.entries(gains)) {
const b = bonds && bonds[id];
if (!b) continue;
const add = layerGainRoom(b.layers, Math.floor(Number(n) || 0));
if (!(add > 0)) continue;
if (!out) out = { ...bonds };
out[id] = { ...b, layers: (b.layers || 0) + add };
}
return out || bonds;
}
/** Snapshot for PlayerBattleInput.bonds: { [bondId]: { count, active, tier, layers } } (plain copy). */
export function bondSnapshot(bonds) {
const o = {};
for (const [id, b] of Object.entries(bonds || {})) o[id] = { count: b.count, active: b.active, tier: b.tier, layers: b.layers };
return o;
}