Files
ark-stronghold-protocol/public/js/ui/watchBonds.js
T

255 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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:<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) };
}