Bond strip follows the watched teammate (v2.5.2): prep scouting, after the own battle, 联防, Final Assault, eliminated observers

- the strip, its counts / layers / tiers and the bond popups show the player whose field or half is on screen, with an
  amber "👁 name" tag; live in-battle layers (runner bondLayers, ≤ 999) and the finished battle's gains until SETTLE
- shared fields follow the ‹ › half's player; 全景 shows your own when you fight there, else the picked teammate [ASSUMED]
- a card's bond chips and popups belong to the unit's owner; a teammate's popup lists their operators on the field
- m.public players[].bonds for living players only; a reload while watching after the own battle returns to it
- DESIGN §20.15, META, PLAYING; tests (unit, UI logic, 4 browser scenarios)
This commit is contained in:
sganggs committed 2026-10-02 12:11:48 +08:00
1 parent 620f88c0d8
commit 82c64086d5
21 files changed
+2087 -57

No files matched your search

+254
View File
@@ -0,0 +1,254 @@
// 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') [ASSUMED — no source shows the strip
// there]: 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:<playerId>' (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<string, number>|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<string, Record<string, number>>|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<string, Record<string, number>>|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<string, Record<string, number>>|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<string, Record<string, number>>|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) };
}