diff --git a/docs/DESIGN.md b/docs/DESIGN.md
index 3c6b324a..ab667a55 100644
--- a/docs/DESIGN.md
+++ b/docs/DESIGN.md
@@ -440,7 +440,7 @@ S→C:
temp: [ Piece | null ], // length 5
board: [ Piece & { row, col, dir } ], // dir ∈ UP|RIGHT|DOWN|LEFT (§3; bench pieces carry none)
deployCap, deployCount,
- bonds: [ { bondId, count, active, tier, layers, harmony? /* 调和's +1 is in count, §21.26 */, thresholds, countsHand } ], // sorted: active first, then layers desc
+ bonds: [ { bondId, count, active, tier, layers, harmony? /* 调和's +1 is in count, §21.26 */, thresholds, countsHand } ], // sorted: active first, then layers desc; an `off` entry (a bond the mode never activates, with members: count only, never active — the strip's grey 本局禁用 disc, bondsMeta.offBondCounts) comes last
effects: [ { id, name, desc, iconKind: 'band'|'choice'|'team'|'item'|'garrison', iconId, counter?, counterText? /* a bounty: 还剩 N 场作战, §20.6 */ } ],
nextEnemies: [ { enemyKey, count, tag, start? } ], // preview of the upcoming round's wave (after waves.js generation); `start` = a leader's spawn tile [row, col] on the boss field (the boss-field prep shows it standing there, community report #12)
stats: { dmgDealt, kills, leaks, gold, refreshes, merges } }
@@ -1109,7 +1109,7 @@ In SETTLE it is the field the last battle left on screen. In prep without watchi
- This is views only: `ps.bonds` / `ps.layers` are untouched. SETTLE clears the pending gains as it adds them (once), and the next round start clears them too.
**Data:**
-- `m.public players[].bonds = ps.alive ? bondList(gd, ps.bondsView()) : []`: every bond with members, layers or an active tier. It is the same list and order as the player's own `m.private bonds`, minus `thresholds` / `countsHand` (the client reads those from bonds.json).
+- `m.public players[].bonds = ps.alive ? bondList(gd, ps.bondsView(), { off: offBondCounts(gd, ps) }) : []`: every bond with members, layers or an active tier (since 0.1.3 also the mode-off bonds with members, `off: true`, last). It is the same list and order as the player's own `m.private bonds`, minus `thresholds` / `countsHand` (the client reads those from bonds.json).
- An eliminated player's list is `[]`, for these reasons:
- nobody can watch them: `g.watch` refuses them and they have no field;
- the result screen reads `m.result`'s own bonds;
diff --git a/docs/META.md b/docs/META.md
index 3aa85aac..e1d7571c 100644
--- a/docs/META.md
+++ b/docs/META.md
@@ -683,7 +683,9 @@ drawn set; `disabledBonds` = drawn ∪ the mode's static list), `hiddenBossId`,
`combatMode` (`'client'` | `'server'`), `fields[].progress { killed, total, done }` (teammates' progress UI), `paused`
(solo pause, §1.3a),
`players[].autoplay`, `players[].uniteLeft` (UNITE, leakers only: their enemies still standing, uncapped — §4),
-`players[].bonds` = `ps.alive ? bondList(gd, ps.bondsView()) : []` — every bond with members, layers or an active tier, the same list
+`players[].bonds` = `ps.alive ? bondList(gd, ps.bondsView(), { off: offBondCounts(gd, ps) }) : []` — every bond with members, layers or an active tier
+(and, last, every bond the mode never activates that the player has members of: `{ bondId, count, active: false, tier: 0, layers, off: true }`,
+the strip's grey 本局禁用 disc — bondsMeta.offBondCounts, never in the battle input), the same list
and order as the player's own `m.private bonds` minus `thresholds` / `countsHand` (the client reads those from
bonds.json; an entry whose count holds 调和's +1 carries `harmony: 1` in both lists — the bond popup's 调和 row, DESIGN
§21.26): a teammate watching the player shows it in the bond strip (DESIGN §20.15); `[]` once the player is
diff --git a/docs/PLAYING.md b/docs/PLAYING.md
index 1bb11995..54961076 100644
--- a/docs/PLAYING.md
+++ b/docs/PLAYING.md
@@ -96,6 +96,7 @@
- **核心盟约**(8 个势力:炎、萨尔贡、维多利亚、谢拉格、拉特兰、阿戈尔、叙拉古、卡西米尔):3 名激活,6 名获得强力的额外效果。
- **附加盟约**(精准、迅捷、灵巧、奥术、坚守、助力、远见、奇迹、投资人、突袭、不屈、调和、协防干员、独行、绝技):多为 2 / 3 名激活;「独行」需要场上**恰好 1 名**,「绝技」按场上的精锐数量计算,没有势力盟约的干员算「协防干员」。
+- **本局禁用**:「标准模拟」与官方相同,关闭拉特兰、阿戈尔、卡西米尔、灵巧、奥术、奇迹、投资人、突袭、独行、绝技这 10 个盟约,它们不会激活。你有这些盟约的成员时(奇迹、投资人也算整备区里的成员),盟约栏末尾显示**灰色带 ✕ 的盟约图标和「本局禁用」**,干员卡、盟约标签和盟约详情也这样标注。「远见」「奇迹」「投资人」计入整备区,盟约详情的「成员」人数也一样计入整备区里的成员(临时整备区不算)。
- **调和**:激活时,场上已有成员的核心盟约人数各 +1(只加 1 人,不论有几名调和干员)。盟约详情会写「含调和 +1」,成员列表上方列出带来这 1 人的调和干员(如缪尔赛思)。
- **炎佑**:场上 6 名以上「炎」干员时召唤炎佑(9 名时两只)。它追踪全场仇恨最高的敌人并悬停在其上方,技能「祛恶之焰」锁定 1 个目标**持续喷火**(最长 20 秒,每秒对目标周围的敌人造成法术伤害并附带灼燃,喷火时不普攻,目标死亡或被沉默时结束);场上暂时没有敌人时停在原地等待。它持有官方的「孤立」:医疗干员不会治疗它,友方的增益、光环和天赋也不会选中它(敌人照常可以攻击它)。
- **层数**:盟约通过各种效果叠加层数,层数越高效果越强(例如「炎」攻击力随层数提升、「远见」每 10 层给资金)。**层数整局保留,但只有盟约激活时才生效**,多数情况下也只有激活的盟约能叠层。每个盟约最多 **999 层**(与官方相同,取自官方客户端的常量):到 999 后再获得的层数直接作废,按层数发放的奖励(如「远见」的资金)也随之停止。作战中获得的层数在盟约栏里**实时**显示,作战结束后一直保留到结算(联防期间也不会退回去)。
diff --git a/public/css/screens/game.css b/public/css/screens/game.css
index 869f65ff..cb101c1a 100644
--- a/public/css/screens/game.css
+++ b/public/css/screens/game.css
@@ -256,6 +256,13 @@
.bslot__count.is-max { border-color: var(--mint-700); color: var(--mint-400); }
.bslot.is-active .bslot__count { border-color: var(--mint-700); }
.bslot.is-open .bond__core { box-shadow: 0 0 0 2px var(--mint-400), 0 0 .2rem rgba(23, 249, 183, .6); }
+/* a bond the mode never activates, with members (the server's `off` entry): the briefing's grey ✕ disc, the chips'
+ struck name, 本局禁用 under it instead of the count */
+.bslot.is-off .bond__name { color: var(--text-dim); text-decoration: line-through; text-decoration-color: rgba(255, 84, 84, .6); }
+.bslot.is-off .bslot__off {
+ position: static; margin-top: .02rem; font-size: .11rem; font-weight: 500; color: var(--text-dim); white-space: nowrap;
+ background: rgba(8, 11, 10, .85); border: 1px dashed rgba(255, 255, 255, .25);
+}
/* ---- team panel ------------------------------------------------------------------------------------------- */
.team { position: absolute; left: .22rem; top: 2.36rem; display: flex; flex-direction: column; gap: .14rem; pointer-events: none; }
diff --git a/public/js/ui/bondStrip.js b/public/js/ui/bondStrip.js
index 2f9c7fb6..2d331d72 100644
--- a/public/js/ui/bondStrip.js
+++ b/public/js/ui/bondStrip.js
@@ -9,14 +9,19 @@
// the +1 used to read as a miscount (GitHub issue #1, DESIGN §21.26). Opened from the detail panel's bond chips it docks
// beside that panel (`beside`: the panel's side).
// Research 06 §11.1: round mint discs, stack count over the disc, name below, sorted by stacks; grey =
-// present but inactive; in 联防 / boss rounds the strip is dimmed ("层数叠加已禁用").
+// present but inactive; in 联防 / boss rounds the strip is dimmed ("层数叠加已禁用"). A bond the mode never activates
+// that the player has members of (the server's `off` entry — server/match/bondsMeta.js offBondCounts: 标准's 投资人 奇迹
+// 突袭 独行 …) comes last as a grey disc with ✕ and 本局禁用 under it instead of its count (community reports
+// 「投资人等在休整区就能生效的盟约不生效」 / 「…不会触发斯卡蒂与异德的突袭」: the bond just vanished, 0.1.3). The popup's 成员
+// header counts the hand members too for a bond that counts the hand (投资人 远见 奇迹: gameLogic memberHeadCount), as 在场
+// already did.
// Watching a teammate (DESIGN §20.15, ui/watchBonds.js) the strip and the popup show THAT player's bonds and layers:
// the strip carries an amber "👁 name" tag (`owner`, the observing pill's spelling, research 09 §3.1) and amber rings,
// the popup a "👁 name 的盟约" line; its member list reads the teammate's operators on the field (no hand: never sent).
import { html, BondDisc, Icon, MicroLabel, Tooltip } from './components.js';
import { RichText, UnitThumb, BondGlyph, GIcon } from './gameComponents.js';
-import { sortBonds, bondMembers, nextThreshold, bondTier, harmonyMembers, HARMONY_BOND } from './gameLogic.js';
+import { sortBonds, bondMembers, nextThreshold, bondTier, harmonyMembers, HARMONY_BOND, memberHeadCount, briefingBondTip } from './gameLogic.js';
import { formatBondEffect } from './richText.js';
import { bondIconUrl } from './assetUrls.js';
import { data } from '../data.js';
@@ -48,6 +53,17 @@ export function BondStrip({ bonds, layersDisabled = false, onOpen, openId = null
${shown.map((b) => {
const rec = data.lookup('bonds', b.bondId);
const th = Array.isArray(b.thresholds) && b.thresholds.length ? b.thresholds : rec?.thresholds || [];
+ if (b.off) {
+ // a bond the mode never activates, with members: the briefing's grey ✕ disc, no stack count, 本局禁用 under it
+ const name = rec?.name || b.bondId;
+ return html`
${harmony ? (harmonyBy.length
? html``
diff --git a/public/js/ui/gameLogic.js b/public/js/ui/gameLogic.js
index 93d1d9c8..7f730ef3 100644
--- a/public/js/ui/gameLogic.js
+++ b/public/js/ui/gameLogic.js
@@ -367,8 +367,10 @@ export function activeBubbles(emotes, now, ttl = 3000) {
// ---- bonds -------------------------------------------------------------------------------------------
/**
- * Sort bonds for the strip: active first, then layers desc, count desc, tier desc, core first, id.
- * @template {{bondId:string, active?:boolean, layers?:number, count?:number, tier?:number}} B
+ * 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[]}
@@ -377,7 +379,8 @@ 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) => (
- (b.active ? 1 : 0) - (a.active ? 1 : 0)
+ (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)
@@ -509,10 +512,11 @@ export function harmonyMembers(priv, getChess = () => null) {
}
/**
- * Member rows of a bond popup: every visible member with owned / on-board / 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 (在场).
+ * 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[]} [banned] banned base chess ids
@@ -525,8 +529,9 @@ export function bondMembers(bond, priv, banned = [], getChess = () => null, getI
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?, items: the wearer's item ids } — operators of the player that join this bond through 变形同构体 */
+ /** 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');
@@ -534,25 +539,45 @@ export function bondMembers(bond, priv, banned = [], getChess = () => null, getI
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, items: itemIds(p) });
+ if (!memberSet.has(base) && !granted.has(base) && grants(p)) granted.set(base, { on: true, hand: false, items: itemIds(p) });
}
- for (const p of [...(Array.isArray(priv?.hand) ? priv.hand : []), ...(Array.isArray(priv?.temp) ? priv.temp : [])]) {
- if (p?.kind !== 'chess') continue;
- const base = baseOf(p.id);
- owned.add(base);
- if (!memberSet.has(base) && !granted.has(base) && grants(p)) granted.set(base, { on: 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), banned: bannedSet.has(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, banned: false, granted: true, items: g.items });
+ 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
@@ -603,9 +628,10 @@ export function briefingBondTip(name, state, bannedN = 0) {
* 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 leaves such a bond out of m.private.bonds, so without a mark a card read "奥术 0/2 未激活" with three 奥术
+ * 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').
+ * 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}
*/
diff --git a/server/match/Match.js b/server/match/Match.js
index 683542a4..f699b567 100644
--- a/server/match/Match.js
+++ b/server/match/Match.js
@@ -139,7 +139,7 @@ import { RealScheduler } from './scheduler.js';
import { SharedPool, drawDisabledBonds } from './pool.js';
import { PlayerState } from './PlayerState.js';
import { buildDeployMap, boardOrder, pieceDir } from './board.js';
-import { bondList } from './bondsMeta.js';
+import { bondList, offBondCounts } from './bondsMeta.js';
import { EffectDispatcher, getDefaultRegistry } from './effectsMeta.js';
import { generateDraft, applyCard, cardView, bountyBattles, isMultiRoundBounty } from './choices.js';
import { setupMatchWaves, buildNormalWave, buildBossWave, bountySpawns, withBounties, previewOf, weightedPick } from './waves.js';
@@ -892,7 +892,8 @@ export class Match {
// this round's in-battle gains included once the COMBAT phase ended (PlayerState.bondsView); [] once eliminated —
// nobody can watch an eliminated player (g.watch refuses them, they have no field) and the result screen reads
// m.result's own bonds, so their layers would only cost every m.public bytes for the rest of the match
- bonds: ps.alive ? bondList(this.gd, ps.bondsView()) : [],
+ // (the mode-off bonds with members included, `off: true`, as in m.private — bondsMeta.offBondCounts)
+ bonds: ps.alive ? bondList(this.gd, ps.bondsView(), { off: offBondCounts(this.gd, ps) }) : [],
fieldId: this.fieldOf(ps),
status: this.statusOf(ps),
autoplay: ps.autoplay,
diff --git a/server/match/PlayerState.js b/server/match/PlayerState.js
index afcdd4ac..22b38bea 100644
--- a/server/match/PlayerState.js
+++ b/server/match/PlayerState.js
@@ -75,7 +75,7 @@ import { checkLoadout, resolveLoadout } from '../../shared/protocol.js';
import { FIELD, tileKey, parseKey, inField, canPlace, positionClass, boardOrder, freeSlot, pieceDir, parseDir, mergeTile, ownerRangeKeys } from './board.js';
import { attackRangeGrid, loadoutRecord, resolveRecordLoadout } from '../../shared/loadoutRecord.js';
import { offsetTile } from '../sim/dir.js';
-import { computeBonds, bondList, bondSnapshot, activatedLayers, bondsWithGains } from './bondsMeta.js';
+import { computeBonds, bondList, bondSnapshot, activatedLayers, bondsWithGains, offBondCounts } from './bondsMeta.js';
import { itemKey } from './gamedata.js';
import { bountyText } from './choices.js';
@@ -1642,7 +1642,8 @@ export class PlayerState {
board,
deployCap: this.deployCap,
deployCount: this.deployCount,
- bonds: bondList(this.gd, this.bondsView(), { full: true }),
+ // + the mode-off bonds it has members of (`off: true`, the strip's grey 本局禁用 discs — bondsMeta.offBondCounts)
+ bonds: bondList(this.gd, this.bondsView(), { full: true, off: offBondCounts(this.gd, this) }),
effects: this.effectsView(),
nextEnemies: this.m.nextEnemiesFor(this),
// DESIGN §16: the effective operator loadout ({ [baseChessId]: { skill, module } }; chess not listed use defaults)
diff --git a/server/match/bondsMeta.js b/server/match/bondsMeta.js
index c068c112..1a0c7c3a 100644
--- a/server/match/bondsMeta.js
+++ b/server/match/bondsMeta.js
@@ -16,7 +16,10 @@
// 助力 (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.
+// Bonds in the mode's static inactive list (FUNNY) never activate and are omitted from computeBonds (the battle
+// input never sees them); the views list the ones the player has members of as `off: true`
+// (offBondCounts → bondList: count only, never active, after the others) so the strip can say
+// 本局禁用 (community reports 「投资人…不生效」 / 「…不会触发斯卡蒂与异德的突袭」, 0.1.3).
// `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.
@@ -48,17 +51,13 @@ export function pieceBonds(gd, piece) {
}
/**
- * 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, hand: Array, layers: Record, bondCountBonus?: Record }} ps
- * @returns {Record}
+ * Who carries which bond (computeBonds' and offBondCounts' shared first step): bondId → Set(baseId) on the board / in
+ * the hand (the 5 temporary slots never count), bondId → Set(baseId|golden) on the board, and the elite chess on the board.
*/
-export function computeBonds(gd, ps) {
+function membership(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();
@@ -72,14 +71,43 @@ export function computeBonds(gd, ps) {
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;
+ return { onBoard, onBoardVariant, inHand, goldenOnBoard: boardChess.filter((p) => gd.isGolden(p.id)).length };
+}
+
+/** A bond's member count by its counting mode (before 调和's +1), with the effects' per-bond count bonus. */
+function rawCount(gd, id, mem, ps) {
+ const bond = gd.bond(id);
+ let count;
+ if (bond.countMode === 'BOARD_ALL_CHESS' || bond.thresholdTemplate === 'count_threshold_upward_golden') {
+ count = mem.goldenOnBoard;
+ } else if (bond.countMode === 'BOARD_AND_DECK') {
+ const s = new Set([...(mem.onBoard.get(id) || []), ...(mem.inHand.get(id) || [])]);
+ count = s.size;
+ } else {
+ count = (mem.onBoard.get(id) || new Set()).size;
+ }
+ const bonus = ps.bondCountBonus || {};
+ return Math.max(0, count + (Number.isInteger(bonus[id]) ? bonus[id] : 0));
+}
+
+const layersIn = (ps, id) => {
+ const v = ps.layers && ps.layers[id];
+ return Number.isFinite(v) && v > 0 ? v : 0;
+};
+
+/**
+ * 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, hand: Array, layers: Record, bondCountBonus?: Record }} ps
+ * @returns {Record}
+ */
+export function computeBonds(gd, ps) {
+ const mem = membership(gd, ps);
+ const { onBoard, onBoardVariant } = mem;
/** @type {Record} */
const out = {};
- const layersOf = (id) => {
- const v = ps.layers && ps.layers[id];
- return Number.isFinite(v) && v > 0 ? v : 0;
- };
+ const layersOf = (id) => layersIn(ps, id);
const bonus = ps.bondCountBonus || {};
const bonusOf = (id) => (Number.isInteger(bonus[id]) ? bonus[id] : 0);
@@ -87,17 +115,7 @@ export function computeBonds(gd, ps) {
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));
+ raw[id] = rawCount(gd, id, mem, ps);
}
// 调和: +1 to core bonds with ≥ 1 real board member while it is active
const harmony = gd.bond(HARMONY_BOND);
@@ -121,6 +139,28 @@ export function computeBonds(gd, ps) {
return out;
}
+/**
+ * The bonds this mode never activates (the static inactive list — 标准模拟's 拉特兰 阿戈尔 卡西米尔 灵巧 奥术 奇迹 投资人 突袭
+ * 独行 绝技) that the player has members of, counted like computeBonds would (投资人 / 奇迹 with the hand): { [bondId]:
+ * { count, layers } }, null when the mode switches nothing off. Only the views use it (bondList `off`) — the strip shows
+ * such a bond as a grey 本局禁用 disc instead of leaving the player to wonder why it does nothing (0.1.3).
+ * @param {import('./gamedata.js').GameData} gd
+ * @param {{ board: Map, hand: Array, layers: Record, bondCountBonus?: Record }} ps
+ * @returns {Record|null}
+ */
+export function offBondCounts(gd, ps) {
+ if (!gd.modeInactiveBonds || !gd.modeInactiveBonds.size) return null;
+ const mem = membership(gd, ps);
+ let out = null;
+ for (const id of gd.bondIds) {
+ if (!gd.modeInactiveBonds.has(id)) continue;
+ const count = rawCount(gd, id, mem, ps);
+ if (!(count > 0)) continue;
+ (out ??= {})[id] = { count, layers: layersIn(ps, id) };
+ }
+ 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);
@@ -149,26 +189,37 @@ export function activatedLayers(bonds) {
/**
* 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).
+ * An entry whose count includes 调和's +1 carries `harmony: 1` (both views; absent otherwise). `off` (offBondCounts): the
+ * mode-off bonds the player has members of follow, in data order, as `{ bondId, count, active: false, tier: 0, layers,
+ * off: true }` — the strip's grey 本局禁用 discs; never in computeBonds, so never in the battle input.
* @param {import('./gamedata.js').GameData} gd
* @param {ReturnType} bonds
- * @param {{ full?: boolean }} [opts] full → include thresholds/countsHand (m.private)
+ * @param {{ full?: boolean, off?: ReturnType }} [opts] full → include thresholds/countsHand (m.private)
*/
-export function bondList(gd, bonds, { full = false } = {}) {
+export function bondList(gd, bonds, { full = false, off = null } = {}) {
const order = new Map(gd.bondIds.map((id, i) => [id, i]));
const list = [];
+ const extras = (e) => {
+ if (!full) return e;
+ const bond = gd.bond(e.bondId);
+ e.thresholds = bond ? thresholdsOf(bond) : [];
+ e.countsHand = !!(bond && bond.countMode === 'BOARD_AND_DECK');
+ return e;
+ };
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.push(extras(e));
}
list.sort((a, b) => (b.active - a.active) || (b.layers - a.layers) || ((order.get(a.bondId) ?? 99) - (order.get(b.bondId) ?? 99)));
+ if (off) {
+ for (const bondId of gd.bondIds) {
+ const o = off[bondId];
+ if (!o || !(o.count > 0) || (bonds && bonds[bondId])) continue;
+ list.push(extras({ bondId, count: o.count, active: false, tier: 0, layers: Number.isFinite(o.layers) && o.layers > 0 ? o.layers : 0, off: true }));
+ }
+ }
return list;
}
diff --git a/test/match/feedback3-off-bonds.test.js b/test/match/feedback3-off-bonds.test.js
new file mode 100644
index 00000000..b0e63df2
--- /dev/null
+++ b/test/match/feedback3-off-bonds.test.js
@@ -0,0 +1,91 @@
+// Mode-off bonds stay out of the battle (computeBonds) and show in the views when the player has members
+// (offBondCounts → bondList `off: true`, the strip's grey 本局禁用 discs). 标准模拟's inactiveBondIdList
+// (activity_table mode_single_funny): 投资人 and 奇迹 count the hand (BOARD_AND_DECK); 突袭 counts the board.
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { GameData } from '../../server/match/gamedata.js';
+import { computeBonds, bondList, bondSnapshot, offBondCounts } from '../../server/match/bondsMeta.js';
+import { DATA } from './harness.js';
+
+const funny = new GameData(DATA, 'mode_single_funny');
+const open = new GameData(DATA, 'mode_multi_normal');
+let uid = 1;
+const piece = (id) => ({ uid: uid++, kind: 'chess', id, items: [] });
+const state = ({ board = [], hand = [], layers = {}, bondCountBonus } = {}) => {
+ const b = new Map();
+ board.forEach((p, i) => b.set(`${9 + (i % 4)},${2 + Math.floor(i / 4)}`, p));
+ const h = new Array(10).fill(null);
+ hand.forEach((p, i) => { h[9 - i] = p; });
+ return { board: b, hand: h, layers, bondCountBonus };
+};
+const members = (bond, n) => Object.values(DATA.chess)
+ .filter((c) => c.visible && !c.isGolden && c.bonds.includes(bond))
+ .slice(0, n)
+ .map((c) => c.chessId);
+
+const INVESTOR = members('investShip', 2);
+const RAIDER = members('raidShip', 1);
+const YAN = members('yanShip', 1);
+
+test('标准模拟: an 投资人 member in hand and a 突袭 member on the board are off entries, never in computeBonds', () => {
+ assert.equal(INVESTOR.length, 2);
+ assert.equal(RAIDER.length, 1);
+ const ps = state({
+ board: [piece(RAIDER[0]), piece(YAN[0])],
+ hand: [piece(INVESTOR[0])],
+ layers: { investShip: 12, raidShip: 4, lateranoShip: 9 },
+ });
+ const bonds = computeBonds(funny, ps);
+ assert.ok(!('investShip' in bonds), '投资人 stays out of the battle input');
+ assert.ok(!('raidShip' in bonds), '突袭 stays out of the battle input');
+ assert.ok(!('lateranoShip' in bonds));
+ assert.equal(bondSnapshot(bonds).investShip, undefined);
+ assert.ok(bonds.yanShip, 'an enabled bond is unchanged');
+
+ const off = offBondCounts(funny, ps);
+ assert.equal(off.investShip.count, 1, 'a hand member counts for 投资人');
+ assert.equal(off.investShip.layers, 12);
+ assert.equal(off.raidShip.count, 1);
+ assert.equal(off.raidShip.layers, 4);
+ assert.ok(!('lateranoShip' in off), 'an inactive bond with zero members is omitted, layers or not');
+
+ const list = bondList(funny, bonds, { full: true, off });
+ const invest = list.find((x) => x.bondId === 'investShip');
+ const raid = list.find((x) => x.bondId === 'raidShip');
+ assert.deepEqual(
+ { count: invest.count, active: invest.active, tier: invest.tier, layers: invest.layers, off: invest.off, countsHand: invest.countsHand },
+ { count: 1, active: false, tier: 0, layers: 12, off: true, countsHand: true },
+ );
+ assert.equal(raid.off, true);
+ assert.equal(raid.active, false);
+ assert.equal(raid.tier, 0);
+ assert.equal(raid.countsHand, false);
+ assert.ok(list.findIndex((x) => x.off) > list.findIndex((x) => x.bondId === 'yanShip'), 'off entries follow the live bonds');
+ assert.ok(list.slice(list.findIndex((x) => x.off)).every((x) => x.off), 'the off entries are a suffix');
+ const pub = bondList(funny, bonds, { off });
+ assert.equal(pub.find((x) => x.bondId === 'investShip').off, true);
+ assert.equal('countsHand' in pub.find((x) => x.bondId === 'investShip'), false, 'm.public omits countsHand');
+});
+
+test('a BOARD_AND_DECK off bond counts the hand, not the temporary slots, and not twice', () => {
+ const [a, b] = INVESTOR;
+ const handOnly = offBondCounts(funny, state({ hand: [piece(a), piece(b)] }));
+ assert.equal(handOnly.investShip.count, 2);
+ assert.ok(!('raidShip' in (handOnly || {})), '突袭 in nobody\'s hand: a board bond with no board member is omitted');
+ const raidHand = offBondCounts(funny, state({ hand: [piece(RAIDER[0])] }));
+ assert.equal(raidHand, null, '突袭 does not count a hand member');
+ const both = offBondCounts(funny, state({ board: [piece(a)], hand: [piece(a), piece(DATA.chess[a].goldenId)] }));
+ assert.equal(both.investShip.count, 1, 'normal and elite of one operator, board and hand, count once');
+ const tempOnly = { board: new Map(), hand: new Array(10).fill(null), layers: {}, temp: [piece(a)] };
+ assert.equal(offBondCounts(funny, tempOnly), null, 'the 5 temporary slots never count');
+});
+
+test('a mode with an empty inactive set returns null, and those bonds stay in computeBonds', () => {
+ assert.equal(open.modeInactiveBonds.size, 0);
+ const ps = state({ board: [piece(RAIDER[0])], hand: [piece(INVESTOR[0])] });
+ assert.equal(offBondCounts(open, ps), null);
+ const bonds = computeBonds(open, ps);
+ assert.equal(bonds.investShip.count, 1);
+ assert.equal(bonds.raidShip.count, 1);
+ assert.equal(bondList(open, bonds).some((x) => x.off), false);
+});
diff --git a/test/ui/feedback1b-bonds.test.js b/test/ui/feedback1b-bonds.test.js
index fc30b409..3a1cc7c0 100644
--- a/test/ui/feedback1b-bonds.test.js
+++ b/test/ui/feedback1b-bonds.test.js
@@ -3,8 +3,9 @@
// of the modes whose official modeDataDict inactiveBondIdList switches 奥术 off (with 拉特兰 阿戈尔 卡西米尔 灵巧 奇迹 投资人
// 突袭 独行 绝技), yet 深靛 洛洛 阿罗玛 夕 圣聆初雪 stay in the pool through their other bond (server pool.js) and their cards
// showed 奥术 like any bond: the detail card's chip read "奥术 0/2 未激活" with three 奥术 operators deployed (the server
-// leaves an inactive bond out of m.private.bonds) and the bond popup "在场 0/2 未激活". The cards, chips and popup now say
-// 本局禁用 for a bond the mode never activates (gameLogic modeOffBonds over config.modes[modeId].inactiveBondIds).
+// used to leave an inactive bond out of m.private.bonds) and the bond popup "在场 0/2 未激活". The cards, chips and popup
+// say 本局禁用 for a bond the mode never activates (gameLogic modeOffBonds over config.modes[modeId].inactiveBondIds);
+// since 0.1.3 the view also lists it (`off: true`) so the strip can draw the grey disc — still never in ps.bonds.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
@@ -63,13 +64,15 @@ test('a real 标准 match: 奥术 operators are buyable, three on the board neve
const used = new Set();
for (const id of members.slice(0, 3)) { const t = legalTileFor(m, ps, id, used); used.add(tileKey(t[0], t[1])); give(m, ps, id, 'board', t); }
ps.recompute();
- assert.equal(ps.bonds.arcaneShip, undefined, 'official: the mode never activates 奥术');
+ assert.equal(ps.bonds.arcaneShip, undefined, 'official: the mode never activates 奥术 — not in the battle state');
const priv = ps.privateView();
- assert.equal(priv.bonds.find((b) => b.bondId === 'arcaneShip'), undefined, 'no 奥术 entry in m.private.bonds');
+ const arc = priv.bonds.find((b) => b.bondId === 'arcaneShip');
+ assert.ok(arc && arc.off === true && arc.active === false && arc.tier === 0 && arc.count === 3, 'the view lists the members as a grey 本局禁用 disc');
+ assert.ok(priv.bonds.slice(priv.bonds.findIndex((b) => b.off)).every((b) => b.off), 'off entries come last');
const off = modeOffBonds(getMode(m.modeId));
- // before: the chip computed 0 members from the missing entry — "奥术:在场 0/2,未激活" with 3 deployed
- const before = chipOf(BondChips({ bondIds: DATA.chess[members[0]].bonds, bonds: priv.bonds }), 'arcaneShip');
- assert.match(before.props.title, /在场 0\/2,未激活/);
+ // without the mode's off set the chip would read the new entry as an ordinary inactive bond (在场 3/3,未激活)
+ const bare = chipOf(BondChips({ bondIds: DATA.chess[members[0]].bonds, bonds: priv.bonds }), 'arcaneShip');
+ assert.match(bare.props.title, /在场 3\/3,未激活/);
const chip = chipOf(BondChips({ bondIds: DATA.chess[members[0]].bonds, bonds: priv.bonds, off }), 'arcaneShip');
assert.ok(hasClass(chip, 'is-off') && !hasClass(chip, 'is-active'));
assert.equal(chip.props.title, '奥术:本局禁用(该盟约不会激活)');
diff --git a/test/ui/gameLogic.test.js b/test/ui/gameLogic.test.js
index b089f727..39fd36dc 100644
--- a/test/ui/gameLogic.test.js
+++ b/test/ui/gameLogic.test.js
@@ -9,7 +9,7 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url';
import {
phaseMode, phaseBanner, isCombatPhase, isBossPhase, countdownState, phaseTotalSeconds, sortBonds, bondTier, nextThreshold,
- bondMembers, bannedPerBond, priceTone, mergeProgress, shopBlockReason, deploySets, indexPieces, placementContext, canPlace,
+ bondMembers, memberHeadCount, bannedPerBond, priceTone, mergeProgress, shopBlockReason, deploySets, indexPieces, placementContext, canPlace,
boardTargets, dropIntent, normalizeDraft, normalizeSp, groupEnemies, factionTypes, snapHud, bossFrac, attackInterval, fmtNum,
rangeGridBox, shortcutFor, sanitizeSettings, DEFAULT_SETTINGS, normalizeResult, cycleField, fieldLabel, homeFieldId,
activeBubbles, sortedPlayers, tileKey, prepCapsuleLabel, prepCamera, dropFailureReason,
@@ -138,9 +138,46 @@ describe('bonds', () => {
assert.equal(byId.get(m2).owned, true, 'golden in hand counts as owned base');
assert.equal(byId.get(m2).onBoard, false);
assert.equal(byId.get(m3).banned, true);
+ assert.equal(byId.get(m2).inHand, true, 'a golden copy in the hand marks the base in hand');
assert.equal(rows[0].id, m1, 'on-board first');
assert.equal(rows.length, b.visibleMembers.length);
});
+ test('memberHeadCount: the hand counts only for a bond that counts the hand, once per row', () => {
+ const rows = [
+ { id: 'a', onBoard: true, inHand: true },
+ { id: 'b', onBoard: false, inHand: true },
+ { id: 'c', onBoard: false, inHand: false },
+ null,
+ ];
+ assert.equal(memberHeadCount(rows, false), 1, 'board only');
+ assert.equal(memberHeadCount(rows, true), 2, 'board or hand, one row once');
+ assert.equal(memberHeadCount(null, true), 0);
+ assert.equal(memberHeadCount(undefined), 0);
+ const invest = bonds.investShip;
+ assert.equal(invest.countsHand, true);
+ const [m1, m2] = invest.visibleMembers;
+ const priv = privWith({
+ board: [{ ...piece(m1), row: 9, col: 3 }],
+ hand: [piece(m2)],
+ temp: [piece(invest.visibleMembers[2] || m2)],
+ });
+ const got = bondMembers(invest, priv, [], getChess);
+ const byId = new Map(got.map((r) => [r.id, r]));
+ assert.equal(byId.get(m1).onBoard, true);
+ assert.equal(byId.get(m2).inHand, true);
+ assert.equal(byId.get(m2).onBoard, false);
+ if (invest.visibleMembers[2]) assert.equal(byId.get(invest.visibleMembers[2]).inHand, false, 'a temporary-slot copy is not in hand');
+ assert.equal(memberHeadCount(got, true), 2, 'the board member and the hand member, not the temporary slot');
+ assert.equal(memberHeadCount(got, false), 1, 'without countsHand the hand member is left out');
+ });
+ test('sortBonds puts a mode-off bond after every live bond', () => {
+ const sorted = sortBonds([
+ { bondId: 'investShip', off: true, active: false, layers: 99, count: 3, tier: 0 },
+ { bondId: 'yanShip', active: true, layers: 1, count: 3, tier: 1 },
+ { bondId: 'raidShip', off: true, layers: 0, count: 1 },
+ ], (id) => bonds[id]).map((b) => b.bondId);
+ assert.deepEqual(sorted, ['yanShip', 'investShip', 'raidShip']);
+ });
test('bannedPerBond counts banned visible members', () => {
const b = bonds.deputShip;
const per = bannedPerBond(Object.values(bonds), [b.visibleMembers[0], b.visibleMembers[1], 'nope']);