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

+72 -6
View File
@@ -12,10 +12,20 @@
// distinct equipment items → the equip-replace dialog).
// SP_START_SHOP=<chessId,…>: the starter kit also stocks the first shop slots with these chess at their normal price
// (test/ui/playtest6-elite.e2e.test.js: the copy that completes a merge).
// SP_START_KITS=<JSON [[chessId,…], …]>: per-human starter kits in seat order, overriding SP_START_CHESS for the humans
// listed ([] = no operators: a leaker without a board — test/ui/watch-bonds.e2e.test.js, 联防).
// SP_BOT_CHESS=<chessId,…>: AI seats get these chess too (kept with SP_IDLE_BOTS). SP_AUTO_PLACE=1: the first prep after
// the jump puts every player's hand operators on the board (the bots' layout planner: tiles and facings; no drag & drop)
// — except a human whose SP_START_KITS entry is [] (operators a band hands out at that prep, e.g. 老鲤's two, stay in
// the hand: the seat keeps no board and leaks).
// SP_ELIMINATE=<humanIdx,…>: those humans (seat order) are eliminated at the jump, before the round's boss pairing — an
// eliminated spectator from the first round on (test/ui/watch-bonds.e2e.test.js, the Final Assault).
// Not a test file (node --test runs it as a no-op module when NODE_TEST_CONTEXT is set).
import { startServer } from '../../server/index.js';
import { Match } from '../../server/match/Match.js';
import { legalTiles, positionClass, tileKey } from '../../server/match/board.js';
import { planLayout } from '../../server/match/bot.js';
if (!process.env.NODE_TEST_CONTEXT) {
const timerScale = Number(process.env.SP_TIMER_SCALE) || 1;
@@ -28,19 +38,74 @@ if (!process.env.NODE_TEST_CONTEXT) {
const kitIds = String(process.env.SP_START_CHESS || '').split(',').map((x) => x.trim()).filter(Boolean);
const kitItems = String(process.env.SP_START_ITEMS || '').split(',').map((x) => x.trim()).filter(Boolean);
const kitShop = String(process.env.SP_START_SHOP || '').split(',').map((x) => x.trim()).filter(Boolean);
let humanKits = [];
try { humanKits = JSON.parse(process.env.SP_START_KITS || '[]'); } catch { humanKits = []; }
const botChess = String(process.env.SP_BOT_CHESS || '').split(',').map((x) => x.trim()).filter(Boolean);
const autoPlace = process.env.SP_AUTO_PLACE === '1';
const eliminate = String(process.env.SP_ELIMINATE || '').split(',').map((x) => x.trim()).filter(Boolean).map(Number);
class FastMatch extends Match {
constructor(opts) {
super({ ...opts, timerScale, combatSpeed });
this._jumped = !startAt;
this._placeRound = null;
}
startRound(r) {
if (this._jumped) { super.startRound(r); return; }
this._jumped = true;
const target = startAt === 'boss' ? this.gd.bossRound : startAt === 'hidden' ? (this.gd.hiddenRound || this.gd.bossRound) : Number(startAt);
const humans = this.order.filter((ps) => !ps.isBot);
for (const i of eliminate) {
const ps = humans[i];
if (ps && ps.alive) { ps.lp = 0; ps.eliminate(r); ps.dirty(); }
}
super.startRound(Number.isInteger(target) && target > r ? target : r);
for (const ps of this.alivePlayers()) if (!ps.isBot) this._starterKit(ps);
humans.forEach((ps, i) => { if (ps.alive) this._starterKit(ps, Array.isArray(humanKits[i]) ? humanKits[i] : null); });
if (botChess.length) for (const ps of this.alivePlayers()) if (ps.isBot) { this._give(ps, botChess); ps.recompute(); }
if (autoPlace) this._placeRound = this.round;
}
/** Chess ids into the hand's free slots (copies taken from the pool when it has them). */
_give(ps, ids) {
for (const id of ids) {
const rec = this.data.chess?.[id];
const slot = ps.hand.findIndex((x) => x == null);
if (!rec || slot < 0) continue;
try { ps.hand[slot] = ps.newPiece('chess', id, { poolCopies: this.pool.take(this.gd.baseIdOf(id), rec.isGolden ? this.gd.goldenCopies : 1) }); } catch { /* best effort */ }
}
}
enterPrep() {
super.enterPrep();
if (this._placeRound == null || this.round !== this._placeRound) return;
this._placeRound = null;
// every player's hand operators onto the field they deploy on: the bots' layout planner (bot.js planLayout —
// tiles and facings), any piece it leaves out on the first free legal tile
const humans = this.order.filter((x) => !x.isBot);
for (const ps of this.alivePlayers()) {
const kit = ps.isBot ? null : humanKits[humans.indexOf(ps)];
if (Array.isArray(kit) && kit.length === 0) continue; // "no operators": a band's gift stays in the hand
const pieces = ps.hand.filter((p) => p && p.kind === 'chess');
let plan = null;
try { plan = planLayout(this, ps, pieces); } catch { plan = null; }
for (const piece of pieces) {
const key = plan && plan.get(piece.uid);
if (key) {
const [row, col] = key.split(',').map(Number);
const res = ps.move(piece.uid, { area: 'board', row, col }, plan.dirs.get(piece.uid) || 'RIGHT');
if (!res || !res.error) continue;
}
for (const [row, col] of legalTiles(ps.deployMap(), positionClass(this.gd.chess(piece.id)))) {
if (ps.board.has(tileKey(row, col))) continue;
const res = ps.move(piece.uid, { area: 'board', row, col }, 'RIGHT');
if (!res || !res.error) break;
}
}
ps.recompute();
ps.dirty();
}
this.markPublic();
}
scheduleBotPrep(ps, i = 0) {
@@ -53,14 +118,15 @@ if (!process.env.NODE_TEST_CONTEXT) {
});
}
/** A few cheap operators in the hand + funds (a test hook: the rounds before were skipped). */
_starterKit(ps) {
/** A few cheap operators in the hand + funds (a test hook: the rounds before were skipped). `own`: this human's kit. */
_starterKit(ps, own = null) {
const ids = own || kitIds;
const all = Object.values(this.data.chess || {}).filter((c) => c && c.visible && !c.isGolden && c.tier <= 3)
.sort((a, b) => (a.tier - b.tier) || String(a.chessId).localeCompare(String(b.chessId)));
const melee = all.filter((c) => c.position === 'MELEE');
const ranged = all.filter((c) => c.position !== 'MELEE');
const picks = kitIds.map((id) => this.data.chess?.[id]).filter(Boolean);
for (let i = 0; !kitIds.length && picks.length < kitSize && (melee[i] || ranged[i]); i++) {
const picks = ids.map((id) => this.data.chess?.[id]).filter(Boolean);
for (let i = 0; !own && !kitIds.length && picks.length < kitSize && (melee[i] || ranged[i]); i++) {
if (melee[i]) picks.push(melee[i]);
if (ranged[i] && picks.length < kitSize) picks.push(ranged[i]);
}
@@ -69,7 +135,7 @@ if (!process.env.NODE_TEST_CONTEXT) {
if (slot < 0) break;
try {
const taken = this.pool.take(this.gd.baseIdOf(rec.chessId), rec.isGolden ? this.gd.goldenCopies : 1);
if (!taken && !kitIds.length) continue; // an explicit SP_START_CHESS piece is handed out even when this match bans it
if (!taken && !ids.length) continue; // an explicit SP_START_CHESS piece is handed out even when this match bans it
ps.hand[slot] = ps.newPiece('chess', rec.chessId, { poolCopies: taken });
} catch { /* keep going: the kit is best effort */ }
}