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

+71 -5
View File
@@ -26,6 +26,10 @@
// own field's min(lpCapPerRound, n) as the LP about to be lost (ui/hud.js liveLp). A 联防 field keeps each leaker's
// enemies still standing (/sim/spec.js uniteLeft; user playtest #6 item 7) as state().uniteLeft { [playerId]: n },
// published whenever it changes (it falls as the helpers kill them), and its authority reports it as b.progress `left`.
// Live bond layers (DESIGN §20.15): every battle simulated here keeps the bonds whose layers grew in it (IN_BATTLE gains,
// Battle.addLayers — normal battles only) as absolute counts per player, published as state().bondLayers
// { [playerId]: { [bondId]: n } } (every battle of the round: the own one, the teammates' replicas) whenever one grows;
// the bond strip of the player on screen — the own one, or a watched teammate's — shows them live (ui/watchBonds.js).
//
// The sim (≈ 0.2–1 ms per tick) runs on the main thread: one battle at a time is stepped for display (plus an
// authoritative one if it is not the one on screen). stats() exposes the measured cost.
@@ -33,7 +37,7 @@
// import { battleRunner } from './battle/runner.js' (browser singleton wired to net.js + store.js; null in Node)
// battleRunner.on('snap' | 'ev' | 'field' | 'state', fn) → off
// battleRunner.state() → { battleId, fieldId, kind, authoritative, watch, done, own, members, loading, paused, leaks,
// uniteLeft } | null
// uniteLeft, bondLayers } | null
// battleRunner.stats() → { ticks, stepMs, avgTickMs, maxFrameMs, catchups, errors, battles }
// battleRunner.unitStats(unitId, fieldId?) → the live stats of a unit of the battle on screen (shared/protocol.js
// unitStatsEntry: current HP, effective max HP / ATK / DEF / RES / interval / block / move
@@ -41,6 +45,8 @@
// playtest #4 item 7). Read-only: it takes the stats the sim computed last (`unit._s`) and
// never makes the unit recompute them, so looking never changes the battle's floats.
// battleRunner.unitIdOf(uid, ownerId, fieldId?) → the id of an own board piece's unit in that battle | null
// battleRunner.ownerOps(ownerId, fieldId?) → [{ kind: 'op', ownerId, defId }] that player's operators in the battle on
// screen (a teammate's bond popup: the members in play, DESIGN §20.15) | []
//
// createBattleRunner(deps) builds an instance with injectable net / store / clock / frame scheduler / sim loader
// (test/match/runner.test.js drives it under Node).
@@ -147,7 +153,7 @@ export function createBattleRunner(deps) {
let lastPool = null;
/** solo pause: the runner clock's instant when m.public.paused turned true (null while running) */
let pausedAt = null;
/** a normal field's leak count changed since the last publishState() */
/** a normal field's leak count (or a battle's bond layers) changed since the last publishState() */
let leaksDirty = false;
const stats = { ticks: 0, stepMs: 0, maxFrameMs: 0, catchups: 0, errors: 0, battles: 0, frames: 0 };
@@ -170,6 +176,19 @@ export function createBattleRunner(deps) {
return out;
}
/**
* Live bond layers of every battle simulated here (DESIGN §20.15): { [playerId]: { [bondId]: n } } — only the bonds
* whose layers grew in this round's battle, as absolute counts (the battle's live copy, ≤ BOND_LAYER_CAP).
*/
function layerMap() {
const out = {};
for (const e of entries.values()) {
if (!e.live) continue;
for (const [pid, m] of Object.entries(e.live)) out[pid] = { ...(out[pid] || {}), ...m };
}
return out;
}
/**
* The 联防 field on screen (never an older round's kept entry, nor while a new battle is being prepared): each
* leaker's enemies still standing { [playerId]: n } (absent = none left), else null.
@@ -181,11 +200,11 @@ export function createBattleRunner(deps) {
function state() {
const e = cur;
if (!e) return loading ? { loading: true, battleId: loading.battleId, fieldId: loading.fieldId, kind: loading.kind, leaks: leakMap(), uniteLeft: uniteLeftMap() } : null;
if (!e) return loading ? { loading: true, battleId: loading.battleId, fieldId: loading.fieldId, kind: loading.kind, leaks: leakMap(), uniteLeft: uniteLeftMap(), bondLayers: layerMap() } : null;
return {
battleId: e.battleId, fieldId: e.fieldId, kind: e.kind, authoritative: e.authoritative, watch: e.watch,
done: e.done, own: e.own, members: e.members.slice(), loading: !!loading, speed: e.speed, paused: pausedAt != null,
leaks: leakMap(), uniteLeft: uniteLeftMap(),
leaks: leakMap(), uniteLeft: uniteLeftMap(), bondLayers: layerMap(),
};
}
@@ -203,6 +222,7 @@ export function createBattleRunner(deps) {
* changed them.
*/
function noteLeaks(e) {
noteLayers(e);
if (e.kind === 'unite') { noteUniteLeft(e); return; }
if (e.kind !== 'normal') return;
const b = e.battle;
@@ -224,7 +244,37 @@ export function createBattleRunner(deps) {
if (JSON.stringify(left) !== JSON.stringify(e.left)) { e.left = left; leaksDirty = true; }
}
/** Publish the state when a leak count changed since the last publish. */
/**
* Re-read an entry's live bond layers (DESIGN §20.15): per player of its spec, the bonds whose layers in the battle's
* live copy (Battle.addLayers, clamped at BOND_LAYER_CAP like the client's AddBondCount) exceed the spec's start
* count. Nothing to do when the battle disables gains (联防, boss rounds).
*/
function noteLayers(e) {
const b = e.battle;
if (!b || (b.flags && b.flags.layerGainsEnabled === false) || typeof b.getPlayer !== 'function') return;
let sum = 0;
let live = null;
for (const sp of Array.isArray(e.spec.players) ? e.spec.players : []) {
const ps = sp && sp.playerId != null ? b.getPlayer(sp.playerId) : null;
if (!ps || !ps.bonds || typeof ps.bonds !== 'object') continue;
const start = sp.bonds && typeof sp.bonds === 'object' ? sp.bonds : {};
for (const id of Object.keys(ps.bonds)) {
const n = Number(ps.bonds[id] && ps.bonds[id].layers) || 0;
const from = Number(start[id] && start[id].layers) || 0;
if (!(n > from)) continue;
sum += n - from;
if (!live) live = {};
if (!live[sp.playerId]) live[sp.playerId] = {};
live[sp.playerId][id] = n;
}
}
if (sum === e.layerSum) return;
e.layerSum = sum;
e.live = live;
leaksDirty = true;
}
/** Publish the state when a leak count (or a battle's bond layers) changed since the last publish. */
function flushLeaks() { if (leaksDirty) publishState(); }
/** Target tick of an entry on its clock. */
@@ -486,6 +536,8 @@ export function createBattleRunner(deps) {
// counted leaks so far (normal fields; noteLeaks) and the Battle state they were counted at; 联防 fields: each
// leaker's enemies still standing (noteUniteLeft)
leaks: 0, leakMark: '', left: null,
// live bond layers grown in this battle { [playerId]: { [bondId]: n } } and their total gain (noteLayers)
live: null, layerSum: 0,
};
if (lastPool && battle.sharedBoss && typeof battle.sharedBoss.sync === 'function') {
battle.sharedBoss.sync(lastPool.hp, lastPool.acked ? lastPool.acked[e.fieldId] : undefined);
@@ -622,6 +674,20 @@ export function createBattleRunner(deps) {
const u = list.find((x) => x && x.uid === uid && x.ownerId === ownerId);
return u && Number.isInteger(u.id) ? u.id : null;
},
/**
* The operators `ownerId` fields in the battle on screen — their board in that battle, waiting to deploy, deployed
* or knocked out (the field meta published by show() is taken before they deploy) — as UnitInfo-like
* { kind: 'op', ownerId, defId }: a teammate's bond popup lists them as the members in play (ui/watchBonds.js
* ownerBoard). [] when no such battle is on screen.
* @param {string} ownerId @param {string|null} [fieldId]
*/
ownerOps(ownerId, fieldId = null) {
const e = cur;
if (!e || typeof ownerId !== 'string' || !ownerId || (fieldId != null && e.fieldId !== fieldId)) return [];
const list = Array.isArray(e.battle.allyUnits) ? e.battle.allyUnits : [];
return list.filter((u) => u && u.kind === 'op' && u.ownerId === ownerId && typeof u.defId === 'string')
.map((u) => ({ kind: 'op', ownerId, defId: u.defId }));
},
/** Re-show the current battle (the game screen remounted). */
reshow() { if (cur) show(cur); },
/** Test / debug hooks. */