// Whose bonds the bond strip shows (DESIGN §20.15, user report after playtest #6: "观看队友时…盟约栏应该变成当前队友的 // 盟约以及他的层数") — pure helpers for the game screen (screens/game.js), the strip and its popup (ui/bondStrip.js). // // The strip follows the player whose field / half the camera shows: // * the own board (prep) and the own battle → your own bonds (m.private bonds); // * a teammate's board in prep (前往查看, research 09 §3.1 "their board, read-only, including their pen and bonds"), a // teammate's battle watched after the own one ended, the field an eliminated player auto-observes → THAT player's // bonds (m.public players[].bonds); // * a shared field (联防 'u', the Final Assault / Hidden Core pair 'b1' / 'b2') — no source shows the strip there; this // rule was settled by the user (DESIGN §20.15): the player holding the half the ‹ › pill points at ("你自己" → yours, "👁 name" → theirs). On 全景 (or an // empty half, "无人在家"): yourself when you fight on that field; a viewer who does not (a 联防 leaker, an eliminated // spectator) never sees their own bonds there — the teammate they picked with 前往查看 (official observing targets a // player: ObserveUp / DeadAutoObDn { obIndex }), else the field's first player (helper 1 / the pair's first seat; // the official non-helper pill reads "‹ 👁 helper# ›"). 前往查看 of a player on a two-half field also moves the // camera to their half (playerLayer), so the pill and the strip name the same player. // In battle the layers are live: the local simulation of the battle on screen (the own one, a teammate's display // replica — battle/runner.js state().bondLayers, absolute counts ≤ BOND_LAYER_CAP) is laid over the view's start-of- // round counts; the server's views add the finished normal battle's gains from the end of COMBAT until the settlement // (server/match/bondsMeta.js bondsWithGains), so the counts never fall back during 联防. // A detail card's bond chips follow the UNIT's owner instead (detailBondOwner: a shared field shows both halves' units // whoever the strip follows), and a bond popup carries the player it was opened for (toggleBond / popupView): the // strip's owner, or the chip's — its entry, member list and "👁 name" label always belong to that one player. import { BOND_LAYER_CAP } from '../../../shared/constants.js'; import { sidesOf, nameOf, cameraLayers } from '../battle/observe.js'; const isObj = (v) => !!v && typeof v === 'object'; const playersOf = (pub) => (Array.isArray(pub?.players) ? pub.players.filter(isObj) : []); const fieldsOf = (pub) => (Array.isArray(pub?.fields) ? pub.fields.filter(isObj) : []); /** * The field on screen whose player the strip follows; null = the own prep board. * @param {{ combat?: boolean, settle?: boolean, watchingOther?: boolean, watching?: string|null, * battleFieldId?: string|null, home?: string|null }} o — battleFieldId: the battle the view shows (client-side combat: * the runner's battle on screen, which is also the field an eliminated player auto-observes; server-run combat: the * field the view entered) * @returns {string|null} */ export function screenFieldId({ combat = false, settle = false, watchingOther = false, watching = null, battleFieldId = null, home = null } = {}) { if (watchingOther && typeof watching === 'string' && watching) return watching; if (!combat && !settle) return null; if (typeof battleFieldId === 'string' && battleFieldId) return battleFieldId; return typeof home === 'string' && home ? home : null; } /** * The player whose bonds the strip shows (see the header). * @param {{ pub: any, myId: string, fieldId?: string|null, field?: any, layer?: 'L'|'ALL'|'R', halves?: boolean, * watched?: string|null }} o — field: the field meta on screen (store.match.field: `sides` / `players`), consulted when * it is `fieldId`; halves: the ‹ › pill is offered (battle/observe.js cameraLayers non-empty); watched: the player * picked with 前往查看 (watchedPlayer), consulted on a shared field the viewer does not fight on * @returns {string} */ export function bondOwnerId({ pub, myId, fieldId = null, field = null, layer = 'ALL', halves = false, watched = null } = {}) { const known = new Set(playersOf(pub).map((p) => p.playerId)); const ok = (pid) => typeof pid === 'string' && pid !== '' && known.has(pid); if (typeof fieldId !== 'string' || !fieldId) return myId; // a normal field / a prep board: 'n:' (DESIGN §8.3) if (fieldId.startsWith('n:')) { const pid = fieldId.slice(2); return ok(pid) ? pid : myId; } const meta = isObj(field) && field.fieldId === fieldId ? field : null; const sides = meta ? sidesOf(meta) : {}; if (halves && meta && (layer === 'L' || layer === 'R')) { const pid = Object.keys(sides).find((id) => sides[id] === layer); if (ok(pid)) return pid; } // 全景, an empty half, no ‹ › pill: the field's members (m.public.fields, else the meta) const listed = fieldsOf(pub).find((f) => f.fieldId === fieldId); const members = Array.isArray(listed?.players) ? listed.players : Array.isArray(meta?.players) ? meta.players : []; if (members.includes(myId)) return myId; // not on that field (a 联防 leaker, an eliminated spectator): never your own — the picked teammate, else the first if (ok(watched) && members.includes(watched)) return watched; const first = members.find(ok); return first || myId; } /** * The player picked with 前往查看 while that watch is on screen: `who` = { fieldId, playerId } remembered by the game * screen when a team row was tapped (a shared field shows more than one player, the fieldId alone cannot tell). * @param {{ fieldId?: string, playerId?: string }|null} who @param {string|null} watching the watched fieldId * @returns {string|null} */ export function watchedPlayer(who, watching) { if (!isObj(who) || typeof who.playerId !== 'string' || !who.playerId) return null; return typeof watching === 'string' && watching && who.fieldId === watching ? who.playerId : null; } /** * The ‹ › layer that shows `playerId`'s half of a two-half shared field (联防 with two helpers, a pair's boss field), * or null (a normal / lone field, a player not on it). 前往查看 of such a player moves the camera there. * @param {any} field the field meta on screen @param {any} pub @param {string} myId @param {string|null} playerId * @returns {'L'|'R'|null} */ export function playerLayer(field, pub, myId, playerId) { if (!isObj(field) || typeof playerId !== 'string' || !playerId) return null; if (!cameraLayers(field, pub, myId).length) return null; const side = sidesOf(field)[playerId]; return side === 'L' || side === 'R' ? side : null; } /** * A player's bond list as the views carry it: your own m.private bonds (thresholds / countsHand included), a teammate's * m.public players[].bonds ({ bondId, count, active, tier, layers }). * @returns {any[]} */ export function ownerBonds({ pub, priv = null, myId, ownerId }) { const row = (pid) => playersOf(pub).find((p) => p.playerId === pid) || null; if (ownerId === myId) { if (Array.isArray(priv?.bonds)) return priv.bonds; const own = row(myId)?.bonds; return Array.isArray(own) ? own : []; } const list = row(ownerId)?.bonds; return Array.isArray(list) ? list : []; } /** * Lay the live layers of the battle on screen over a bond list: `live` = { [bondId]: n } absolute counts (the runner's * bondLayers of that player). A count only ever grows in a battle — the higher of the two wins (the view may already * carry the settled gains) — and never shows above BOND_LAYER_CAP; a bond that gained without being listed joins the * list (no members, not active). Returns the list itself when there is nothing to lay over. * @param {any[]} bonds @param {Record|null|undefined} live */ export function withLiveLayers(bonds, live) { const list = Array.isArray(bonds) ? bonds : []; if (!isObj(live)) return list; const cap = BOND_LAYER_CAP > 0 ? BOND_LAYER_CAP : Infinity; const ids = Object.keys(live).filter((id) => Number.isFinite(live[id]) && live[id] > 0); if (!ids.length) return list; let changed = false; const out = list.map((b) => { const n = isObj(b) && typeof b.bondId === 'string' ? live[b.bondId] : undefined; if (!Number.isFinite(n)) return b; const v = Math.min(cap, n); if (!(v > (Number(b.layers) || 0))) return b; changed = true; return { ...b, layers: v }; }); for (const id of ids) { if (out.some((b) => isObj(b) && b.bondId === id)) continue; changed = true; out.push({ bondId: id, count: 0, active: false, tier: 0, layers: Math.min(cap, live[id]) }); } return changed ? out : list; } /** * A teammate's board as far as the screen shows it, in the m.private shape the bond popup's member list reads * (gameLogic bondMembers): their operators on the field meta on screen (the prep scouting board, a server-run battle * field's units) and, under client-side combat, the battle on screen's (`extra` = battle/runner.js ownerOps: the meta * the runner publishes is taken before the operators deploy). Their hand is never sent — members count as owned only * when they are on the field. null without a field or battle. * @param {any} field @param {string} ownerId @param {any[]|null} [extra] UnitInfo-like { kind, ownerId, defId } */ export function ownerBoard(field, ownerId, extra = null) { const fromField = isObj(field) && Array.isArray(field.units) ? field.units : null; const fromBattle = Array.isArray(extra) ? extra : null; if (!fromField && !fromBattle) return null; const board = [...(fromField || []), ...(fromBattle || [])] .filter((u) => isObj(u) && u.ownerId === ownerId && u.kind === 'op' && typeof u.defId === 'string') .map((u) => ({ kind: 'chess', id: u.defId })); return { board, hand: [], temp: [] }; } /** * Everything the strip needs: whose bonds, their name (null for your own) and the list with the live layers laid over. * @param {{ pub: any, priv?: any, myId: string, fieldId?: string|null, field?: any, layer?: 'L'|'ALL'|'R', halves?: boolean, * watched?: string|null, live?: Record>|null }} o — live: the runner's bondLayers (null * outside a battle) * @returns {{ ownerId: string, self: boolean, name: string|null, bonds: any[] }} */ export function stripView({ pub, priv = null, myId, fieldId = null, field = null, layer = 'ALL', halves = false, watched = null, live = null }) { const ownerId = bondOwnerId({ pub, myId, fieldId, field, layer, halves, watched }); const self = ownerId === myId; const bonds = withLiveLayers(ownerBonds({ pub, priv, myId, ownerId }), isObj(live) ? live[ownerId] : null); return { ownerId, self, name: self ? null : nameOf(pub, ownerId), bonds }; } /** * The game screen's strip from its state (screens/game.js): the field on screen, whether the ‹ › pill is offered for * it, the picked teammate, the live layers (battle phases and SETTLE only) — then stripView. Also returns `fieldId`. * @param {{ pub: any, priv?: any, myId: string, combat?: boolean, settle?: boolean, watchingOther?: boolean, * watching?: string|null, home?: string|null, battleFieldId?: string|null, field?: any, layers?: any[], * layer?: 'L'|'ALL'|'R', who?: { fieldId?: string, playerId?: string }|null, * bondLayers?: Record>|null }} o — layers: the ‹ › pill's layers on offer * (battle/observe.js cameraLayers of `field`, [] when not offered); bondLayers: the runner's state().bondLayers * @returns {{ fieldId: string|null, ownerId: string, self: boolean, name: string|null, bonds: any[] }} */ export function screenStrip({ pub, priv = null, myId, combat = false, settle = false, watchingOther = false, watching = null, home = null, battleFieldId = null, field = null, layers = [], layer = 'ALL', who = null, bondLayers = null }) { const fieldId = screenFieldId({ combat, settle, watchingOther, watching, home, battleFieldId }); const halves = Array.isArray(layers) && layers.length > 0 && isObj(field) && field.fieldId === fieldId; const live = (combat || settle) && isObj(bondLayers) ? bondLayers : null; const watched = watchingOther ? watchedPlayer(who, watching) : null; return { fieldId, ...stripView({ pub, priv, myId, fieldId, field, layer, halves, watched, live }) }; } /** * The bonds of any player with the live layers laid over (the detail card's bond chips of a teammate's unit). * @param {{ pub: any, priv?: any, myId: string, ownerId: string, live?: Record>|null }} o */ export function playerBonds({ pub, priv = null, myId, ownerId, live = null }) { return withLiveLayers(ownerBonds({ pub, priv, myId, ownerId }), isObj(live) ? live[ownerId] : null); } /** * Whose bonds a detail card's bond chips show — and so the popup a chip opens: a unit on the field → its owner (yours or * a teammate's, when a known player: a 联防 / 最终攻势 field shows both halves' units whoever the strip follows); your own * piece (board / bench) → yours; a bond-member card opened from a popup → that popup's owner (`target.owner`); anything * else (shop / reward / drawer cards, a unit without a known owner) → the strip's owner. * @param {any} target the detail target (screens/game.js detailTarget) @param {{ pub: any, myId: string, * stripOwnerId?: string|null }} o * @returns {string} */ export function detailBondOwner(target, { pub, myId, stripOwnerId = null }) { const known = (pid) => typeof pid === 'string' && pid !== '' && playersOf(pub).some((p) => p.playerId === pid); if (isObj(target)) { if (target.kind === 'unit' && known(target.unit?.ownerId)) return target.unit.ownerId; if (target.kind === 'piece') return myId; if (target.kind === 'chess' && known(target.owner)) return target.owner; } return typeof stripOwnerId === 'string' && stripOwnerId ? stripOwnerId : myId; } /** * Open / close a bond popup: `open` = { id, ownerId, from } — the bond, the player it shows (the strip's owner, or the * detail card's: detailBondOwner) and where it was opened ('strip' | 'detail'). The same bond of the same player closes it. * @param {{ id: string, ownerId: string, from?: string }|null} open @param {string} id @param {string} ownerId * @param {'strip'|'detail'} from */ export function toggleBond(open, id, ownerId, from) { if (isObj(open) && open.id === id && open.ownerId === ownerId) return null; return { id, ownerId, from }; } /** * The bond popup's data, for the player it was opened for (never simply the strip's: a chip of the other half's unit * opens THAT player's bond): their entry with the live layers (as the strip and the chips show it), the member list's * board (your pieces, or their operators on the field on screen — ownerBoard) and their name (null for your own). * @param {{ open: { id: string, ownerId?: string }|null, pub: any, priv?: any, myId: string, field?: any, * units?: any[]|null, live?: Record>|null }} o — units: the owner's operators in the * battle on screen (battle/runner.js ownerOps; null outside client-side combat); live: the runner's bondLayers (null * outside a battle) * @returns {{ bondId: string, ownerId: string, self: boolean, name: string|null, entry: any, priv: any }|null} */ export function popupView({ open, pub, priv = null, myId, field = null, units = null, live = null }) { if (!isObj(open) || typeof open.id !== 'string' || !open.id) return null; const ownerId = typeof open.ownerId === 'string' && open.ownerId ? open.ownerId : myId; const self = ownerId === myId; const entry = playerBonds({ pub, priv, myId, ownerId, live }).find((b) => isObj(b) && b.bondId === open.id) || null; return { bondId: open.id, ownerId, self, name: self ? null : nameOf(pub, ownerId), entry, priv: self ? priv : ownerBoard(field, ownerId, units) }; }