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`
+ <${BondDisc} name=${name} icon=${bondIconUrl(m, b.bondId)} tier=${0} maxTier=${Math.max(1, th.length)} active=${false} + disabled=${true} size="sm" showName=${true} layersDisabled=${layersDisabled} onClick=${() => onOpen(b.bondId)} + title=${`${briefingBondTip(name, 'off')} · ${b.count ?? 0} 名成员`} /> + 本局禁用 +
`; + } const next = nextThreshold(b.count ?? 0, th); return html`
0 ? b.harmony : null} class=${cx('bslot', b.active && 'is-active', openId === b.bondId && 'is-open')}> @@ -123,7 +139,7 @@ export function BondPopup({ bondId, entry, priv, banned = [], onClose, onMember, <${RichText} as="p" text=${b.descRaw || b.desc} class="bpop__desc" />
-

成员 ${members.filter((x) => x.onBoard).length}/${members.length}

+

成员 ${memberHeadCount(members, countsHand)}/${members.length}

${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']);