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

+81 -1
View File
@@ -820,7 +820,7 @@ Nineteen reports after playing v2.4 (#1–#3 from a friend of the user). Where e
Eight workstreams were merged into `playtest6` (WH → WG → WD → WC → WB → WA → WF → WE); the reviews' last rounds and the cross-workstream notes were applied on top: a stocked skill summon deploys as soon as its owner is back (§20.1), the AUTO DP skills 焰尾 S1 / 凛御银灰 S1 fire on full SP (§20.2), 锏 S3's slashes give 无敌 and 晕眩 / 冻结免疫 (§20.3), the `AUTO_OP_COOLDOWN` comment and the SKILL_RANGE count (13 skills / 26 records) were corrected. The user then answered the open questions (2026-10-01); the answers are **settled decisions**, recorded in their sections and in §20.9, each with the one-line change that flips it: the placed skill summons deploy once at the battle start and 凯瑟琳's device stays a hand card (§20.1), every blocker hits what it blocks (§20.3), 萨卡兹枯朽战车 follows the re-research — ranged enemies reach an operator by its 0.25 collider (§20.4) — and multi-round bounties last two battles (§20.6). Normative lines rewritten with it: §2 (`fear.js`), §3 (the enemies' reach), §5.2 (ASPD floor 20), §5.3 (`camou`, `fear`, `noHeal`, `isolated`), §5.4 (push / pull / ally-selection helpers), §5.5 (initial deployment, blocking, operator attacks, healers, enemy AI, end), §5.6 (skill triggers, the operation cooldown), §6.1 / §6.2 (the Final Assault verdict, summon stacks, summons in the hand, the promotion reward), §7 (bounty draft, 教鞭), §8.2 / §8.3 (`uniteLeft`, `descRaw`, `counterText`, the `elem` fill, the stealth bit), §9 (gauge row, model scale), §10 (机变 card, the card tap), §14 (`left`, the pool floor, the verdict, `uniteLeft`); superseded or revised: §17.5 (the 联防 count), §18.3 (the gauge look), §18.6 #11 (skill summons), §19.2 (the melee-only blocked-first rule; 灰毫 S2), §19.5 (the gauge look).
**Follow-up (`playtest6b`, 2026-10-02).** Four more workstreams were merged into `playtest6b` in this order: the elite-to-board rule (§20.11), the 假想敌:胄 kit audit (§20.13), the Final Assault leader HP report (§20.10) and the official limits read from the client binary (§20.12). Reports: co-op 终极 / 绝境 leaders died within seconds although the official leader never dies that fast with such layers (§20.10); "官方就是合成精锐时,如果消耗了场上的干员,精锐会出现在场上那个位置" (§20.11, user-confirmed). The boss-HP and the limits workstreams both capped bond layers at 999 — one implementation remains (`shared/constants.js BOND_LAYER_CAP` / `layerGainRoom`, used by every writer), and the boss-HP workstream's reading of 限伤 as an engine fixed-point overflow "not modelled" is superseded by the binary evidence (`MAX_BATTLE_DAMAGE` 300000, the hit is cancelled). Normative lines rewritten with it: §2 (`promote.js`), §5.1 (`layerGains` capped), §5.2 (直接乘算 in `Σpct`), §5.3 (same-named debuffs), §5.4 (`addLayers`, `loseHp`), §5.5 (限伤 between the multipliers and the shields), §6.2 (where a merge's elite goes), §6.3 (the layer cap), §7 (leader parts, drones), §8.2 (`hitCap`), §9 (the promotion cue), §14 (the layer bound of client results); §20.9 records the decisions. Two QA verifiers then checked the integrated build; their residual issues are handled in §20.14.
**Follow-up (`playtest6b`, 2026-10-02).** Four more workstreams were merged into `playtest6b` in this order: the elite-to-board rule (§20.11), the 假想敌:胄 kit audit (§20.13), the Final Assault leader HP report (§20.10) and the official limits read from the client binary (§20.12). Reports: co-op 终极 / 绝境 leaders died within seconds although the official leader never dies that fast with such layers (§20.10); "官方就是合成精锐时,如果消耗了场上的干员,精锐会出现在场上那个位置" (§20.11, user-confirmed). The boss-HP and the limits workstreams both capped bond layers at 999 — one implementation remains (`shared/constants.js BOND_LAYER_CAP` / `layerGainRoom`, used by every writer), and the boss-HP workstream's reading of 限伤 as an engine fixed-point overflow "not modelled" is superseded by the binary evidence (`MAX_BATTLE_DAMAGE` 300000, the hit is cancelled). Normative lines rewritten with it: §2 (`promote.js`), §5.1 (`layerGains` capped), §5.2 (直接乘算 in `Σpct`), §5.3 (same-named debuffs), §5.4 (`addLayers`, `loseHp`), §5.5 (限伤 between the multipliers and the shields), §6.2 (where a merge's elite goes), §6.3 (the layer cap), §7 (leader parts, drones), §8.2 (`hitCap`), §9 (the promotion cue), §14 (the layer bound of client results); §20.9 records the decisions. Two QA verifiers then checked the integrated build; their residual issues are handled in §20.14. A later report after v2.5.1 — the bond strip should follow the watched teammate in every watch situation — → §20.15.
### 20.1 Summons placed by hand (#1, #2) — `tools/build-data.mjs`, `match/gamedata.js placeableTokens`, `PlayerState`, `match/bot.js`, `sim/Battle.js`, `content/tokens.js`, kits tier2/3/4, `ui/detailPanel.js`, `shared/constants.js SKILL_SUMMON_START_DEPLOY`
@@ -1015,3 +1015,83 @@ Two QA verifiers ran the integrated `playtest6b` (real 4-AI and 2-human co-op ma
- **Not changed — per-source talent debuffs [ASSUMED].** Some talents key their debuff with the source unit (`etlchi:dot:<id>`, `ines:atkSteal:<id>`, `ines:sentry:<id>`, and similar slows / DoTs in other kits), so two copies of one operator (a pair field where both players own it) each apply theirs. PRTS 作战机制 says only that same-named buffs show one instance under the *default* overlap strategy; whether these talents use it across copies is not in the data the project has, and the effect on leader kill times is small (an arts DoT, an ATK steal, a slow). The rule stays applied to the debuffs that move leader kills (奥术, 灵知 坚冰, the RES cuts — §20.10).
- **Not changed — temp items behind a full hand.** With the hand full, items overflowing into the 临时整备区 (row 8) stand behind the bench row's operator models and are hard to see; the row's red frame, the 「临时整备区 N 件道具待处理」 notice and the refused Ready still show them. A layout change of the temp row is left for a later pass.
- Tests: `test/match/clientCombat.test.js` (the held result; a 'cleared' result not covering the pool is still handed over), `test/render/playtest6-promote.test.js`, opt-in `RENDER_E2E=1 node --test test/render/browser.test.js` (a merge between two preps is cued), `test/ui/playtest6-elite.test.js` (the detail hint), opt-in `SP_E2E=1 node --test test/ui/playtest6-elite.e2e.test.js` (the hint in the real detail card).
### 20.15 The bond strip follows the watched player (user report after playtest #6, 2026-10-02) — `ui/watchBonds.js`, `ui/bondStrip.js`, `ui/combatHud.js`, `screens/game.js` (`watchWho`, `bondOpen`), `battle/runner.js bondLayers / ownerOps`, `battle/observe.js resumedWatch`, `match/bondsMeta.js bondsWithGains`, `PlayerState.pendingLayerGains / bondsView`, `Match.publicView / _finishCombat / settle`
User: 「观看队友时(不仅是休整时候,还有自己战斗结束时,联防时,最终boss战)盟约栏应该变成当前队友的盟约以及他的层数」.
**Before this change:**
- Only 前往查看 of a teammate's normal field switched the strip, and it showed the start-of-round layers.
- An eliminated player's auto-observed field, the 联防 field and the Final Assault pair field kept the viewer's own bonds.
- A teammate's popup listed the viewer's pieces as its members.
**Whose bonds** (`watchBonds.screenFieldId` / `bondOwnerId` / `screenStrip`): the player whose field or half the camera shows.
The field on screen is, in order of preference:
- the watched field (`g.watch`);
- else the battle the view shows. Under client-side combat this is the runner's battle on screen, which is also an eliminated player's auto-observed field; under server-run combat it is the field the view entered;
- else home.
In SETTLE it is the field the last battle left on screen. In prep without watching, it is the own board.
- **The own board / own battle** → your bonds (`m.private bonds`).
- **A normal field `'n:<pid>'`** → its owner's bonds (`m.public players[].bonds`). This covers:
- a teammate's board in prep (前往查看). Research 09 §3.1: "their board, read-only, including their pen and bonds". Official clients receive every board's bonds (`ChangePositionDn.boardStatus.updatedBonds`);
- a teammate's battle watched after the own one;
- the field an eliminated player auto-observes;
- a field picked in the legacy view switcher.
- **A shared field** (联防 `'u'`, boss / hidden `'b1'` / `'b2'`) **[ASSUMED — no source shows the strip there]**:
1. If the ‹ › pill points at a half that a player holds → that player. The pill's "你自己" means yours; "👁 name" means theirs.
2. Else (全景, an empty half "无人在家", or no pill), if you fight on that field → yours.
3. Else (you are not on the field: a 联防 leaker, an eliminated spectator) → never your own. The strip shows the teammate you picked with 前往查看 if they are on that field, else the field's first listed player (helper 1 / the pair's first seat).
Basis: official observing targets a player (`ObserveUp { obIndex }`, `DeadAutoObDn { obIndex }`, `m_leftBattleObIdx / m_rightBattleObIdx`), and the official non-helper pill reads "‹ 👁 helper# ›" (research 09 §3.1).
- **The picked teammate:** the game screen remembers `watchWho = { fieldId, playerId }` when a team row is tapped (`requestWatch(fid, playerId)`). The `g.watch` request still names only the field. `watchWho` counts only while that field is the watched one (`watchedPlayer`). It is cleared by 返回战场, the own row and a phase change, and restored if the server refuses the watch.
- **Camera:** picking a teammate on a two-half shared field moves the camera to their half (`playerLayer`), once per pick and per field entered. The ‹ › pill is also offered on a shared field watched with 前往查看, so the pill and the strip tag always name the same player.
- On such a field the ‹ › pill replaces the observing pill and carries 返回战场 (absent for an eliminated player).
- On a normal field the observing pill "👁 name" takes the strip owner's name.
- The default camera on entering a field is still 全景.
- **返回战场 without an own field** (client-side combat: a 联防 leaker, an eliminated player): no `g.watch` is sent, because it would be refused with `BAD_TARGET 'no such field'` (an error toast). The screen keeps the field it shows, and the strip falls back to the rules above.
- **A reload / reconnect while watching** a teammate's battle after the own one (client-side combat): the server already resends the watched field (`Match._resendBattle`, `b.start watch: true`). But a fresh screen starts with no watched field, so before this the HUD showed 「作战结束,等待队友完成作战」 with no observing pill and no 返回战场, and the own row did nothing.
- The screen now adopts that field as the watched one, once per battle (`observe.resumedWatch`, decided at the first sight of its battleId).
- It does so only for a teammate's normal battle in COMBAT, shown to a living player who watches nothing.
- A battle first seen while a watch is already set is only marked as seen. So a 返回战场 tapped while the own `b.start` is still on its way never brings the teammate back.
- A loading battle waits until it runs (its state carries no `watch` yet).
- 联防 leakers and eliminated spectators keep their own rules.
**Labelling:**
- A teammate's strip leads with an amber "👁 name" pill (the observing pill's spelling) and gets amber disc rings, `aria-label` "name 的盟约" and `data-owner`; slots carry `data-bond`.
- The pill is horizontal at every width, with a pixel floor: 10 px on desktop and tablet, 9 px below 768 CSS px (1rem is clamped at 40 px there).
- An empty teammate strip reads "name 尚未激活盟约".
**Detail card chips and the popup (whose bond a popup shows):**
- A unit card's bond chips use the bonds of `watchBonds.detailBondOwner`:
- a unit on the field → its owner, when that owner is a known player (yours or a teammate's: a shared 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 player;
- anything else (shop / reward / drawer cards, a unit without a known owner) → the strip's player.
- A bond popup carries the player it was opened for: `bondOpen = { id, ownerId, from: 'strip' | 'detail' }` (`toggleBond`). From the strip, that is the strip's player; from a chip, it is the same player the chips show. The same bond of the same player closes it.
- `popupView` builds the popup from that player, never simply from the strip:
- the entry with the live layers (as the strip and the chips show it);
- "👁 name 的盟约" for a teammate;
- the member list: your pieces, or that teammate's operators on the field on screen (`ownerBoard`: the prep scouting board's or a server-run battle field's units). Under client-side combat it also uses the battle on screen's operators (`battleRunner.ownerOps(ownerId, fieldId)`), because the field meta the runner publishes is taken before they deploy.
- Their hand is never sent, so a teammate's member counts as owned only when it is on the field.
- The strip marks a disc as open only for a popup of its own player.
- A popup opened from the strip closes when the strip changes hands: another teammate, a ‹ › half, or back to your own. A popup opened from a chip stays open, labelled with its own player.
**Layers:**
- **In battle:** the battle runner reports `state().bondLayers = { [playerId]: { [bondId]: n } }`. These are absolute live counts of every bond that grew in a battle it simulates (the own battle, a teammate's display replica), capped at `BOND_LAYER_CAP` (999). They are published when one grows and kept through the round.
- The client lays them over the view's counts with `withLiveLayers`: the higher value wins (a count only grows) and never shows above 999. They apply in the battle phases and SETTLE only.
- 联防 and boss battles disable gains, so they report none.
- **After COMBAT** (`_finishCombat`): until SETTLE, both `m.private bonds` and `m.public players[].bonds` add the finished normal battle's IN_BATTLE gains (`PlayerState.pendingLayerGains` = the result's `layerGains`; `bondsMeta.bondsWithGains`: floored, at most up to the cap, like `settle()`). The strip of a player, and of a teammate watching him in the 联防, therefore keeps the layers his battle reached.
- This is views only: `ps.bonds` / `ps.layers` are untouched. SETTLE clears the pending gains as it adds them (once), and the next round start clears them too.
**Data:**
- `m.public players[].bonds = ps.alive ? bondList(gd, ps.bondsView()) : []`: every bond with members, layers or an active tier. It is the same list and order as the player's own `m.private bonds`, minus `thresholds` / `countsHand` (the client reads those from bonds.json).
- An eliminated player's list is `[]`, for these reasons:
- nobody can watch them: `g.watch` refuses them and they have no field;
- the result screen reads `m.result`'s own bonds;
- their remaining layers would otherwise cost every `m.public` for the rest of the match.
- Measured in a 4-AI NORMAL harness match (seed 11, round 13, 3 players eliminated): `players[].bonds` is 771 B, against 2424 B without this rule. The review measured 705 B on v2.5.1; the difference is the living player's layers-only entries, which the strip needs.
- There is no new message and no new field. `m.public` stays throttled and deduplicated, so the extra traffic is the living players' layers-only entries plus one change at the end of COMBAT.
- Nothing is exposed that scouting (research 09 §3.1) or watching the battle does not already show.
+26 -3
View File
@@ -47,7 +47,8 @@ LOBBY → INFO_CHECK (co-op 25 s; solo and single-human matches untimed; all hum
(band_cannot keeps them), boss round: Σ activated layers recorded for the hidden-core check
COMBAT one Battle per alive player (FieldRunner, 2× game speed; limit = 2 × maxPlayTime game s, §3)
UNITE co-op, ≥ 1 leaker and ≥ 1 perfect player (§4)
SETTLE (3 s) LP, coins, layers, bounties, eliminations, onBattleResult
SETTLE (3 s) LP, coins, layers (the views showed them since the end of COMBAT, §5), bounties, eliminations,
onBattleResult
boss round → FINAL_ASSAULT (instead of COMBAT/UNITE/SETTLE) hidden round → HIDDEN_CORE
→ RESULT (m.result to every human, then onEnd once)
```
@@ -149,7 +150,18 @@ fields' `startAt` / `lastProgressAt` and the boss start by the paused time and r
Client-side combat (the default, DESIGN §14 Spectating; `Match._watchClient`): `g.watch` answers with the field's
`b.start` (a display replica fast-forwarded to the field clock); an alive player may not watch another normal field
while its own normal battle runs (`WRONG_PHASE 'own battle running'`) nor the other pair's boss field (`BAD_TARGET 'other
group hidden'`); eliminated players watch anything. The rest of this section is the legacy server-run mode
group hidden'`); eliminated players watch anything. Whatever is watched, the viewer's bond strip shows the bonds of the
player on screen from that player's `m.public players[].bonds` (§5) plus the replica's live layers (DESIGN §20.15) — no
extra message. On a shared field (联防 / a pair's boss field) the client remembers the teammate picked with 前往查看
(the `g.watch` request still names the field) and frames that player's half; a viewer who does not fight on the field
never gets its own strip there. A detail card's bond chips — and the popup a chip opens — follow the unit's owner
(the popup carries the player it was opened for); a teammate's popup lists as members their operators in the battle on
screen (the runner's `ownerOps`, its field meta predating their deploy). A client-side 返回战场 without an own field to return to (a 联防 leaker, an eliminated
player) sends no `g.watch` (it would be refused with `BAD_TARGET 'no such field'`). A reload / reconnect while watching a
teammate's battle after the own one gets the watched field again (`_resendBattle`, `b.start watch: true`); the fresh
screen adopts it as the watched field once per battle (`battle/observe.js resumedWatch`: a living player's teammate
normal battle in COMBAT, first seen while watching nothing), so the observing pill, 返回战场 and the own row work again.
The rest of this section is the legacy server-run mode
(`SP_COMBAT=server`):
`g.watch { fieldId }`: any live field during COMBAT / 联防; while no battle field is up (PREP, drafts, SETTLE)
`'n:<pid>'` returns a one-shot board view — during a battle phase an `'n:<pid>'` id must name a live field
@@ -534,7 +546,11 @@ leaker's own capsule / row (`ui/hud.js uniteRemaining`) and the leakers' team ro
drawn set; `disabledBonds` = drawn ∪ the mode's static list), `hiddenBossId`, `bossRound`, `hiddenRound`, `spRound`,
`combatMode` (`'client'` | `'server'`), `fields[].progress { killed, total, done }` (teammates' progress UI), `paused`
(solo pause, §1.3a),
`players[].autoplay`, `players[].uniteLeft` (UNITE, leakers only: their enemies still standing, uncapped — §4), and per phase: `draft { order, turn, picks, skipsLeft, turnDeadline, turnSeconds, untimed }` (BAND_DRAFT),
`players[].autoplay`, `players[].uniteLeft` (UNITE, leakers only: their enemies still standing, uncapped — §4),
`players[].bonds` = `ps.alive ? bondList(gd, ps.bondsView()) : []` — every bond with members, layers or an active tier, the same list
and order as the player's own `m.private bonds` minus `thresholds` / `countsHand` (the client reads those from
bonds.json): a teammate watching the player shows it in the bond strip (DESIGN §20.15); `[]` once the player is
eliminated (nobody can watch them; the result screen reads `m.result`'s own bonds), and per phase: `draft { order, turn, picks, skipsLeft, turnDeadline, turnSeconds, untimed }` (BAND_DRAFT),
`sp { family, name, desc, eventId, cards:[{ idx, kind:'bounty'|'item'|'tactic', id, name, desc, tier, descRaw?, coin?,
payout?, rounds?, enemyKey?, count?, price?, team?, tacticKind? }], order, turn, picks:{pid: idx}, taken:{idx: pid}, untimed }`
(SP_DRAFT), `teamLp` / `bossHp {hp,max}` (Final Assault on), `overtimeAt` (最终攻势 / 隐秘核心: ms epoch when the
@@ -545,6 +561,13 @@ PREP ready/acting · COMBAT/boss combat/done · UNITE helping/done · others don
`m.private` = DESIGN §8.3 exactly (sent per player whenever it changed). `nextEnemies` = the current round's wave
(+ the player's bounty enemies, tag `bounty`; boss rounds: the player's boss field, tag `boss`, + its bounties).
Bond layers in the views (DESIGN §20.15): from the end of COMBAT (`_finishCombat`, every normal result in) until SETTLE,
`m.private bonds` and `m.public players[].bonds` add the finished battle's IN_BATTLE gains (`PlayerState.pendingLayerGains`
= the result's `layerGains`; `bondsMeta.bondsWithGains`: floored, at most up to `BOND_LAYER_CAP`, like the settlement), so
the strip keeps the layers the battle reached through the COMBAT_END pause and the 联防. Views only: `ps.bonds` /
`ps.layers` (rules, the 联防 spec, `activatedLayers`) are untouched; SETTLE clears the pending gains as it adds them to
`ps.layers` (once); the next round start clears them too.
`m.field` = `{ fieldId, kind, rect, stageId, units, live }`; during prep `g.watch 'n:<pid>'` returns a one-shot board
view with `prep: true` (scouting a teammate).
+2 -1
View File
@@ -95,7 +95,7 @@
- **核心盟约**(8 个势力:炎、萨尔贡、维多利亚、谢拉格、拉特兰、阿戈尔、叙拉古、卡西米尔):3 名激活,6 名获得强力的额外效果。
- **附加盟约**(精准、迅捷、灵巧、奥术、坚守、助力、远见、奇迹、投资人、突袭、不屈、调和、协防干员、独行、绝技):多为 2 / 3 名激活;「独行」需要场上**恰好 1 名**,「绝技」按场上的精锐数量计算,没有势力盟约的干员算「协防干员」。
- **炎佑**:场上 6 名以上「炎」干员时召唤炎佑(9 名时两只)。它追踪全场仇恨最高的敌人并悬停在其上方,技能「祛恶之焰」锁定 1 个目标**持续喷火**(最长 20 秒,每秒对目标周围的敌人造成法术伤害并附带灼燃,喷火时不普攻,目标死亡或被沉默时结束);场上暂时没有敌人时停在原地等待。它持有官方的「孤立」:医疗干员不会治疗它,友方的增益、光环和天赋也不会选中它(敌人照常可以攻击它)。
- **层数**:盟约通过各种效果叠加层数,层数越高效果越强(例如「炎」攻击力随层数提升、「远见」每 10 层给资金)。**层数整局保留,但只有盟约激活时才生效**,多数情况下也只有激活的盟约能叠层。每个盟约最多 **999 层**(与官方相同,取自官方客户端的常量):到 999 后再获得的层数直接作废,按层数发放的奖励(如「远见」的资金)也随之停止。
- **层数**:盟约通过各种效果叠加层数,层数越高效果越强(例如「炎」攻击力随层数提升、「远见」每 10 层给资金)。**层数整局保留,但只有盟约激活时才生效**,多数情况下也只有激活的盟约能叠层。每个盟约最多 **999 层**(与官方相同,取自官方客户端的常量):到 999 后再获得的层数直接作废,按层数发放的奖励(如「远见」的资金)也随之停止。作战中获得的层数在盟约栏里**实时**显示,作战结束后一直保留到结算(联防期间也不会退回去)。
- **加成怎么叠**:盟约、策略、装备、机变卡和特质给的「攻击力 / 防御力 / 生命值 +X%」与技能的「攻击力 +X%」**相加**后再乘到属性上(官方:直接乘算),例如精准 +130%、独行 +60%、一件 +40% 的装备合计 +230%,而不是 ×2.3×1.6×1.4。「伤害提升至 X%」「受到的伤害 +X%」(维多利亚、谢拉格、奥术、脆弱等)这类伤害倍率之间仍然相乘;但同名效果只算一个——同一战场两名玩家都开了「奥术」时,敌人身上只挂一层奥术易伤,取两人中较高的那一个(不再相乘);两名「灵知」的坚冰、两名「莱恩哈特」/「缄默德克萨斯」的法抗削减也一样只算最强的一个。「坚守」的反伤计入被打的那名成员(个人对领袖伤害统计里能看到)。
- 每局会随机**禁用**若干盟约(标准模拟较少),它们的干员不会出现在商店;开局的「确认本局信息」会列出。
- 两件同名装备会合成一件更强的装备;特定的装备组合可以为干员赋予额外的盟约效果。
@@ -114,6 +114,7 @@
- 左下角「交流」打开表情面板:6 套主题 × 6 个表情,左右滑动(或方向键)换主题。表情以气泡形式显示在你头像旁 3 秒,发送冷却 1 秒。
- 休整期、或自己的作战结束后(显示「作战结束,等待队友完成作战」),点左侧队友头像 →「前往查看」观看他的战场;自己的作战进行中不能观战。联防和最终攻势中可以用 ‹ › 切换左半 / 全景 / 右半。
- 被淘汰后可以自由观看任何战场。
- **观看谁,盟约栏就显示谁的盟约**:查看队友的阵地或战场时(休整期、作战结束后、被淘汰后自动观战),左上的盟约栏换成**该队友的盟约、人数和层数**,前面有琥珀色的「👁 队友名」标签,点开的盟约详情也写着「队友名 的盟约」(成员一栏只标出他在场上的干员,整备区看不到);作战中层数实时增长(与他自己看到的一致)。联防和最终攻势里跟随 ‹ ›:镜头在队友的半场时显示他的盟约,在你自己的半场时显示你自己的;「全景」时,你在这个战场上作战就显示你自己的,你不在这个战场上(漏怪后观看联防、被淘汰后观战)则显示你点「前往查看」选中的那名队友,没选过就显示该战场的第一名玩家(1 号联防者 / 这一组的第一位),**不会显示你自己的**。在这样的战场上点某名队友的头像 →「前往查看」,镜头会直接移到他的半场,‹ › 上的「👁 队友名」与盟约栏的标签一致;‹ › 旁边有「返回战场」(被淘汰时没有)。盟约栏换人时,从盟约栏打开的盟约详情会自动关闭。点开场上某个干员的详情卡时,卡上的盟约(以及点它打开的盟约详情)属于**这个干员的主人**,与盟约栏当前显示谁无关——例如在「全景」点队友的干员,看到的是「队友名 的盟约」及他的人数和层数;在队友的半场点你自己的干员,看到的是你自己的。回到自己的阵地 / 战场后恢复成你自己的盟约。作战结束后观看队友时刷新页面 / 断线重连,仍会回到正在观看的队友战场,「👁 队友名」和「返回战场」都还在。已被淘汰的队友不再显示盟约(也无法被查看)。
## 9. 结算称号
+3
View File
@@ -232,6 +232,9 @@
.bpop__disc.is-active .bglyph img { filter: brightness(0) opacity(.85); }
.bpop__titles { display: flex; flex-direction: column; gap: .02rem; min-width: 0; }
.bpop__name { font-size: .32rem; font-weight: 900; letter-spacing: .08em; }
.bpop__owner { display: inline-flex; align-items: center; gap: .06rem; font-size: .14rem; color: var(--amber); }
.bpop__owner b { color: #fff; font-weight: 700; max-width: 2.4rem; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.bpop.is-other .bpop__disc { box-shadow: 0 0 0 1px rgba(246, 163, 41, .55); }
.bpop__facts { display: flex; gap: .16rem; font-size: .15rem; color: var(--text-lo); }
.bpop__facts b { color: #fff; font-size: .18rem; }
.bpop__facts small { font-size: .12rem; }
+16 -2
View File
@@ -229,8 +229,20 @@
.bstrip { display: flex; align-items: flex-start; gap: .1rem; flex-wrap: nowrap; }
.bstrip.is-frozen .bslot { opacity: .5; filter: saturate(.4); }
.bstrip--empty { display: inline-flex; align-items: center; gap: .12rem; padding: .08rem .16rem; font-size: .15rem; color: var(--text-dim); background: rgba(8, 11, 10, .6); border: 1px dashed var(--line-2); }
.bstrip__owner { align-self: center; writing-mode: vertical-rl; padding: .06rem .02rem; border: 1px solid rgba(246, 163, 41, .5); background: rgba(40, 26, 6, .7); }
.bstrip__owner .micro { color: var(--amber); letter-spacing: .1em; font-size: .12rem; }
/* a watched teammate's bonds (DESIGN §20.15): an amber "👁 name" pill in front of the discs, amber rings. Horizontal at
every width with a pixel floor (a vertical .12rem name was ~6-8 px per glyph on desktop / tablet, ~5 px on phones
where 1rem is clamped at 40 px); centred on the discs, clear of the team panel below */
.bstrip__owner { align-self: center; flex: none; display: inline-flex; flex-direction: row; align-items: center; gap: max(.04rem, 3px);
padding: max(.03rem, 2px) max(.06rem, 4px); border: 1px solid rgba(246, 163, 41, .5); background: rgba(40, 26, 6, .7); }
.bstrip__owner .micro { color: var(--amber); font-size: max(.15rem, 10px); letter-spacing: .04em; max-width: max(1.3rem, 72px);
overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.bstrip__eye { color: var(--amber); font-size: max(.17rem, 11px); flex: none; }
.bstrip--empty .bstrip__owner .micro { max-width: max(1.6rem, 80px); }
.bstrip--empty.is-other { border-color: rgba(246, 163, 41, .5); }
@media (max-width: 767px) {
.bstrip__owner .micro { font-size: max(.12rem, 9px); }
.bstrip__eye { font-size: max(.15rem, 10px); }
}
.bstrip.is-other .bond__core { box-shadow: 0 0 0 1px rgba(246, 163, 41, .45); }
.bstrip__more { align-self: center; font-size: .16rem; color: var(--text-lo); padding: .04rem .08rem; border: 1px solid var(--line-2); background: rgba(8, 11, 10, .7); }
.bslot { position: relative; display: flex; flex-direction: column; align-items: center; width: .74rem; }
@@ -454,6 +466,8 @@
.chud__layers .vswitch__label .icon { width: .22rem; height: .22rem; color: var(--amber); }
.chud__back { flex: none; }
.chud__layers { border-color: var(--mint-700); }
/* a 联防 / 最终攻势 field watched with 前往查看: the ‹ › pill carries 返回战场 (DESIGN §20.15) */
.chud__layers.is-observing { gap: .1rem; padding-right: .08rem; border-color: rgba(246, 163, 41, .55); }
@keyframes spin { to { transform: rotate(360deg); } }
/* ---- settings modal ------------------------------------------------------------------------------------------------ */
+25
View File
@@ -55,6 +55,31 @@ export function observeTarget(p, pub, myId, { observing = false, ownDone = false
return { fieldId: target.fieldId };
}
/**
* A screen reloaded (or a socket reconnected) while it watched a teammate's battle after the own one: the server
* resends the watched field (Match._resendBattle, b.start `watch: true`), but a fresh screen starts at home — so the
* observing pill, 返回战场 and the own row would be missing. Decided once per battle, at the first sight of its
* battleId (`seen` = the battleId already decided): adopt `fieldId` as the watched field when it is a teammate's normal
* battle in 各自行动 shown to a living player who watches nothing. Never for a battle first seen while watching (a
* 前往查看 under way — and its 返回战场 must not bring it back while the own b.start is on its way), the own field, a
* 联防 leaker's field or an eliminated player's auto-observed one (those keep their own rules). A loading battle (its
* state carries no `watch` yet) is decided once it runs, unless the screen already watches (then it is marked seen).
* @param {any} b battleRunner.state() @param {{ pub?: any, myId?: string, alive?: boolean, watching?: string|null,
* seen?: string|null }} [o]
* @returns {{ seen: string|null, fieldId: string|null }} seen: the new value to remember
*/
export function resumedWatch(b, { pub = null, myId = '', alive = true, watching = null, seen = null } = {}) {
const keep = { seen, fieldId: null };
if (!isObj(pub) || !isObj(b) || typeof b.battleId !== 'string' || !b.battleId || b.battleId === seen) return keep;
if (b.loading && !watching) return keep;
const done = { seen: b.battleId, fieldId: null };
if (b.loading || watching || !alive || !isClientCombat(pub) || pub.phase !== PHASE.COMBAT) return done;
if (!b.watch || b.kind !== 'normal' || typeof b.fieldId !== 'string' || !b.fieldId) return done;
const f = fields(pub).find((x) => x.fieldId === b.fieldId);
if (!f || f.kind !== 'normal' || !Array.isArray(f.players) || f.players.includes(myId)) return done;
return { seen: b.battleId, fieldId: b.fieldId };
}
/** Teammates' progress for the waiting pill: [{ playerId, name, killed, total, done, isBot }]. */
export function teammateProgress(pub, myId) {
const out = [];
+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. */
+103 -19
View File
@@ -48,6 +48,13 @@
// reward card armed for a purchase that completes a merge lights, in gold, the board tile its elite will take (the
// deployed copy that deploys first, gameLogic.mergeTarget — none when no copy is deployed: the elite goes to the hand);
// the elite then appears there with the promotion cue (render/app.js setPrep → fx.promote).
// The bond strip, its popup and a unit card's bond chips follow the player on screen (DESIGN §20.15, ui/watchBonds.js):
// a scouted / watched / auto-observed teammate's field → that teammate's bonds (m.public players[].bonds, tagged
// "👁 name"); 联防 / 最终攻势 → the player on the ‹ › half (全景: yours when you fight there, else the teammate picked
// with 前往查看 — `watchWho` — or the field's first player, never the viewer's own); in battle with the live layers of
// the battle on screen (battle/runner.js state().bondLayers). Picking a teammate on such a field (a 联防 leaker, an
// eliminated spectator) frames their half and keeps the ‹ › pill (with 返回战场); an open bond popup closes when the
// strip changes hands.
import { useCallback, useEffect, useMemo, useRef, useState } from '../../vendor/hooks.module.js';
import { PHASE, GEO } from '../../../shared/constants.js';
@@ -89,7 +96,8 @@ import { ResultScreen } from './result.js';
import { net } from '../net.js';
import { store, useStore, shallowEqual, serverNow, emptyMatch } from '../store.js';
import { battleRunner } from '../battle/runner.js';
import { isClientCombat, observeTarget, teammateProgress, cameraLayers, layerCamera, sidesOf } from '../battle/observe.js';
import { isClientCombat, observeTarget, teammateProgress, cameraLayers, layerCamera, sidesOf, resumedWatch } from '../battle/observe.js';
import { screenStrip, playerBonds, playerLayer, detailBondOwner, toggleBond, popupView } from '../ui/watchBonds.js';
import { data, localAsset } from '../data.js';
import { audio } from '../audio.js';
import { useDocClass, FullscreenButton } from '../ui/device.js';
@@ -176,8 +184,9 @@ function MatchScreen() {
const { view, kind: viewKind } = useFieldView(hostRef);
const [watching, setWatching] = useState(null); // fieldId the player chose to watch (null = home)
const [watchWho, setWatchWho] = useState(null); // { fieldId, playerId }: the teammate picked with 前往查看
const [drawer, setDrawer] = useState(null); // 'enemies' | 'info' | null
const [bondOpen, setBondOpen] = useState(null);
const [bondOpen, setBondOpen] = useState(null); // { id, ownerId, from }: the bond popup and whose bond it shows
const [detail, setDetail] = useState(null); // detail target
const [collapsed, setCollapsed] = useState(false);
const [rewardMin, setRewardMin] = useState(false);
@@ -249,7 +258,7 @@ function MatchScreen() {
priv, stage: gd.stage(pub?.stageId), editable, field: deployField,
getChess: gd.chess, getToken: gd.token, getItem: gd.item, getEffect: gd.effect,
}), [priv, pub?.stageId, editable, gd.ready, deployField]);
live.current = { pub, priv, field, editable, placeCtx, watching, home, myId, detail, drawer, bondOpen, emoteOpen, settingsOpen, exitOpen, drag, facing, sel, pen, collapsedNow: collapsed, localDone: false, canPause: false, paused };
live.current = { pub, priv, field, editable, placeCtx, watching, watchWho, home, myId, detail, drawer, bondOpen, emoteOpen, settingsOpen, exitOpen, drag, facing, sel, pen, collapsedNow: collapsed, localDone: false, canPause: false, paused };
// ---- camera: every request goes through setCam, which remembers it for the pen's way back -----------------------
// the own prep board: the normal board, or — in the prep of a boss round — the player's half of the boss field
@@ -470,6 +479,23 @@ function MatchScreen() {
setCam(kind, layerCamera(field, layer, sidesOf(field)[myId] || 'L'));
}, [layer]);
// 前往查看 of a teammate on a two-half shared field (a 联防 leaker / an eliminated spectator tapping a helper or a pair
// player): the camera goes to that player's half, so the ‹ › pill reads "👁 name" like the strip's tag (DESIGN §20.15;
// official observing targets a player, research 09 §3.1). Once per pick and per field entered (entering resets 全景).
const whoAppliedRef = useRef({ who: null, field: null });
useEffect(() => {
const who = watchWho;
if (!view || !who || !field || field.fieldId !== who.fieldId || !field.local || !isCombatPhase(phase)) return;
const A = whoAppliedRef.current;
if (A.who === who && A.field === field) return;
whoAppliedRef.current = { who, field };
const k = playerLayer(field, pub, myId, who.playerId);
if (!k) return;
setLayer(k);
// the camera directly too: when the layer state already equals k the [layer] effect would not run
setCam(field.kind === 'hidden' ? 'boss' : field.kind, layerCamera(field, k, sidesOf(field)[myId] || 'L'));
}, [view, watchWho, field, phase]);
// graceful degradation: without WebGL / the render engine the DOM view takes over — say so once per match
useEffect(() => {
if (viewKind !== 'fallback') return;
@@ -496,6 +522,7 @@ function MatchScreen() {
else if (phase === PHASE.HIDDEN_CORE) audio.sfx('bossRoundSecret');
else if (phase === PHASE.SP_DRAFT) audio.sfx('draft');
setWatching(null); // the server resets every watcher to its own field on phase changes
setWatchWho(null);
// the pen is a 休整期 view: leaving prep returns the camera (the next setCam would, too)
if (phase !== PHASE.PREP && penRef.current.on) togglePenRef.current(false);
// a battle unit's panel (live HP of a unit of the fight that just ended) never outlives its battle
@@ -506,6 +533,16 @@ function MatchScreen() {
setSpBusy(null);
}, [phaseKey]);
// a reload / reconnect while watching a teammate's battle after the own one (client-side combat): the server resends
// the watched field, the fresh screen adopts it as watched once per battle — the observing pill, 返回战场 and the own
// row work again, and the bond strip's "👁 name" matches the HUD (battle/observe.js resumedWatch)
const seenBattleRef = useRef(null);
useEffect(() => {
const r = resumedWatch(battleState, { pub, myId, alive, watching, seen: seenBattleRef.current });
seenBattleRef.current = r.seen;
if (r.fieldId) setWatching(r.fieldId);
}, [battleState?.battleId, battleState?.loading, !!pub]);
// timer ticks (≤ 10 s) while the player still has something to do
const cd = countdownState(pub?.deadline, serverNow(), total);
useTicker(cd.remain != null && cd.remain <= 10 ? 1000 : 0);
@@ -576,19 +613,34 @@ function MatchScreen() {
// The watched field only changes once the server accepted g.watch: a refused target (an eliminated teammate, the
// other pair's boss field) must not move the eye icon / switcher label away from what is actually on screen.
const requestWatch = useCallback(async (fid) => {
// `playerId`: the teammate picked (a team row) — a shared field shows two players, the strip follows the picked one
// (DESIGN §20.15); null for a field picked as such (the legacy switcher).
const requestWatch = useCallback(async (fid, playerId = null) => {
const prev = live.current.watching;
const prevWho = live.current.watchWho;
const who = playerId ? { fieldId: fid, playerId } : null;
setWatching(fid);
setWatchWho(who);
const ok = await actions.watch(fid);
if (!ok) setWatching((w) => (w === fid ? prev : w));
if (!ok) {
setWatching((w) => (w === fid ? prev : w));
setWatchWho((w) => (w === who ? prevWho : w));
}
return ok;
}, []);
/** Back to the own field (返回战场 / the own row). */
const backHome = useCallback(() => {
const L = live.current;
if (L.watching && L.watching !== L.home) actions.watch(isCombatPhase(L.pub?.phase) ? L.home : ownFieldId(L.myId));
if (L.watching && L.watching !== L.home) {
const target = isCombatPhase(L.pub?.phase) ? L.home : ownFieldId(L.myId);
// client-side combat without an own field to go back to (a 联防 leaker, an eliminated player): the screen keeps
// the field it shows — g.watch of a field that does not exist would only be refused (an error toast)
const exists = !isClientCombat(L.pub) || !isCombatPhase(L.pub?.phase) || (Array.isArray(L.pub?.fields) && L.pub.fields.some((f) => f && f.fieldId === target));
if (exists) actions.watch(target);
}
setWatching(null);
setWatchWho(null);
}, []);
const watchPlayer = useCallback((p) => {
@@ -598,18 +650,19 @@ function MatchScreen() {
const t = observeTarget(p, L.pub, L.myId, { observing, ownDone: L.localDone });
if (t.back) { backHome(); return; }
if (t.reason) { toast(t.reason, 'warn'); audio.sfx('error', { volume: 0.5 }); return; }
if (t.fieldId) requestWatch(t.fieldId);
if (t.fieldId) requestWatch(t.fieldId, p.playerId);
return;
}
const self = p.playerId === L.myId;
if (self) {
if (L.watching && L.watching !== L.home) actions.watch(isCombatPhase(L.pub?.phase) ? L.home : ownFieldId(L.myId));
setWatching(null);
setWatchWho(null);
return;
}
const t = watchTarget(p, L.pub, L.myId);
if (t.reason) { toast(t.reason, 'warn'); audio.sfx('error', { volume: 0.5 }); return; }
requestWatch(t.fieldId);
requestWatch(t.fieldId, p.playerId);
}, []);
const watchField = useCallback((fid) => { requestWatch(fid); }, []);
@@ -993,14 +1046,40 @@ function MatchScreen() {
// client-side combat: observing a teammate's battle (research 09 §3.1) and the 联防 / 最终攻势 camera halves
// (an eliminated player auto-observes a teammate's normal field — research 09 "keep-watching" — without asking)
const watchedFid = watchingOther ? watching : (cc && combat && !alive && battleState && battleState.watch && battleState.kind === 'normal' ? battleState.fieldId : null);
const observingName = cc && combat && watchedFid ? (players.find((p) => p.fieldId === watchedFid || ownFieldId(p.playerId) === watchedFid)?.name || '队友') : null;
const layers = cc && combat && field && field.local && !watchingOther ? cameraLayers(field, pub, myId) : [];
// the ‹ › pill: on the 联防 / 最终攻势 field on screen — also one watched with 前往查看 (a leaker, an eliminated spectator)
const layers = cc && combat && field && field.local && (!watchingOther || field.fieldId === watching) ? cameraLayers(field, pub, myId) : [];
const progress = cc && phase === PHASE.COMBAT ? teammateProgress(pub, myId) : null;
// watching a teammate's normal field: the strip shows their bonds (m.public players[].bonds)
// the bond strip follows the player on screen (DESIGN §20.15, ui/watchBonds.js): a teammate's board / battle (前往查看,
// an eliminated player's auto-observed field) → their bonds; a 联防 / 最终攻势 field → the player on the ‹ › half (全景:
// yours when you fight there, else the teammate picked with 前往查看 / the field's first player); in battle with the
// live layers of the battle on screen (the runner's bondLayers)
const settleMode = mode === 'settle';
const strip = screenStrip({
pub, priv, myId, combat, settle: settleMode, watchingOther, watching, home,
battleFieldId: cc ? (battleState?.fieldId || null) : (combat || settleMode ? lastFieldRef.current : null),
field, layers, layer, who: watchWho, bondLayers: battleState?.bondLayers || null,
});
const stripFid = strip.fieldId;
const liveLayers = (combat || settleMode) && battleState?.bondLayers ? battleState.bondLayers : null;
// the observing pill names the player whose bonds the strip shows (the same teammate as the strip's "👁 name" tag)
const observingName = cc && combat && watchedFid ? (!strip.self && stripFid === watchedFid ? strip.name : (players.find((p) => p.fieldId === watchedFid || ownFieldId(p.playerId) === watchedFid)?.name || '队友')) : null;
const watchedP = watchingOther ? (players.find((p) => p.playerId !== myId && (watching === ownFieldId(p.playerId) || (watching === p.fieldId && String(watching).startsWith('n:')))) || null) : null;
const watchedName = watchingOther ? (watchedP?.name || players.find((p) => watching === p.fieldId)?.name || '队友') : null;
const stripBonds = watchedP ? (watchedP.bonds || []) : (priv?.bonds || meP?.bonds || []);
const bondEntry = bondOpen ? stripBonds.find((b) => b.bondId === bondOpen) : null;
const stripBonds = strip.bonds;
// a popup opened from the strip closes when the strip changes hands (another teammate scouted / a ‹ › half / back to
// the own bonds); one opened from a card's chip keeps its unit owner (it carries its own player either way)
const stripOwnerRef = useRef(strip.ownerId);
useEffect(() => {
if (stripOwnerRef.current === strip.ownerId) return;
stripOwnerRef.current = strip.ownerId;
setBondOpen((b) => (b && b.from === 'strip' ? null : b));
}, [strip.ownerId]);
// the popup shows the player it was opened for (ui/watchBonds.js popupView): their entry + live layers, their name,
// and as members your pieces or their operators on the field on screen — under client-side combat the battle's own
// (the runner's field meta is taken before they deploy); their hand is never sent
const popOps = cc && battleRunner && field?.local && bondOpen && bondOpen.ownerId !== myId ? battleRunner.ownerOps(bondOpen.ownerId, field.fieldId) : null;
const bondPop = popupView({ open: bondOpen, pub, priv, myId, field, units: popOps, live: liveLayers });
const openBond = (id, ownerId, from) => { setBondOpen((b) => toggleBond(b, id, ownerId, from)); audio.sfx('click', { volume: 0.4 }); };
const watchingNow = combat ? (watching || field?.fieldId || home) : watching;
const shopOpen = showShop && !collapsed;
const ufShown = !!(selEntry && editable && !facing && !drag && showPrep && ufGeo);
@@ -1051,6 +1130,11 @@ function MatchScreen() {
}
return priv?.loadout ?? null; // own pieces, shop / reward / bond-member cards
})();
// the card's bond chips — and the popup a chip opens: a battle / scouted unit's OWNER's bonds (yours, or that
// teammate's — m.public + live layers), your own piece's yours, a popup member card the popup's player; shop / reward
// cards read the strip's (ui/watchBonds.js detailBondOwner)
const detailOwner = detailBondOwner(detailTarget, { pub, myId, stripOwnerId: strip.ownerId });
const detailBonds = detailOwner === strip.ownerId ? stripBonds : playerBonds({ pub, priv, myId, ownerId: detailOwner, live: liveLayers });
return html`<div class=${cx('screen', 'gm', `gm--${mode}`, drag && 'is-dragging', collapsed && 'is-collapsed', sp && 'has-sp', pen && 'is-pen', readyWhy && 'has-readywhy')}
data-camera=${pen ? 'pen' : camKind}>
@@ -1069,8 +1153,8 @@ function MatchScreen() {
live=${liveLpNow} />
<div class="gm__bonds">
<${BondStrip} bonds=${stripBonds} layersDisabled=${layersDisabled} openId=${bondOpen} owner=${watchedP ? watchedP.name : null}
onOpen=${(id) => { setBondOpen((b) => (b === id ? null : id)); audio.sfx('click', { volume: 0.4 }); }} />
<${BondStrip} bonds=${stripBonds} layersDisabled=${layersDisabled} openId=${bondPop && bondPop.ownerId === strip.ownerId ? bondPop.bondId : null}
owner=${strip.name} onOpen=${(id) => openBond(id, strip.ownerId, 'strip')} />
</div>
<${TeamPanel} pub=${pub} myId=${myId} watching=${watchingNow} bubbles=${bubbles} onWatch=${watchPlayer} cap=${gd.config?.lpCapPerRound ?? 10} uniteLocal=${uniteLocal}
@@ -1114,13 +1198,13 @@ function MatchScreen() {
${drawer ? html`<${EnemyDrawer} tab=${drawer} onTab=${setDrawer} pub=${pub} priv=${priv} onClose=${() => setDrawer(null)}
onEnemy=${(k, n) => setDetail({ kind: 'enemy', id: k, count: n })} onChess=${(id) => setDetail({ kind: 'chess', id })} />` : null}
${bondOpen ? html`<${BondPopup} bondId=${bondOpen} entry=${bondEntry} priv=${priv} banned=${pub?.bannedChess || []}
${bondPop ? html`<${BondPopup} bondId=${bondPop.bondId} entry=${bondPop.entry} priv=${bondPop.priv} banned=${pub?.bannedChess || []} owner=${bondPop.name}
place=${bpPlace} over=${!!resolved && bpPlace === dSide}
onClose=${() => setBondOpen(null)} onMember=${(id) => setDetail({ kind: 'chess', id })} />` : null}
onClose=${() => setBondOpen(null)} onMember=${(id) => setDetail({ kind: 'chess', id, owner: bondPop.ownerId })} />` : null}
${resolved ? html`<${DetailPanel} detail=${resolved} snapHp=${snapHp} onClose=${() => { setDetail(null); setSel(null); }}
bonds=${stripBonds} loadout=${detailLoadout} side=${dSide} shopOpen=${shopOpen} live=${liveStats}
onBond=${(id) => { setBondOpen((b) => (b === id ? null : id)); audio.sfx('click', { volume: 0.4 }); }} />` : null}
bonds=${detailBonds} loadout=${detailLoadout} side=${dSide} shopOpen=${shopOpen} live=${liveStats}
onBond=${(id) => openBond(id, detailOwner, 'detail')} />` : null}
${selEntry && editable && !facing && !drag && showPrep ? html`<${Underframe} key=${sel.uid} view=${view} uid=${sel.uid}
row=${pieceTile(selEntry)?.row} col=${pieceTile(selEntry)?.col} actions=${underframeActions(placeCtx, sel.uid)} busy=${selBusy}
+26 -10
View File
@@ -4,9 +4,12 @@
// the detail panel's bond chips it docks beside that panel (`beside`: the panel's side).
// Research 06 §11.1: round mint discs, stack count over the disc, name below, sorted by stacks; grey =
// present but inactive; in 联防 / boss rounds the strip is dimmed ("层数叠加已禁用").
// Watching a teammate (DESIGN §20.15, ui/watchBonds.js) the strip and the popup show THAT player's bonds and layers:
// the strip carries an amber "👁 name" tag (`owner`, the observing pill's spelling, research 09 §3.1) and amber rings,
// the popup a "👁 name 的盟约" line; its member list reads the teammate's operators on the field (no hand: never sent).
import { html, BondDisc, Icon, MicroLabel, Tooltip } from './components.js';
import { RichText, UnitThumb, BondGlyph } from './gameComponents.js';
import { RichText, UnitThumb, BondGlyph, GIcon } from './gameComponents.js';
import { sortBonds, bondMembers, nextThreshold, bondTier } from './gameLogic.js';
import { formatBondEffect } from './richText.js';
import { bondIconUrl } from './assetUrls.js';
@@ -14,23 +17,33 @@ import { data } from '../data.js';
const cx = (...p) => p.flat().filter(Boolean).join(' ');
/** The "👁 name" tag of a teammate's strip (DESIGN §20.15). */
function OwnerTag({ owner }) {
return html`<span class="bstrip__owner" title=${`正在查看 ${owner} 的盟约`} data-owner=${owner}>
<${GIcon} name="eye" class="bstrip__eye" /><${MicroLabel}>${owner}</${MicroLabel}>
</span>`;
}
/**
* @param {{ bonds: any[], layersDisabled?: boolean, onOpen:(bondId:string)=>void, openId?: string|null, max?: number }} props
* @param {{ bonds: any[], layersDisabled?: boolean, onOpen:(bondId:string)=>void, openId?: string|null, max?: number,
* owner?: string|null }} props — owner: the watched teammate's name (null = your own bonds)
*/
export function BondStrip({ bonds, layersDisabled = false, onOpen, openId = null, max = 14, owner = null }) {
const sorted = sortBonds(bonds, (id) => data.lookup('bonds', id));
if (!sorted.length) {
return html`<div class="bstrip bstrip--empty"><${MicroLabel}>BONDS</${MicroLabel}><span>${owner ? `${owner} 尚未激活盟约` : '部署干员以激活盟约'}</span></div>`;
return html`<div class=${cx('bstrip', 'bstrip--empty', owner && 'is-other')} data-owner=${owner || null}>
${owner ? html`<${OwnerTag} owner=${owner} />` : html`<${MicroLabel}>BONDS</${MicroLabel}>`}<span>${owner ? `${owner} 尚未激活盟约` : '部署干员以激活盟约'}</span></div>`;
}
const m = data.get('assets');
const shown = sorted.slice(0, max);
const strip = html`<div class=${cx('bstrip', layersDisabled && 'is-frozen', owner && 'is-other')} role="list">
${owner ? html`<span class="bstrip__owner"><${MicroLabel}>${owner}</${MicroLabel}></span>` : null}
const strip = html`<div class=${cx('bstrip', layersDisabled && 'is-frozen', owner && 'is-other')} role="list"
aria-label=${owner ? `${owner} 的盟约` : '我的盟约'} data-owner=${owner || null}>
${owner ? html`<${OwnerTag} owner=${owner} />` : null}
${shown.map((b) => {
const rec = data.lookup('bonds', b.bondId);
const th = Array.isArray(b.thresholds) && b.thresholds.length ? b.thresholds : rec?.thresholds || [];
const next = nextThreshold(b.count ?? 0, th);
return html`<div key=${b.bondId} role="listitem" class=${cx('bslot', b.active && 'is-active', openId === b.bondId && 'is-open')}>
return html`<div key=${b.bondId} role="listitem" data-bond=${b.bondId} class=${cx('bslot', b.active && 'is-active', openId === b.bondId && 'is-open')}>
<${BondDisc} name=${rec?.name || b.bondId} icon=${bondIconUrl(m, b.bondId)} layers=${b.layers ?? 0}
tier=${b.tier ?? 0} maxTier=${Math.max(1, th.length)} active=${!!b.active} size="sm" showName=${true}
layersDisabled=${layersDisabled} onClick=${() => onOpen(b.bondId)}
@@ -46,10 +59,11 @@ export function BondStrip({ bonds, layersDisabled = false, onOpen, openId = null
/**
* Bond detail popup.
* @param {{ bondId: string, entry?: any, priv?: any, banned?: string[], onClose: Function, onMember?: (chessId:string)=>void,
* place?: 'left'|'beside'|'besideR'|'right'|null, over?: boolean, beside?: 'left'|'right'|null }} props — `place`: where it
* opens (gameLogic bondPopupPlace; `beside: 'left'` = the older spelling of 'beside'); `over`: above the detail card
* place?: 'left'|'beside'|'besideR'|'right'|null, over?: boolean, beside?: 'left'|'right'|null, owner?: string|null }} props —
* `place`: where it opens (gameLogic bondPopupPlace; `beside: 'left'` = the older spelling of 'beside'); `over`: above the
* detail card; `owner`: the watched teammate's name (`entry` / `priv` are then theirs: ui/watchBonds.js)
*/
export function BondPopup({ bondId, entry, priv, banned = [], onClose, onMember, place = null, over = false, beside = null }) {
export function BondPopup({ bondId, entry, priv, banned = [], onClose, onMember, place = null, over = false, beside = null, owner = null }) {
const b = data.lookup('bonds', bondId);
if (!b) return null;
const count = entry?.count ?? 0;
@@ -62,12 +76,14 @@ export function BondPopup({ bondId, entry, priv, banned = [], onClose, onMember,
const next = nextThreshold(count, th);
const hasNow = !!(b.effectDescRaw || b.effectDesc);
const at = place || (beside === 'left' ? 'beside' : 'left');
return html`<div class=${cx('bpop', 'brackets', `bpop--${at}`, over && 'is-over')} data-place=${at} role="dialog" aria-label=${`盟约:${b.name}`}>
return html`<div class=${cx('bpop', 'brackets', `bpop--${at}`, over && 'is-over', owner && 'is-other')} data-place=${at} data-owner=${owner || null}
role="dialog" aria-label=${owner ? `${owner} 的盟约:${b.name}` : `盟约:${b.name}`}>
<button type="button" class="bpop__close" aria-label="关闭" onClick=${onClose}><${Icon} name="close" /></button>
<header class="bpop__head">
<div class=${cx('bpop__disc', active && 'is-active')}><${BondGlyph} bondId=${bondId} /></div>
<div class="bpop__titles">
<${MicroLabel} tone="mint">${b.isCore ? 'CORE BOND // 核心盟约' : 'ADD-ON BOND // 附加盟约'}</${MicroLabel}>
${owner ? html`<span class="bpop__owner"><${GIcon} name="eye" /><b>${owner}</b> 的盟约</span>` : null}
<h3 class="bpop__name">${b.name}</h3>
<div class="bpop__facts">
<span>在场 <b class="num">${count}</b>${next != null ? html`<small class="num">/${next}</small>` : null}${countsHand ? html`<small>(含整备区)</small>` : null}</span>
+8 -3
View File
@@ -3,7 +3,9 @@
// Client-side combat (`client` prop, DESIGN §14 — the official behaviour):
// * own normal battle over: "⌛ 作战结束,等待队友完成作战" with each teammate's live progress (kills / total, ✓);
// * observing a teammate (team row → 前往查看): "👁 name" + 返回战场;
// * 联防 / 最终攻势: the ‹ › pill switches the camera LEFT half / 全景 / RIGHT half ("你自己" / name / "全景");
// * 联防 / 最终攻势: the ‹ › pill switches the camera LEFT half / 全景 / RIGHT half ("你自己" / name / "全景"); it
// replaces the observing pill on such a field watched with 前往查看 (a 联防 leaker, an eliminated spectator — the
// official non-helper pill "‹ 👁 helper# ›"), with 返回战场 beside the arrows (DESIGN §20.15);
// * there is NO view switcher during normal combat.
// Server-run combat (legacy streaming mode): the ‹ 自己 › switcher cycling the live fields of m.public.fields.
@@ -64,22 +66,25 @@ function ClientHud({ hud, myDone, spectating, client }) {
if (n !== idx) client.onLayer(layers[n].key);
};
const observing = client.observing;
const halves = layers.length > 0;
return html`<div class="chud">
<${DpCounter} dp=${hud?.dp} />
<div class="chud__bottom">
${myDone && !observing ? html`<div class="chud__msg chud__wait" role="status"><${Icon} name="hourglass" /><span>作战结束,等待队友完成作战</span></div>
<${ProgressList} list=${client.progress} />` : null}
${spectating && !observing ? html`<div class="chud__msg chud__msg--dead" role="status"><${Icon} name="close" /><span>你已被淘汰,可点击队友头像前往查看</span></div>` : null}
${observing ? html`<div class="vswitch chud__observe is-single" role="status">
${observing && !halves ? html`<div class="vswitch chud__observe is-single" role="status">
<span class="vswitch__label"><${GIcon} name="eye" /><span>${observing.name}</span></span>
${client.onBack ? html`<button type="button" class="btn btn--secondary btn--sm chud__back" onClick=${() => client.onBack()}>
<span class="btn__label">返回战场</span></button>` : null}
</div>` : null}
${!observing && layers.length ? html`<div class="vswitch chud__layers">
${halves ? html`<div class=${cx('vswitch', 'chud__layers', observing && 'is-observing')}>
<button type="button" class="vswitch__arrow" disabled=${idx <= 0} aria-label="左侧战场" onClick=${() => step(-1)}><${Icon} name="chevronLeft" /></button>
<span class="vswitch__label">
${cur && cur.watch ? html`<${GIcon} name="eye" />` : null}<span>${cur ? cur.label : '全景'}</span></span>
<button type="button" class="vswitch__arrow" disabled=${idx >= layers.length - 1} aria-label="右侧战场" onClick=${() => step(1)}><${Icon} name="chevronRight" /></button>
${observing && client.onBack ? html`<button type="button" class="btn btn--secondary btn--sm chud__back" onClick=${() => client.onBack()}>
<span class="btn__label">返回战场</span></button>` : null}
</div>` : null}
</div>
</div>`;
+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) };
}
+12 -1
View File
@@ -812,7 +812,12 @@ export class Match {
shopLevel: ps.shop.level,
boardCount: ps.deployCount,
ready: this.phase === PHASE.INFO_CHECK ? ps.infoReady : ps.ready,
bonds: bondList(this.gd, ps.bonds).filter((b) => b.count > 0 || b.active),
// the strip of a teammate watching this player (DESIGN §20.15): every bond with members, layers or an active tier
// (= the player's own m.private list without thresholds / countsHand — the client reads those from bonds.json),
// this round's in-battle gains included once the COMBAT phase ended (PlayerState.bondsView); [] once eliminated —
// nobody can watch an eliminated player (g.watch refuses them, they have no field) and the result screen reads
// m.result's own bonds, so their layers would only cost every m.public bytes for the rest of the match
bonds: ps.alive ? bondList(this.gd, ps.bondsView()) : [],
fieldId: this.fieldOf(ps),
status: this.statusOf(ps),
autoplay: ps.autoplay,
@@ -1804,6 +1809,10 @@ export class Match {
for (const pid of f.players) {
const pp = res.perPlayer && res.perPlayer[pid];
this.lastResults.set(pid, pp || { killed: 0, total: 0, leaked: [], perfect: true, layerGains: {}, coins: 0, damageDealt: 0, unitsEnd: [], unitStats: [] });
// the views show the layers the battle reached until settle() makes them persistent (DESIGN §20.15)
const ps = this.players.get(pid);
const gains = pp && pp.layerGains && typeof pp.layerGains === 'object' ? pp.layerGains : null;
if (ps && gains && Object.keys(gains).length) { ps.pendingLayerGains = { ...gains }; ps.dirty(); }
}
}
for (const f of this.fields) f.live = false;
@@ -2685,6 +2694,8 @@ export class Match {
this.phase = PHASE.SETTLE;
this.runner = null;
this._stopClientCombat();
// the pending in-battle gains the views showed become persistent below (alive players) or lapse (DESIGN §20.15)
for (const ps of this.order) if (ps.pendingLayerGains) { ps.pendingLayerGains = null; ps.dirty(); }
const cap = this.gd.lpCapPerRound;
// a 联防 battle that could not run at all (synthetic result) must not wipe the leakers' losses: charge their own leaks
const uniteRan = !!(plan && uniteResult && !uniteResult.synthetic);
+14 -2
View File
@@ -58,7 +58,7 @@ import { ERR, GEO, PHASE, layerGainRoom } from '../../shared/constants.js';
import { checkLoadout, resolveLoadout } from '../../shared/protocol.js';
import { FIELD, tileKey, parseKey, inField, canPlace, positionClass, boardOrder, freeSlot, pieceDir, parseDir, mergeTile } from './board.js';
import { offsetTile } from '../sim/dir.js';
import { computeBonds, bondList, bondSnapshot, activatedLayers } from './bondsMeta.js';
import { computeBonds, bondList, bondSnapshot, activatedLayers, bondsWithGains } from './bondsMeta.js';
import { itemKey } from './gamedata.js';
import { bountyText } from './choices.js';
@@ -112,6 +112,11 @@ export class PlayerState {
this.layers = {};
/** computed bond states */
this.bonds = {};
/**
* this round's IN_BATTLE layer gains of the finished normal battle ({ [bondId]: n }, Match._finishCombat) until
* settle() makes them persistent — the views add them (bondsView, DESIGN §20.15); null otherwise
*/
this.pendingLayerGains = null;
/** optional per-bond count bonus written by effects */
this.bondCountBonus = {};
/** EffectRef list: { id, key, name, desc, iconKind, iconId, counter?, battle, params, data } */
@@ -1301,6 +1306,7 @@ export class PlayerState {
startRound(r) {
this.round = { refreshes: 0, buys: 0, sells: 0, spent: 0, gainedChess: 0, arts: 0 };
this.pendingLayerGains = null; // settled (or lapsed) at the last SETTLE
if (r > 1) this.shop.upgradePrice = Math.max(0, this.shop.upgradePrice - 1);
// onIncome handlers may rewrite ev.income / ev.pending (e.g. 老鲤 withholds R1–R2 income until R3)
const ev = { round: r, income: this.gd.income(r), pending: this.pendingFunds };
@@ -1366,6 +1372,12 @@ export class PlayerState {
activatedLayers() { return activatedLayers(this.bonds); }
/**
* The bond states the views show (m.private bonds, m.public players[].bonds): the computed states plus the pending
* in-battle gains of this round's finished normal battle (bondsMeta.bondsWithGains). Never used by rules.
*/
bondsView() { return bondsWithGains(this.bonds, this.pendingLayerGains); }
// =================================================================================================
// battle input
@@ -1476,7 +1488,7 @@ export class PlayerState {
board,
deployCap: this.deployCap,
deployCount: this.deployCount,
bonds: bondList(this.gd, this.bonds, { full: true }),
bonds: bondList(this.gd, this.bondsView(), { full: true }),
effects: this.effectsView(),
nextEnemies: this.m.nextEnemiesFor(this),
// DESIGN §16: the effective operator loadout ({ [baseChessId]: { skill, module } }; chess not listed use defaults)
+25
View File
@@ -17,6 +17,8 @@
// `tier` = number of thresholds reached (downward: 1 when active); `active = tier ≥ 1`.
// Layers (`ps.layers[bondId]`) persist the whole match; they are reported for every bond but only matter while active.
import { layerGainRoom } from '../../shared/constants.js';
export const HARMONY_BOND = 'maniShip';
export const DEPUTY_BOND = 'deputShip';
@@ -164,6 +166,29 @@ export function bondList(gd, bonds, { full = false } = {}) {
return list;
}
/**
* The bond states the views show (m.private / m.public players[].bonds, DESIGN §20.15): `bonds` with this round's
* IN_BATTLE gains of a finished normal battle (`gains` = the result's layerGains, PlayerState.pendingLayerGains, set
* when the COMBAT phase ends) added the way settle() will add them — at most up to BOND_LAYER_CAP — so the strip of a
* player (and of a teammate watching him) keeps the layers his battle reached through the 联防 until the settlement
* makes them persistent. `bonds` itself when there is nothing to add; the persistent state is never touched.
* @param {ReturnType<typeof computeBonds>} bonds
* @param {Record<string, number>|null|undefined} gains
*/
export function bondsWithGains(bonds, gains) {
if (!gains || typeof gains !== 'object') return bonds;
let out = null;
for (const [id, n] of Object.entries(gains)) {
const b = bonds && bonds[id];
if (!b) continue;
const add = layerGainRoom(b.layers, Math.floor(Number(n) || 0));
if (!(add > 0)) continue;
if (!out) out = { ...bonds };
out[id] = { ...b, layers: (b.layers || 0) + add };
}
return out || bonds;
}
/** Snapshot for PlayerBattleInput.bonds: { [bondId]: { count, active, tier, layers } } (plain copy). */
export function bondSnapshot(bonds) {
const o = {};
+1 -1
View File
@@ -394,7 +394,7 @@ test('user playtest #6 (DESIGN §20): summons, skill triggers, blocking, push fo
const sub = (a, b) => DESIGN.slice(DESIGN.indexOf(`### ${a}`), DESIGN.indexOf(`### ${b}`));
const S20 = sec(20);
assert.match(DESIGN, /## 20\. User playtest #6 \(v2\.5\)/);
for (let i = 1; i <= 13; i++) assert.match(S20, new RegExp(`### 20\\.${i} `), `§20.${i}`);
for (let i = 1; i <= 15; i++) assert.match(S20, new RegExp(`### 20\\.${i} `), `§20.${i}`);
const intro = S20.slice(0, S20.indexOf('### 20.1'));
for (let i = 1; i <= 19; i++) assert.match(intro, new RegExp(`#${i} `), `the intro maps report #${i}`);
const s55 = DESIGN.slice(DESIGN.indexOf('### 5.5'), DESIGN.indexOf('### 5.6'));
+7 -1
View File
@@ -30,7 +30,9 @@ export function freePort() {
* Start the real server (`node server/index.js`, or test/e2e/fastServer.mjs with `fast` speed-ups) on a free port (or
* `port`: e.g. the port of a server just stopped — a server restart the open clients reconnect to).
* @param {{ port?: number, fast?: { timerScale?: number, combatSpeed?: number, startRound?: 'boss'|'hidden'|number, kit?: number, chess?: string[],
* items?: string[], idleBots?: boolean } }} [opts]
* items?: string[], idleBots?: boolean, kits?: string[][], botChess?: string[], autoPlace?: boolean, eliminate?: number[] } }} [opts]
* kits: per-human starter kits (seat order); botChess: the AI seats' kit; autoPlace: the kits go onto the board at the
* first prep; eliminate: humans (seat order) eliminated at the jump — fastServer.mjs hooks
* @returns {Promise<{ base: string, port: number, logs: string[], stop: (o?: { hard?: boolean }) => Promise<void> }>}
*/
export async function startRealServer(opts = {}) {
@@ -49,6 +51,10 @@ export async function startRealServer(opts = {}) {
if (opts.fast.items?.length) env.SP_START_ITEMS = opts.fast.items.join(',');
if (opts.fast.shop?.length) env.SP_START_SHOP = opts.fast.shop.join(',');
if (opts.fast.idleBots) env.SP_IDLE_BOTS = '1';
if (Array.isArray(opts.fast.kits)) env.SP_START_KITS = JSON.stringify(opts.fast.kits);
if (opts.fast.botChess?.length) env.SP_BOT_CHESS = opts.fast.botChess.join(',');
if (opts.fast.autoPlace) env.SP_AUTO_PLACE = '1';
if (opts.fast.eliminate?.length) env.SP_ELIMINATE = opts.fast.eliminate.join(',');
}
const child = spawn(process.execPath, [entry], { cwd: ROOT, env, stdio: ['ignore', 'pipe', 'pipe'] });
const logs = [];
+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 */ }
}
+36 -2
View File
@@ -1,9 +1,10 @@
// Observing rules of the client (public/js/battle/observe.js, research 09 §3.1 / §6.3) — the mirror of the server's
// Match._watchClient: prep boards, no observing while the own normal battle runs, a teammate's field once it is over,
// the other pair's boss field hidden, eliminated players free; the 联防 / 最终攻势 camera halves and their captions.
// the other pair's boss field hidden, eliminated players free; the 联防 / 最终攻势 camera halves and their captions;
// a reloaded screen that watched a teammate's battle takes the watch back (resumedWatch).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { observeTarget, teammateProgress, cameraLayers, layerCamera, isClientCombat } from '../../public/js/battle/observe.js';
import { observeTarget, teammateProgress, cameraLayers, layerCamera, isClientCombat, resumedWatch } from '../../public/js/battle/observe.js';
const P = (id, seat, extra = {}) => ({ playerId: id, seat, name: id.toUpperCase(), alive: true, ...extra });
const pubOf = (phase, fields, players) => ({ phase, combatMode: 'client', fields, players });
@@ -43,3 +44,36 @@ test('cameraLayers: ‹ LEFT / 全景 / RIGHT › with "你自己" / name / "无
assert.deepEqual(layerCamera(unite, 'R', 'R'), { rect: unite.rect, side: 'R', half: true });
assert.deepEqual(layerCamera(unite, 'ALL', 'R'), { rect: unite.rect, side: 'R' });
});
test('resumedWatch: a reload while watching a teammate\'s battle adopts the resent field once per battle (review, DESIGN §20.15)', () => {
const players = [P('a', 0), P('b', 1), P('c', 2)];
const pub = pubOf('COMBAT', [
{ fieldId: 'n:a', kind: 'normal', players: ['a'], live: false },
{ fieldId: 'n:b', kind: 'normal', players: ['b'], live: true },
], players);
const watched = { battleId: 'r3-n:b', fieldId: 'n:b', kind: 'normal', watch: true, done: false };
const own = { battleId: 'r3-n:a', fieldId: 'n:a', kind: 'normal', watch: false, done: true };
const o = { pub, myId: 'a', alive: true, watching: null, seen: null };
// the reload: the server resends b's battle (b.start watch: true) to a screen at home
assert.deepEqual(resumedWatch(watched, o), { seen: 'r3-n:b', fieldId: 'n:b' });
// once per battle: after 返回战场 (watching null again) the same battle, still shown until the own b.start, stays put
assert.deepEqual(resumedWatch(watched, { ...o, seen: 'r3-n:b' }), { seen: 'r3-n:b', fieldId: null });
// first seen while watching (a 前往查看 under way): marked, never adopted later
assert.deepEqual(resumedWatch(watched, { ...o, watching: 'n:b' }), { seen: 'r3-n:b', fieldId: null });
// loading (no `watch` in that state yet): undecided while watching nothing, marked when already watching
const loading = { loading: true, battleId: 'r3-n:b', fieldId: 'n:b', kind: 'normal' };
assert.deepEqual(resumedWatch(loading, o), { seen: null, fieldId: null });
assert.deepEqual(resumedWatch(loading, { ...o, watching: 'n:b' }), { seen: 'r3-n:b', fieldId: null });
// the own battle, no battle, no m.public yet (undecided)
assert.deepEqual(resumedWatch(own, o), { seen: 'r3-n:a', fieldId: null });
assert.deepEqual(resumedWatch({ ...watched, fieldId: 'n:a' }, o), { seen: 'r3-n:b', fieldId: null }, 'a field listing the viewer');
assert.deepEqual(resumedWatch(null, o), { seen: null, fieldId: null });
assert.deepEqual(resumedWatch(watched, { ...o, pub: null }), { seen: null, fieldId: null });
// other rules keep theirs: an eliminated player's auto-observe, 联防 / boss fields, other phases, server-run combat
assert.deepEqual(resumedWatch(watched, { ...o, alive: false }), { seen: 'r3-n:b', fieldId: null });
const unite = pubOf('UNITE', [{ fieldId: 'u', kind: 'unite', players: ['b', 'c'], live: true }], players);
assert.deepEqual(resumedWatch({ battleId: 'r3-u', fieldId: 'u', kind: 'unite', watch: true }, { ...o, pub: unite }), { seen: 'r3-u', fieldId: null }, 'a 联防 leaker');
assert.deepEqual(resumedWatch(watched, { ...o, pub: { ...pub, phase: 'UNITE' } }), { seen: 'r3-n:b', fieldId: null });
assert.deepEqual(resumedWatch(watched, { ...o, pub: { ...pub, combatMode: 'server' } }), { seen: 'r3-n:b', fieldId: null });
assert.deepEqual(resumedWatch({ ...watched, fieldId: 'n:x' }, o), { seen: 'r3-n:b', fieldId: null }, 'a field not listed');
});
+243
View File
@@ -0,0 +1,243 @@
// The data behind the watched player's bond strip (DESIGN §20.15, user report after playtest #6): what the server
// sends of every player's bonds and what the browser's battle runner adds live.
// * m.public players[].bonds = every bond with members, layers or an active tier — the player's own m.private list
// without the per-bond extras the client reads from bonds.json (thresholds / countsHand); nothing else — and nothing
// at all for an eliminated player (nobody can watch them).
// * From the end of COMBAT until the settlement the views (m.private and m.public) carry the finished normal battle's
// in-battle gains (PlayerState.bondsView / bondsMeta.bondsWithGains, capped at 999): the strip of a player — and of a
// teammate watching him in the 联防 — keeps the layers his battle reached. The persistent state is untouched and the
// settlement adds the gains once.
// * The runner publishes state().bondLayers { [playerId]: { [bondId]: n } } — absolute live counts of every bond that
// grew in a battle it simulates (the own one, a teammate's display replica), ≤ 999, kept through the round; and
// ownerOps(ownerId) — that player's operators in the battle on screen (a teammate's popup: the members in play).
// UI selection logic: test/ui/watch-bonds.test.js; on screen: test/ui/watch-bonds.e2e.test.js.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { PHASE, BOND_LAYER_CAP } from '../../shared/constants.js';
import { bondsWithGains, computeBonds } from '../../server/match/bondsMeta.js';
import { createBattleRunner } from '../../public/js/battle/runner.js';
import { createStore, initialState } from '../../public/js/store.js';
import * as specMod from '../../server/sim/spec.js';
import { DataSource } from '../../server/sim/simdata.js';
import { DATA, makeMatch, checkInvariants } from './harness.js';
const row = (h, pid) => h.m.publicView().players.find((p) => p.playerId === pid);
const entry = (list, id) => (list || []).find((b) => b.bondId === id) || null;
test('m.public players[].bonds: every bond with members, layers or a tier — the m.private list minus thresholds / countsHand', () => {
const h = makeMatch({ mode: 'coop', humans: 2, seed: 91, fake: true }).start();
h.toPrep(1);
const ps = h.ps('p_1');
ps.layers.sargonShip = 7; // layers, no member on the board: the own strip shows it (grey) — so does the teammate's
ps.recompute();
const pub = row(h, 'p_1').bonds;
const priv = ps.privateView().bonds;
assert.deepEqual(entry(pub, 'sargonShip'), { bondId: 'sargonShip', count: 0, active: false, tier: 0, layers: 7 });
assert.deepEqual(pub.map((b) => b.bondId), priv.map((b) => b.bondId), 'the same bonds, the same order');
for (const b of pub) assert.deepEqual(Object.keys(b).sort(), ['active', 'bondId', 'count', 'layers', 'tier'], `${b.bondId}: only what the strip needs`);
assert.ok(priv.every((b) => Array.isArray(b.thresholds) && typeof b.countsHand === 'boolean'), 'm.private keeps its extras');
h.m.dispose();
});
test('m.public players[].bonds of an eliminated player: [] (nobody can watch them), even with layers and pending gains', () => {
const h = makeMatch({ mode: 'coop', humans: 2, seed: 95, fake: true }).start();
h.toPrep(1);
const ps = h.ps('p_1');
ps.layers.sargonShip = 7;
ps.recompute();
assert.ok(entry(row(h, 'p_1').bonds, 'sargonShip'), 'alive: the layers-only bond is sent');
ps.eliminate(1);
ps.layers.yanShip = 40; // layers survive the elimination (computeBonds still lists them) …
ps.pendingLayerGains = { sargonShip: 3 };
ps.recompute();
assert.ok(ps.bondsView().sargonShip.layers > 0, '… the state still has them');
assert.deepEqual(row(h, 'p_1').bonds, [], 'm.public: nothing for an eliminated player');
assert.equal(row(h, 'p_1').alive, false);
const a = h.ps('p_0');
a.layers.yanShip = 5;
a.recompute();
assert.equal(entry(row(h, 'p_0').bonds, 'yanShip')?.layers, 5, 'a living teammate keeps the list');
h.m.dispose();
});
test('bondsWithGains: adds a result\'s layerGains like settle() (floored, ≤ 999), never mutates, ignores unknown bonds', () => {
const ps = { board: new Map(), hand: [], layers: { yanShip: 990, sargonShip: 4 } };
const bonds = computeBonds(gdOf(), ps);
const snap = JSON.stringify(bonds);
assert.equal(bondsWithGains(bonds, null), bonds);
assert.equal(bondsWithGains(bonds, {}), bonds);
const v = bondsWithGains(bonds, { yanShip: 50, sargonShip: 2.7, noSuchBond: 9 });
assert.equal(v.yanShip.layers, BOND_LAYER_CAP, '990 + 50 shows 999');
assert.equal(v.sargonShip.layers, 6, 'floored like settle()');
assert.ok(!('noSuchBond' in v));
assert.equal(JSON.stringify(bonds), snap, 'the computed states are untouched');
assert.equal(bondsWithGains({ yanShip: { ...bonds.yanShip, layers: 999 } }, { yanShip: 5 }).yanShip.layers, 999, 'at the cap: nothing');
});
test('after COMBAT the views carry the battle\'s gains until SETTLE makes them persistent (once); the state is untouched meanwhile', () => {
const h = makeMatch({
mode: 'coop', humans: 2, seed: 92, fake: true,
script: (b) => (b.round === 1 ? { layerGains: { p_0: { yanShip: 50 }, p_1: { sargonShip: 12 } } } : {}),
}).start();
const m = h.m;
h.toPrep(1);
const [a, b] = [h.ps('p_0'), h.ps('p_1')];
a.layers.yanShip = 980;
a.recompute();
// the COMBAT_END pause: every result is in, nothing settled yet
h.drive(() => m.phase === PHASE.COMBAT && m.lastResults.size === 2);
assert.equal(m.phase, PHASE.COMBAT);
assert.deepEqual([a.layers.yanShip, b.layers.sargonShip || 0], [980, 0], 'nothing persistent yet');
assert.equal(a.bonds.yanShip.layers, 980, 'the computed state is untouched (rules read it)');
assert.equal(entry(a.privateView().bonds, 'yanShip').layers, BOND_LAYER_CAP, 'm.private: 980 + 50 shows 999');
assert.equal(entry(row(h, 'p_0').bonds, 'yanShip').layers, BOND_LAYER_CAP, 'm.public: the same');
assert.equal(entry(row(h, 'p_1').bonds, 'sargonShip').layers, 12, 'a teammate\'s gain shows in his public row');
checkInvariants(m);
h.drive(() => m.phase === PHASE.SETTLE || m.phase === PHASE.PREP);
assert.deepEqual([a.layers.yanShip, b.layers.sargonShip], [BOND_LAYER_CAP, 12], 'settled once');
assert.deepEqual([a.pendingLayerGains, b.pendingLayerGains], [null, null]);
assert.equal(entry(row(h, 'p_1').bonds, 'sargonShip').layers, 12, 'not added twice');
assert.equal(entry(b.privateView().bonds, 'sargonShip').layers, 12);
checkInvariants(m);
m.dispose();
});
test('the 联防 phase keeps showing the gains (a teammate watching the helper sees the layers his battle reached)', () => {
// p_0 perfect with a gain, p_1 leaks: 联防 with p_0 as the helper
const h = makeMatch({
mode: 'coop', humans: 2, seed: 93, fake: true,
script: (b) => (b.round === 1 && b.kind === 'normal' ? { layerGains: { p_0: { yanShip: 9 } }, leaks: { p_1: 2 } } : {}),
}).start();
const m = h.m;
h.toPrep(1);
h.drive(() => m.phase === PHASE.UNITE || m.phase === PHASE.SETTLE);
assert.equal(m.phase, PHASE.UNITE, 'p_1 leaked, p_0 was perfect: 联防');
assert.equal(h.ps('p_0').layers.yanShip || 0, 0, 'not settled during 联防');
assert.equal(entry(row(h, 'p_0').bonds, 'yanShip').layers, 9, 'the helper\'s public row shows his battle\'s count');
assert.equal(entry(h.ps('p_0').privateView().bonds, 'yanShip').layers, 9, 'and his own strip');
h.drive(() => m.phase === PHASE.PREP && m.round === 2);
assert.equal(h.ps('p_0').layers.yanShip, 9);
assert.equal(entry(row(h, 'p_0').bonds, 'yanShip').layers, 9);
m.dispose();
});
// ---- the browser runner's live layers ---------------------------------------------------------------------------------
const DS = new DataSource(DATA, null);
function gdOf() {
const h = makeMatch({ mode: 'solo', seed: 94, fake: true });
const gd = h.m.gd;
h.m.dispose();
return gd;
}
function rig() {
let t = 1000;
const frames = [];
const handlers = new Map();
const net = {
on(type, fn) { if (!handlers.has(type)) handlers.set(type, new Set()); handlers.get(type).add(fn); return () => handlers.get(type).delete(fn); },
emit(type, msg) { for (const fn of handlers.get(type) || []) fn({ t: type, ...msg }); },
send() { return true; },
request() { return Promise.resolve({ t: 'ok' }); },
};
const store = createStore(initialState);
const runner = createBattleRunner({
net, store, doc: { hidden: false, addEventListener() {} }, now: () => t,
raf: (fn) => { frames.push(fn); return frames.length; }, caf() {}, setInterval: () => 1, clearInterval() {},
loadSim: async () => ({ spec: specMod, ds: DS }), logger: { error() {}, warn() {}, info() {}, debug() {} },
});
return {
runner, net, store,
advance(ms, step = 1000 / 60) { const end = t + ms; while (t < end) { t = Math.min(end, t + step); for (const fn of frames.splice(0)) fn(t); } },
async settle() { for (let i = 0; i < 50; i++) { await new Promise((res) => setImmediate(res)); for (const fn of frames.splice(0)) fn(t); } },
};
}
/** A real b.start of a normal round-2 battle (client-side combat), the board's bonds in its spec. */
function realStart(seed = 7302) {
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 1, bots: 1, seed, captureFrames: false, clientCombat: true, clients: false });
h.autoHumans();
h.m.start();
h.run(() => h.m.phase === PHASE.COMBAT && h.m.round === 2, { maxSteps: 3e6 });
const msg = h.lastTo('p_0', 'b.start');
h.m.dispose();
return JSON.parse(JSON.stringify(msg));
}
test('runner: state().bondLayers = the absolute live counts of the bonds that grew (≤ 999), published when one grows, kept through the round', async () => {
const start = realStart();
assert.ok(start && start.kind === 'normal');
const sp = start.spec.players.find((p) => p.playerId === 'p_0');
const ids = Object.keys(sp.bonds);
assert.ok(ids.length >= 2, 'the spec carries the bond snapshot');
const [b1, b2] = ids;
sp.bonds[b1].layers = 995; // near the cap
sp.bonds[b2].layers = 3;
const r = rig();
r.net.emit('b.start', start);
await r.settle();
const e = r.runner._entries.get(start.battleId);
assert.ok(e && e.battle.flags.layerGainsEnabled !== false);
assert.deepEqual(r.store.get().match.battle.bondLayers, {}, 'nothing grew yet');
let publishes = 0;
r.store.subscribe((s, prev) => { if (s.match.battle !== prev.match.battle) publishes++; });
e.battle.addLayers('p_0', b1, 10, 'test');
e.battle.addLayers('p_0', b2, 4, 'test');
r.advance(40);
assert.deepEqual(r.store.get().match.battle.bondLayers, { p_0: { [b1]: BOND_LAYER_CAP, [b2]: 7 } }, 'absolute counts, capped at 999');
assert.ok(publishes >= 1, 'published in the frame of the gain');
const before = publishes;
r.advance(200);
assert.ok(publishes - before <= 2, 'no publish per frame without a change');
// a display replica (a teammate's battle watched after the own one) reports its player's live counts too
const replica = JSON.parse(JSON.stringify(start));
replica.battleId = `${start.battleId}x`;
replica.authoritative = false;
replica.watch = true;
r.net.emit('b.start', replica);
await r.settle();
const e2 = r.runner._entries.get(replica.battleId);
e2.battle.addLayers('p_0', b2, 1, 'test');
r.advance(40);
assert.equal(r.store.get().match.battle.watch, true, 'the replica is on screen');
assert.ok(r.store.get().match.battle.bondLayers.p_0, 'the round\'s counts stay published while another battle is on screen');
// a new prep drops every battle: nothing to lay over any more
r.store.patch('match', { public: { phase: 'PREP' } });
assert.deepEqual(r.store.get().match.battle?.bondLayers ?? {}, {});
r.runner.dispose();
});
test('runner: ownerOps(ownerId) = that player\'s operators in the battle on screen (a teammate\'s popup members), [] elsewhere', async () => {
const start = realStart(7304);
const r = rig();
r.net.emit('b.start', start);
await r.settle();
const e = r.runner._entries.get(start.battleId);
const pid = start.spec.players[0].playerId;
const want = e.battle.allyUnits.filter((u) => u.kind === 'op' && u.ownerId === pid).map((u) => u.defId).sort();
assert.ok(want.length > 0, 'the board fields operators');
const got = r.runner.ownerOps(pid);
assert.deepEqual(got.map((u) => u.defId).sort(), want, 'before they deploy too (the field meta may not list them yet)');
assert.ok(got.every((u) => u.kind === 'op' && u.ownerId === pid), 'UnitInfo-like entries');
assert.deepEqual(r.runner.ownerOps(pid, start.fieldId).length, want.length);
assert.deepEqual(r.runner.ownerOps(pid, 'n:nobody'), [], 'another field: nothing');
assert.deepEqual(r.runner.ownerOps('ghost'), [], 'a player without operators there');
assert.deepEqual(r.runner.ownerOps(null), []);
r.runner.dispose();
});
test('runner: a battle that disables gains (联防 / boss) publishes no live layers', async () => {
const start = realStart(7303);
start.spec.flags = { ...(start.spec.flags || {}), layerGainsEnabled: false };
const r = rig();
r.net.emit('b.start', start);
await r.settle();
const e = r.runner._entries.get(start.battleId);
const id = Object.keys(start.spec.players[0].bonds)[0];
assert.equal(e.battle.addLayers(start.spec.players[0].playerId, id, 5, 'test'), 0);
r.advance(100);
assert.deepEqual(r.store.get().match.battle.bondLayers, {});
r.runner.dispose();
});
+625
View File
@@ -0,0 +1,625 @@
// Real-server browser E2E (DESIGN §20.15, user report after playtest #6): the bond strip follows the watched teammate.
// Two humans — the host on a 1280×720 desktop, the guest deploying at 1280×720 and then on the user's phone (756×366 CSS
// px) — start a co-op match at the Final Assault (fastServer SP_START_ROUND=boss) with a known starter kit; the host
// deploys 古米 + 艾丝黛尔 (坚守 / 萨尔贡), the guest 惊蛰 + 跃跃 (炎 / 精准), so their strips hold different bonds.
// * prep: the host scouts the guest (前往查看) → the strip shows the GUEST's bonds and counts with the amber
// "👁 阿米娅" tag, the popup of one of them says "阿米娅 的盟约"; 返回自己 → the host's own bonds again;
// * Final Assault (one pair field): 全景 → your own bonds; the ‹ › pill on the partner's half → the partner's bonds
// (tagged), on your own half → yours; the guest on the phone does the same — the tagged strip stays on screen, clear
// of the team panel, the name at ≥ 9 px (≥ 10 px on the desktop); a card's bond chip opens the UNIT owner's popup
// whichever player the strip follows (the guest's operator on 全景 → "阿米娅 的盟约", the host's own on the partner's
// half → the host's own), with the count the chip shows.
// 联防 observer (2 humans + 1 AI from round 3, fastServer hooks: per-human kits auto-placed, the guest without a board):
// the guest leaks, the host and the AI help → the guest (phone) on 全景 sees helper 1's bonds (never its own), taps
// each helper's avatar (前往查看) → the camera goes to that helper's half, the ‹ › pill and the strip name the same
// helper, 返回战场 sits in the pill and leaves no error; the host (helper, desktop) keeps its own bonds on 全景.
// Final Assault spectator (2 humans + 2 AI at the boss round, the guest eliminated at the jump): the guest auto-observes
// the pair field → its first player's bonds (never its own), taps the second player's operator and its card's bond
// chip → the SECOND player's popup (the strip stays on the first), taps each pair player → their half + bonds, taps
// the lone field's player → that field and its player's bonds.
// After the own battle (2 humans + 1 idle AI from round 3, 1× combat): the host's battle ends first, the host watches a
// teammate still fighting → the strip shows that teammate's bonds with the observing pill "👁 name" + 返回战场; a
// reload keeps all three (the resent field is adopted — battle/observe.js resumedWatch); 返回战场 → the own bonds.
// Unit counterparts: test/ui/watch-bonds.test.js (selection logic incl. 联防), test/match/watch-bonds.test.js (views),
// test/match/observe.test.js (resumedWatch).
//
// SP_E2E=1 node --test test/ui/watch-bonds.e2e.test.js
//
// Screenshots: test/e2e/out/watch-bonds-*.png.
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync } from 'node:fs';
import path from 'node:path';
import { Client, ROOT, sleep, hasChrome, startRealServer, problemsOf } from '../e2e/client.mjs';
const ENABLED = process.env.SP_E2E === '1' && hasChrome() && existsSync(path.join(ROOT, 'public/assets'));
const HOST_KIT = ['chess_char_1_10_a', 'chess_char_1_12_a']; // 古米 (坚守), 艾丝黛尔 (萨尔贡)
const GUEST_KIT = ['chess_char_1_03_a', 'chess_char_1_09_a']; // 惊蛰 (炎), 跃跃 (精准)
// a 联防 needs two perfect players: the host fields six 萨尔贡 operators (百炼嘉维尔, 卡涅利安, 菲莱, 至简, 艾丝黛尔, 莎草),
// the AI seats five 拉特兰 ones + 古米 (信仰搅拌机, 莫斯提马, 能天使, 空弦, 送葬人; 坚守) — different bonds on each strip
const UNITE_HOST_KIT = ['chess_char_4_23_a', 'chess_char_4_24_a', 'chess_char_3_06_a', 'chess_char_3_13_a', 'chess_char_1_12_a', 'chess_char_2_06_a'];
const BOT_KIT = ['chess_char_4_01_a', 'chess_char_4_02_a', 'chess_char_3_01_a', 'chess_char_3_21_a', 'chess_char_2_01_a', 'chess_char_1_10_a'];
/** The strip as shown: whose (data-owner), the bonds (data-bond) with their counts, the tag's text. */
const stripOf = (c) => c.page.evaluate(() => {
const el = document.querySelector('.gm__bonds .bstrip');
if (!el) return null;
const r = el.getBoundingClientRect();
const tag = el.querySelector('.bstrip__owner');
const tr = tag ? tag.getBoundingClientRect() : null;
return {
owner: el.getAttribute('data-owner'), other: el.classList.contains('is-other'),
bonds: [...el.querySelectorAll('.bslot')].map((s) => s.getAttribute('data-bond')).sort(),
counts: Object.fromEntries([...el.querySelectorAll('.bslot')].map((s) => [s.getAttribute('data-bond'), s.querySelector('.bslot__count')?.firstChild?.textContent ?? null])),
tag: tag ? tag.textContent.trim() : null,
tagFont: tag ? parseFloat(getComputedStyle(tag.querySelector('.micro')).fontSize) : null,
pill: document.querySelector('.chud__layers .vswitch__label')?.textContent?.trim() ?? null,
pillBack: !!document.querySelector('.chud__layers .chud__back'),
observe: document.querySelector('.chud__observe .vswitch__label')?.textContent?.trim() ?? null,
tagRect: tr ? { left: tr.left, top: tr.top, right: tr.right, bottom: tr.bottom } : null,
rect: { left: r.left, top: r.top, right: r.right, bottom: r.bottom },
vw: innerWidth, vh: innerHeight,
team: (() => { const t = document.querySelector('.team'); if (!t) return null; const b = t.getBoundingClientRect(); return { left: b.left, top: b.top, right: b.right, bottom: b.bottom }; })(),
};
});
/** A player's bond ids with members as the views carry them (yours: m.private; a teammate's: m.public). */
const bondsOf = (c, name) => c.page.evaluate((name) => {
const s = globalThis.__SP__.store.get();
const p = s.match.public.players.find((x) => x.name === name);
const own = p && p.playerId === s.me.playerId;
const list = own ? s.match.private.bonds : p.bonds;
return { ids: list.filter((b) => b.count > 0 || b.layers > 0 || b.active).map((b) => b.bondId).sort(), counts: Object.fromEntries(list.map((b) => [b.bondId, String(b.count)])) };
}, name);
/**
* Tap one of `ownerId`'s operators on the battle field on screen (a point the renderer's own picking maps to that unit,
* not covered by the HUD), then its card's first bond chip: the popup that chip opens (whose, its count) + the chip's
* count. null when no such operator could be tapped.
*/
async function chipPopup(c, ownerId) {
for (let attempt = 0; attempt < 8; attempt++) {
const pt = await c.page.evaluate((ownerId) => {
const d = globalThis.__SP_VIEW__?.raw?.debug;
if (!d?.views || !d.pick?.battleUnitAt || !d.app?.view) return null;
const canvas = d.app.view;
const r = canvas.getBoundingClientRect();
for (const v of d.views.values()) {
const info = v.info;
if (v.destroyed || !info || info.ownerId !== ownerId || info.side === 'enemy' || typeof v.bounds !== 'function') continue;
const b = v.bounds();
for (const at of [0.85, 0.65, 0.95, 0.45]) {
const x = b.x + b.width / 2;
const y = b.y + b.height * at;
if (d.pick.battleUnitAt(x, y) !== v || document.elementFromPoint(r.left + x, r.top + y) !== canvas) continue;
return { x: r.left + x, y: r.top + y, defId: info.defId };
}
}
return null;
}, ownerId);
if (!pt) { await sleep(400); continue; }
await c.page.mouse.click(pt.x, pt.y);
const card = await c.page.waitForSelector('.dpanel button.dbond[data-bond]', { timeout: 2500 }).then(() => true).catch(() => false);
if (!card) continue;
const chip = await c.page.evaluate(() => {
const el = document.querySelector('.dpanel button.dbond[data-bond]');
return { bondId: el.getAttribute('data-bond'), count: el.querySelector('.dbond__count')?.firstChild?.textContent ?? null };
});
await c.page.click(`.dpanel button.dbond[data-bond="${chip.bondId}"]`);
await c.page.waitForSelector('.bpop', { timeout: 3000 });
const pop = await c.page.evaluate(() => {
const el = document.querySelector('.bpop');
return { owner: el.getAttribute('data-owner'), label: el.getAttribute('aria-label'), tag: el.querySelector('.bpop__owner')?.textContent || '',
count: el.querySelector('.bpop__facts b.num')?.textContent ?? null, on: el.querySelectorAll('.bpop__member.is-on').length };
});
return { ...pop, bondId: chip.bondId, chipCount: chip.count, defId: pt.defId };
}
return null;
}
/** Close the bond popup and the card (Esc closes the popup first). */
async function closeCard(c) {
for (let i = 0; i < 3 && (await c.page.$('.bpop, .dpanel')); i++) {
await c.page.keyboard.press('Escape');
await sleep(250);
}
}
/** Hand piece `chessId` → a legal board tile through the direction wheel. */
async function place(c, chessId) {
const piece = (await c.handPieces('chess')).find((p) => p.id === chessId);
assert.ok(piece, `${c.label}: ${chessId} in the hand`);
const n0 = (await c.boardPieces()).length;
const tile = await c.freeTileFor(piece.uid);
assert.ok(tile, `${c.label}: a legal tile for ${chessId}`);
await c.drag(await c.piecePoint(piece.uid), await c.tilePoint(tile.row, tile.col));
await c.page.waitForSelector('.fwheel__dia', { timeout: 4000 });
await c.swipe('RIGHT');
await c.page.waitForFunction((n0) => (globalThis.__SP__.store.get().match.private?.board || []).length > n0, { timeout: 8000 }, n0);
}
/** Two humans in a co-op room with `bots` AI seats: the host on a 1280×720 desktop, the guest on the user's phone
* (756×366 CSS px, touch), through the briefing and the band draft. */
async function coopMatch(P, base, { bots = 1, prefix }) {
const host = new Client(P, base, 'host', { prefix: `${prefix}-host`, w: 1280, h: 720 });
const guest = new Client(P, base, 'guest', { prefix: `${prefix}-phone`, w: 756, h: 366 });
await host.open();
await host.enter('凯尔希');
await host.click('.mode-card', '同盟模拟');
await host.click('.diff-card', '标准模拟');
await host.click('.create-box button', '创建同盟');
const room = (await host.waitFor((s) => !!s.room?.code, 'room created')).room;
for (let i = 0; i < bots; i++) {
await host.click('button', '添加 AI 队友');
await sleep(600);
}
await guest.open(`?room=${room.code}`);
await guest.page.setViewport({ width: 756, height: 366, hasTouch: true, isMobile: true });
await guest.page.reload({ waitUntil: 'domcontentloaded' });
await guest.page.waitForFunction(() => !!globalThis.__SP__ && !!document.querySelector('.screen'), { timeout: 30000 });
await guest.enter('阿米娅');
await guest.waitFor((s) => s.room?.code === room.code, 'guest joined');
const guestReady = () => guest.page.evaluate(() => {
const s = globalThis.__SP__.store.get();
return !!s.room?.seats?.find((x) => x && x.playerId === s.me.playerId)?.ready;
});
for (let i = 0; i < 6 && !(await guestReady()); i++) {
await guest.click('.room-bar__right button', '准备就绪', { optional: true, timeout: 3000 });
await sleep(600);
}
assert.ok(await guestReady(), 'guest ready');
await host.click('.room-bar__right button', '开始模拟', { timeout: 20000 });
for (const c of [host, guest]) await c.waitFor((s) => s.phase === 'INFO_CHECK', 'briefing', 30000);
for (const c of [host, guest]) await c.click('.brief__foot .btn--primary', '准备就绪');
for (const c of [host, guest]) await c.waitFor((s) => s.phase !== 'INFO_CHECK', 'band draft', 40000);
const picked = new Set();
const t0 = Date.now();
while (picked.size < 2 && Date.now() - t0 < 120000) {
for (const c of [host, guest]) {
const s = await c.st();
if (s.phase !== 'BAND_DRAFT') { picked.add(c.label); continue; }
if (picked.has(c.label) || s.draft?.turn !== s.me) continue;
await c.click('.dband:not(.is-taken)', null, { nth: c === host ? 2 : 5 });
await sleep(200);
await c.click('.draft-detail__btns .btn--primary', '确认选择');
picked.add(c.label);
}
await sleep(250);
}
// error toasts from here on (返回战场 of a leaker must not raise one)
for (const c of [host, guest]) {
await c.page.evaluate(() => {
globalThis.__toastErrors = [];
new MutationObserver(() => {
for (const t of document.querySelectorAll('.toast--error .toast__text')) {
if (!t.dataset.seen) { t.dataset.seen = '1'; globalThis.__toastErrors.push(t.textContent); }
}
}).observe(document.body, { childList: true, subtree: true });
});
}
return { host, guest };
}
/** A teammate's row → 前往查看 (team panel). */
async function watchMate(c, name) {
const pt = await c.page.evaluate((name) => {
for (const row of document.querySelectorAll('.team__row')) {
if (!(row.querySelector('.team__name')?.textContent || '').includes(name)) continue;
const b = row.querySelector('.team__btn').getBoundingClientRect();
return { x: b.left + b.width / 2, y: b.top + b.height / 2 };
}
return null;
}, name);
assert.ok(pt, `${c.label}: ${name}'s row`);
await c.page.mouse.click(pt.x, pt.y);
assert.ok(await c.click('.team__ob', '前往查看', { timeout: 4000, optional: true }), `${c.label}: 前往查看 on ${name}`);
}
/** Every player's name and bond ids (count, layers or active), the field listing, the 联防 helpers. */
const matchView = (c) => c.page.evaluate(() => {
const s = globalThis.__SP__.store.get();
const pub = s.match.public;
const ids = (list) => (list || []).filter((b) => b.count > 0 || b.layers > 0 || b.active).map((b) => b.bondId).sort();
return {
me: s.me.playerId,
names: Object.fromEntries(pub.players.map((p) => [p.playerId, p.name])),
bonds: Object.fromEntries(pub.players.map((p) => [p.name, ids(p.playerId === s.me.playerId && s.match.private ? s.match.private.bonds : p.bonds)])),
fields: (pub.fields || []).map((f) => ({ fieldId: f.fieldId, kind: f.kind, players: f.players })),
field: s.match.field ? { fieldId: s.match.field.fieldId, sides: s.match.field.sides || null } : null,
};
});
const ownerIs = (c, name) => c.page.waitForFunction((name) => (document.querySelector('.gm__bonds .bstrip')?.getAttribute('data-owner') ?? null) === name, { timeout: 6000 }, name);
describe('DESIGN §20.15 — the bond strip shows the watched teammate\'s bonds (real server)', { skip: !ENABLED && 'set SP_E2E=1 (Chrome + public/assets)' }, () => {
test('prep scouting and the Final Assault ‹ › halves (desktop host, phone guest)', { timeout: 8 * 60 * 1000 }, async () => {
const srv = await startRealServer({ fast: { timerScale: 1, combatSpeed: 2, startRound: 'boss', chess: [...HOST_KIT, ...GUEST_KIT] } });
const P = (await import('puppeteer-core')).default;
const host = new Client(P, srv.base, 'host', { prefix: 'watch-bonds', w: 1280, h: 720 });
// the guest deploys on a desktop-sized window (the phone's shop bar covers the bench), then turns into the phone
const guest = new Client(P, srv.base, 'guest', { prefix: 'watch-bonds-phone', w: 1280, h: 720 });
try {
await host.open();
await host.enter('凯尔希');
await host.click('.mode-card', '同盟模拟');
await host.click('.diff-card', '标准模拟');
await host.click('.create-box button', '创建同盟');
const room = (await host.waitFor((s) => !!s.room?.code, 'room created')).room;
await guest.open(`?room=${room.code}`);
await guest.enter('阿米娅');
await guest.waitFor((s) => s.room?.code === room.code, 'guest joined');
const guestReady = () => guest.page.evaluate(() => {
const s = globalThis.__SP__.store.get();
return !!s.room?.seats?.find((x) => x && x.playerId === s.me.playerId)?.ready;
});
for (let i = 0; i < 6 && !(await guestReady()); i++) {
await guest.click('.room-bar__right button', '准备就绪', { optional: true, timeout: 3000 });
await sleep(600);
}
assert.ok(await guestReady(), 'guest ready');
await host.click('.room-bar__right button', '开始模拟', { timeout: 20000 });
for (const c of [host, guest]) await c.waitFor((s) => s.phase === 'INFO_CHECK', 'briefing', 30000);
for (const c of [host, guest]) await c.click('.brief__foot .btn--primary', '准备就绪');
for (const c of [host, guest]) await c.waitFor((s) => s.phase !== 'INFO_CHECK', 'band draft', 40000);
const picked = new Set();
const t0 = Date.now();
while (picked.size < 2 && Date.now() - t0 < 120000) {
for (const c of [host, guest]) {
const s = await c.st();
if (s.phase !== 'BAND_DRAFT') { picked.add(c.label); continue; }
if (picked.has(c.label) || s.draft?.turn !== s.me) continue;
await c.click('.dband:not(.is-taken)', null, { nth: c === host ? 2 : 5 });
await sleep(200);
await c.click('.draft-detail__btns .btn--primary', '确认选择');
picked.add(c.label);
}
await sleep(250);
}
for (const c of [host, guest]) await c.waitFor((s) => s.phase === 'PREP' && !s.ready && s.hand >= 4, 'boss-round prep with the kit', 60000);
await sleep(1800);
for (const id of HOST_KIT) await place(host, id);
for (const id of GUEST_KIT) await place(guest, id);
await sleep(800);
// ---- prep: the host's own strip, then the guest's board (前往查看) -------------------------------------------
const hostOwn = await bondsOf(host, '凯尔希');
const guestPub = await bondsOf(host, '阿米娅');
assert.ok(hostOwn.ids.length && guestPub.ids.length, `both have bonds (${hostOwn.ids} / ${guestPub.ids})`);
assert.notDeepEqual(hostOwn.ids, guestPub.ids, 'different kits, different bonds');
let st = await stripOf(host);
assert.deepEqual([st.owner, st.other, st.bonds], [null, false, hostOwn.ids], 'prep, own board: the host\'s own bonds');
await host.click('.team__row:not(.is-self) .team__btn', null, { nth: 0 });
assert.ok(await host.click('.team__ob', '前往查看', { optional: true, timeout: 3000 }), 'host: 前往查看 in prep');
await host.page.waitForSelector('.gm__watching', { timeout: 6000 });
await host.page.waitForFunction(() => document.querySelector('.gm__bonds .bstrip')?.getAttribute('data-owner') === '阿米娅', { timeout: 6000 });
st = await stripOf(host);
assert.deepEqual([st.owner, st.other, st.bonds], ['阿米娅', true, guestPub.ids], 'scouting: the GUEST\'s bonds');
for (const id of st.bonds) assert.equal(st.counts[id], guestPub.counts[id], `${id}: the guest's member count`);
assert.match(st.tag, /阿米娅/, 'the 👁 name tag');
// the popup of one of them is the guest's too
await host.click(`.gm__bonds .bslot[data-bond="${st.bonds[0]}"] .bond`, null, { any: true });
await host.page.waitForSelector('.bpop[data-owner="阿米娅"]', { timeout: 4000 });
const pop = await host.page.evaluate(() => ({ owner: document.querySelector('.bpop__owner')?.textContent || '', facts: document.querySelector('.bpop__facts')?.textContent || '' }));
assert.match(pop.owner, /阿米娅.*的盟约/);
await host.shot('prep-scout');
await host.page.keyboard.press('Escape');
await host.click('.gm__watching button', '返回自己');
await host.page.waitForFunction(() => !document.querySelector('.gm__bonds .bstrip')?.getAttribute('data-owner'), { timeout: 6000 });
st = await stripOf(host);
assert.deepEqual([st.owner, st.bonds], [null, hostOwn.ids], '返回自己: the host\'s own bonds again');
// ---- Final Assault: the pair field's halves (the guest on the user's phone, 756×366 CSS px) ----------------------
await guest.page.setViewport({ width: 756, height: 366 });
guest.w = 756;
guest.h = 366;
for (const c of [host, guest]) {
if (!(await c.st()).ready) await c.click('.readybtn');
}
for (const c of [host, guest]) await c.waitFor((s) => s.phase === 'FINAL_ASSAULT', 'Final Assault', 60000);
for (const c of [host, guest]) await c.page.waitForSelector('.chud__layers', { timeout: 20000 });
await sleep(1200);
const pair = await host.page.evaluate(() => {
const f = globalThis.__SP__.store.get().match.field;
return { fieldId: f?.fieldId, sides: f?.sides || null, me: globalThis.__SP__.store.get().me.playerId };
});
assert.ok(pair.sides && Object.keys(pair.sides).length === 2, `one pair field with both (${JSON.stringify(pair)})`);
const hostSide = pair.sides[pair.me];
st = await stripOf(host);
assert.deepEqual([st.owner, st.bonds], [null, hostOwn.ids], '全景: the host\'s own bonds');
// a card's bond chip on the shared field (review): on 全景 (the strip is the host's) the GUEST's operator's chip
// opens the GUEST's bond — labelled, with the guest's count, the same number the chip shows
const guestId = Object.keys(pair.sides).find((id) => id !== pair.me);
let chip = await chipPopup(host, guestId);
assert.ok(chip, 'host: a card of the guest\'s operator and its bond chip');
const guestNow = await bondsOf(host, '阿米娅');
assert.equal(chip.owner, '阿米娅', `全景: the guest's unit chip opens the guest's popup (${JSON.stringify(chip)})`);
assert.match(chip.tag, /阿米娅.*的盟约/);
assert.equal(chip.count, guestNow.counts[chip.bondId] ?? '0', `the guest's member count (${JSON.stringify({ chip, guestNow })})`);
assert.equal(chip.chipCount, chip.count, 'the chip and its popup agree');
if (Number(chip.count) > 0) assert.ok(chip.on >= 1, `the guest's operators in play are listed as members (${JSON.stringify(chip)})`);
st = await stripOf(host);
assert.equal(st.owner, null, 'the strip stays the host\'s on 全景');
await host.shot('fa-chip-partner');
await closeCard(host);
/** Step the ‹ › pill to `key` ('L' | 'ALL' | 'R') from 全景. */
const toLayer = async (c, key) => {
const arrow = key === 'L' ? '左侧战场' : '右侧战场';
if (key !== 'ALL') await c.click(`.chud__layers .vswitch__arrow[aria-label="${arrow}"]`);
await sleep(500);
};
const back = async (c, key) => { if (key !== 'ALL') await c.click(`.chud__layers .vswitch__arrow[aria-label="${key === 'L' ? '右侧战场' : '左侧战场'}"]`); await sleep(400); };
const partnerSide = hostSide === 'L' ? 'R' : 'L';
await toLayer(host, partnerSide);
await host.page.waitForFunction(() => document.querySelector('.gm__bonds .bstrip')?.getAttribute('data-owner') === '阿米娅', { timeout: 4000 });
st = await stripOf(host);
assert.deepEqual([st.owner, st.bonds], ['阿米娅', guestPub.ids], 'the partner\'s half: the GUEST\'s bonds');
assert.ok(st.tagFont >= 10, `desktop: the name is legible (${st.tagFont} px)`);
await host.shot('fa-partner-half');
// the reverse: on the partner's half (the strip is the guest's) the host's OWN operator's chip opens the host's bond
chip = await chipPopup(host, pair.me);
assert.ok(chip, 'host: a card of its own operator and its bond chip');
const hostNow = await bondsOf(host, '凯尔希');
assert.equal(chip.owner, null, `the partner's half: an own unit's chip opens the host's own popup (${JSON.stringify(chip)})`);
assert.equal(chip.tag, '');
assert.equal(chip.count, hostNow.counts[chip.bondId] ?? '0', `the host's member count (${JSON.stringify({ chip, hostNow })})`);
assert.equal(chip.chipCount, chip.count, 'the chip and its popup agree');
st = await stripOf(host);
assert.equal(st.owner, '阿米娅', 'the strip stays the guest\'s on that half');
await closeCard(host);
await back(host, partnerSide);
await toLayer(host, hostSide);
st = await stripOf(host);
assert.deepEqual([st.owner, st.bonds], [null, hostOwn.ids], 'the own half ("你自己"): the host\'s bonds');
// the phone: the guest looks at the host's half
const guestSide = hostSide === 'L' ? 'R' : 'L';
st = await stripOf(guest);
assert.equal(st.owner, null, 'guest, 全景: own bonds');
await toLayer(guest, hostSide);
await guest.page.waitForFunction(() => document.querySelector('.gm__bonds .bstrip')?.getAttribute('data-owner') === '凯尔希', { timeout: 4000 });
st = await stripOf(guest);
assert.deepEqual([st.owner, st.bonds], ['凯尔希', hostOwn.ids], 'phone: the partner\'s half shows the HOST\'s bonds');
assert.ok(st.tagRect && st.tagRect.left >= 0 && st.tagRect.right <= st.vw && st.tagRect.top >= 0 && st.tagRect.bottom <= st.vh, `phone: the tag is on screen (${JSON.stringify(st.tagRect)})`);
assert.ok(st.rect.right <= st.vw + 1, `phone: the strip stays inside the viewport (${JSON.stringify(st.rect)})`);
assert.ok(st.tagFont >= 9, `phone: the name is legible (${st.tagFont} px)`);
if (st.team) {
const overlap = !(st.tagRect.right <= st.team.left || st.tagRect.left >= st.team.right || st.tagRect.bottom <= st.team.top || st.tagRect.top >= st.team.bottom);
assert.ok(!overlap, `phone: the tag is clear of the team panel (${JSON.stringify({ tag: st.tagRect, team: st.team })})`);
}
await guest.shot('fa-partner-half');
await back(guest, hostSide);
await toLayer(guest, guestSide);
st = await stripOf(guest);
assert.equal(st.owner, null, 'phone, own half: own bonds');
assert.deepEqual(problemsOf([host, guest]), []);
} finally {
for (const c of [host, guest]) if (c.problems.length) console.log(c.label, c.problems.slice(0, 20).join('\n'));
await host.close();
await guest.close();
await srv.stop();
}
});
test('联防 observer: helper 1 on 全景, the picked helper\'s half + bonds, 返回战场 in the pill (phone leaker, desktop helper)', { timeout: 15 * 60 * 1000 }, async () => {
// the host fights with a 萨尔贡 kit, the AI with a 拉特兰 kit, the guest has no board (every enemy leaks)
const srv = await startRealServer({ fast: { timerScale: 1, combatSpeed: 2, startRound: 3, idleBots: true, autoPlace: true,
kits: [UNITE_HOST_KIT, []], botChess: BOT_KIT } });
const P = (await import('puppeteer-core')).default;
let host = null;
let guest = null;
try {
({ host, guest } = await coopMatch(P, srv.base, { bots: 1, prefix: 'watch-bonds-unite' }));
// up to three rounds: the guest (no board — fastServer keeps a band's gift operators in its hand) leaks; a 联防
// needs the host and the AI perfect (random waves). Only a 联防 of exactly those two helpers counts (review: the
// guest must be the leaker, not a helper)
let after = null;
let found = false;
for (let round = 3; round <= 5 && !found; round++) {
for (const c of [host, guest]) await c.waitFor((s) => s.phase === 'PREP' && s.round === round, `round-${round} prep`, 120000);
await sleep(1500);
for (const c of [host, guest]) if (!(await c.st()).ready) await c.click('.readybtn');
after = await guest.waitFor((s) => s.phase === 'UNITE' || s.phase === 'SETTLE' || (s.phase === 'PREP' && s.round > round), '联防', 300000);
const mv0 = after.phase === 'UNITE' ? await matchView(guest) : null;
const u0 = mv0 ? mv0.fields.find((f) => f.fieldId === 'u') : null;
if (u0 && u0.players.length === 2 && !u0.players.includes(mv0.me)) { found = true; break; }
console.log(`round ${round}: no 2-helper 联防 without the guest (${after.phase} ${JSON.stringify(mv0 ? mv0.fields : after.fields)}), next round`);
await guest.waitFor((s) => s.phase !== 'UNITE', '联防 over', 300000);
}
assert.ok(found && after.phase === 'UNITE', `the guest leaked and two helpers were perfect (${JSON.stringify(after.fields)})`);
await guest.page.waitForSelector('.chud__layers', { timeout: 15000 });
const mv = await matchView(guest);
const u = mv.fields.find((f) => f.fieldId === 'u');
assert.ok(u && u.players.length === 2 && !u.players.includes(mv.me), `a 2-helper 联防 without the guest (${JSON.stringify(mv.fields)})`);
const [h1, h2] = u.players.map((pid) => mv.names[pid]);
// 全景: helper 1's bonds, tagged — not the leaker's own
await ownerIs(guest, h1);
let st = await stripOf(guest);
assert.deepEqual([st.owner, st.other, st.bonds, st.pill], [h1, true, mv.bonds[h1], '全景'], 'leaker on 全景: helper 1');
await sleep(800);
await guest.shot('overview');
// the host (helper 1 or 2) on 全景: its own bonds
const hs = await stripOf(host);
assert.equal(hs.owner, null, 'the helper on 全景: own bonds');
// the guest picks each helper: the camera goes to their half, pill and strip agree, 返回战场 in the pill
for (const name of [h2, h1]) {
assert.equal((await guest.st()).phase, 'UNITE', '联防 still running');
await watchMate(guest, name);
await ownerIs(guest, name);
await guest.page.waitForFunction((name) => (document.querySelector('.chud__layers .vswitch__label')?.textContent || '').includes(name), { timeout: 6000 }, name);
st = await stripOf(guest);
assert.deepEqual([st.owner, st.bonds, st.observe], [name, mv.bonds[name], null], `picked ${name}: their bonds, no separate observing pill`);
assert.ok(st.pill.includes(name) && st.pillBack, `the ‹ › pill names ${name} and carries 返回战场 (${st.pill})`);
assert.ok(st.tagRect && st.tagRect.left >= 0 && st.tagRect.right <= st.vw && st.tagFont >= 9, `phone: the tag on screen, legible (${JSON.stringify(st.tagRect)} ${st.tagFont}px)`);
await sleep(900); // the camera flies to the half (750 ms)
await guest.shot(`picked-${name === mv.names[u.players[0]] ? 'helper1' : 'helper2'}`);
}
// 返回战场: no refused g.watch (no own field in 联防), the 联防 view stays with a helper's bonds
await guest.click('.chud__layers .chud__back', '返回战场');
await sleep(1200);
st = await stripOf(guest);
assert.ok(st.owner === h1 || st.owner === h2, `back: still a helper's bonds (${st.owner})`);
assert.equal(st.pillBack, false, 'back: the pill without 返回战场');
assert.deepEqual(await guest.page.evaluate(() => globalThis.__toastErrors), [], 'no error toast');
assert.deepEqual(problemsOf([host, guest]), []);
} finally {
for (const c of [host, guest]) if (c?.problems.length) console.log(c.label, c.problems.slice(0, 20).join('\n'));
await host?.close();
await guest?.close();
await srv.stop();
}
});
test('after the own battle: the watched teammate\'s bonds, kept through a reload with the observing pill + 返回战场 (desktop)', { timeout: 12 * 60 * 1000 }, async () => {
// the host's six 萨尔贡 operators end their battle early; the guest (no board) and the idle AI leak for the enemies'
// whole walk (combat at 1×: a window for the reload) — the host watches one of them, reloads, then goes back
const srv = await startRealServer({ fast: { timerScale: 1, combatSpeed: 1, startRound: 3, idleBots: true, autoPlace: true,
kits: [UNITE_HOST_KIT, []] } });
const P = (await import('puppeteer-core')).default;
let host = null;
let guest = null;
try {
({ host, guest } = await coopMatch(P, srv.base, { bots: 1, prefix: 'watch-bonds-after' }));
let checked = false;
for (let round = 3; round <= 5 && !checked; round++) {
for (const c of [host, guest]) await c.waitFor((s) => s.phase === 'PREP' && s.round === round, `round-${round} prep`, 180000);
await sleep(1500);
for (const c of [host, guest]) if (!(await c.st()).ready) await c.click('.readybtn');
await host.waitFor((s) => s.phase === 'COMBAT', 'combat', 60000);
// the own battle over while a teammate's still runs
const target = await host.page.waitForFunction(() => {
const s = globalThis.__SP__.store.get();
const pub = s.match.public;
if (pub?.phase !== 'COMBAT') return { gone: true };
const me = s.me.playerId;
const b = s.match.battle;
const mine = pub.players.find((p) => p.playerId === me);
if (!(mine?.status === 'done' || (b && b.fieldId === `n:${me}` && b.done))) return false;
const live = pub.fields.filter((f) => f.kind === 'normal' && f.live && f.players[0] !== me);
if (!live.length) return { gone: true };
const pick = live.find((f) => !pub.players.find((p) => p.playerId === f.players[0])?.isBot) || live[0];
return { fieldId: pick.fieldId, playerId: pick.players[0], name: pub.players.find((p) => p.playerId === pick.players[0]).name };
}, { timeout: 240000, polling: 250 }).then((h) => h.jsonValue());
if (target.gone) { console.log(`round ${round}: no teammate battle left to watch, next round`); continue; }
const name = target.name;
await watchMate(host, name);
await ownerIs(host, name);
const pubBonds = async () => (await matchView(host)).bonds[name];
let st = await stripOf(host);
// every bond the teammate's view lists is on the strip (live in-battle gains may add more)
for (const id of await pubBonds()) assert.ok(st.bonds.includes(id), `${name}'s ${id} on the strip (${JSON.stringify(st.bonds)})`);
assert.ok((st.observe || '').includes(name), `the observing pill names ${name} (${st.observe})`);
assert.ok(await host.page.$('.chud__observe .chud__back'), '返回战场 in the observing pill');
// the replica on screen (the strip follows the watch at once; the field arrives with its b.start)
const onScreen = await host.page.waitForFunction((fid) => {
const s = globalThis.__SP__.store.get();
if (s.match.public?.phase !== 'COMBAT') return { gone: true };
return s.match.battle && !s.match.battle.loading && s.match.battle.fieldId === fid ? { ok: true } : false;
}, { timeout: 30000, polling: 250 }, target.fieldId).then((h) => h.jsonValue());
if (onScreen.gone) { console.log(`round ${round}: the battles ended while the replica loaded, next round`); continue; }
assert.equal((await stripOf(host)).owner, name, 'the replica on screen: still their bonds');
await sleep(600);
await host.shot('watching');
// a reload while watching (pre-existing gap, review): the resent field is adopted — strip, pill and 返回战场
const before = host.problems.length;
await host.page.reload({ waitUntil: 'domcontentloaded' });
await host.page.waitForFunction(() => !!globalThis.__SP__ && !!document.querySelector('.screen'), { timeout: 30000 });
const resumed = await host.page.waitForFunction((fid) => {
const s = globalThis.__SP__.store.get();
if (s.match.public && s.match.public.phase !== 'COMBAT') return { gone: true };
return s.match.battle && !s.match.battle.loading && s.match.battle.fieldId === fid && document.querySelector('.gm__bonds .bstrip') ? { ok: true } : false;
}, { timeout: 60000, polling: 250 }, target.fieldId).then((h) => h.jsonValue());
host.problems.splice(before, Infinity, ...host.problems.slice(before).filter((p) => !/ERR_ABORTED/.test(p)));
if (resumed.gone) { console.log(`round ${round}: the battles ended during the reload, next round`); continue; }
await ownerIs(host, name);
await host.page.waitForFunction((name) => (document.querySelector('.chud__observe .vswitch__label')?.textContent || '').includes(name), { timeout: 8000 }, name);
assert.ok(await host.page.$('.chud__observe .chud__back'), 'after the reload: 返回战场 again');
assert.equal(await host.page.$('.chud__wait'), null, 'after the reload: no "等待队友" message in place of the observing pill');
st = await stripOf(host);
for (const id of await pubBonds()) assert.ok(st.bonds.includes(id), `after the reload: ${name}'s ${id} on the strip (${JSON.stringify(st.bonds)})`);
await sleep(1500); // the phase banner of the fresh screen
await host.shot('reloaded');
// 返回战场 → the own bonds, untagged
await host.click('.chud__observe .chud__back', '返回战场');
await host.page.waitForFunction(() => !document.querySelector('.gm__bonds .bstrip')?.getAttribute('data-owner'), { timeout: 8000 });
st = await stripOf(host);
assert.deepEqual([st.owner, st.observe], [null, null], 'back: own bonds, no observing pill');
const fid = await host.page.evaluate(() => globalThis.__SP__.store.get().match.battle?.fieldId ?? null);
const me = (await host.st()).me;
if ((await host.st()).phase === 'COMBAT') assert.equal(fid, `n:${me}`, 'back: the own battle on screen');
checked = true;
}
assert.ok(checked, 'a teammate\'s battle was watched, reloaded and left');
assert.deepEqual(problemsOf([host, guest]), []);
} finally {
for (const c of [host, guest]) if (c?.problems.length) console.log(c.label, c.problems.slice(0, 20).join('\n'));
await host?.close();
await guest?.close();
await srv.stop();
}
});
test('Final Assault spectator (eliminated): the pair\'s first player, each picked player\'s half + bonds, the lone field', { timeout: 8 * 60 * 1000 }, async () => {
const srv = await startRealServer({ fast: { timerScale: 1, combatSpeed: 2, startRound: 'boss', idleBots: true, autoPlace: true,
kits: [UNITE_HOST_KIT, []], botChess: BOT_KIT, eliminate: [1] } });
const P = (await import('puppeteer-core')).default;
let host = null;
let guest = null;
try {
({ host, guest } = await coopMatch(P, srv.base, { bots: 2, prefix: 'watch-bonds-fa' }));
await host.waitFor((s) => s.phase === 'PREP' && !s.ready, 'boss-round prep', 60000);
assert.equal((await guest.st()).alive, false, 'the guest is eliminated');
await sleep(1500);
await host.click('.readybtn');
for (const c of [host, guest]) await c.waitFor((s) => s.phase === 'FINAL_ASSAULT', 'Final Assault', 90000);
await guest.page.waitForSelector('.chud__layers', { timeout: 20000 });
await sleep(1000);
const mv = await matchView(guest);
const pair = mv.fields.find((f) => f.players.length === 2);
const lone = mv.fields.find((f) => f.players.length === 1);
assert.ok(pair && lone && mv.field?.fieldId === mv.fields[0].fieldId, `a pair field and a lone one, the first auto-observed (${JSON.stringify(mv)})`);
assert.equal(pair.fieldId, mv.fields[0].fieldId, 'the pair field is listed first');
const first = mv.names[pair.players[0]];
await ownerIs(guest, first);
let st = await stripOf(guest);
assert.deepEqual([st.owner, st.bonds, st.pill], [first, mv.bonds[first], '全景'], 'auto-observed pair, 全景: the first player (never the spectator\'s own)');
await guest.shot('auto');
// a card's bond chip (review): the strip follows the first player, the SECOND player's operator's chip opens the
// second player's bond (labelled, their count — the number the chip shows), the strip stays on the first
const second = mv.names[pair.players[1]];
const chip = await chipPopup(guest, pair.players[1]);
assert.ok(chip, `spectator: a card of ${second}'s operator and its bond chip`);
const secondNow = await bondsOf(guest, second);
assert.equal(chip.owner, second, `全景: ${second}'s unit chip opens ${second}'s popup (${JSON.stringify(chip)})`);
assert.equal(chip.count, secondNow.counts[chip.bondId] ?? '0', `${second}'s member count (${JSON.stringify({ chip, secondNow })})`);
assert.equal(chip.chipCount, chip.count, 'the chip and its popup agree');
if (Number(chip.count) > 0) assert.ok(chip.on >= 1, `${second}'s operators in play are listed as members (${JSON.stringify(chip)})`);
assert.equal((await stripOf(guest)).owner, first, 'the strip stays on the first player');
await guest.shot('chip-second');
await closeCard(guest);
for (const pid of [pair.players[1], pair.players[0]]) {
const name = mv.names[pid];
await watchMate(guest, name);
await ownerIs(guest, name);
await guest.page.waitForFunction((name) => (document.querySelector('.chud__layers .vswitch__label')?.textContent || '').includes(name), { timeout: 6000 }, name);
st = await stripOf(guest);
assert.deepEqual([st.owner, st.bonds, st.pillBack], [name, mv.bonds[name], false], `picked ${name}: their half and bonds (no 返回战场 when eliminated)`);
}
await sleep(900); // the camera flies to the half (750 ms)
await guest.shot('picked');
const loneName = mv.names[lone.players[0]];
await watchMate(guest, loneName);
await ownerIs(guest, loneName);
await guest.page.waitForFunction((name) => (document.querySelector('.chud__observe .vswitch__label')?.textContent || '').includes(name), { timeout: 8000 }, loneName);
st = await stripOf(guest);
assert.deepEqual([st.owner, st.bonds, st.observe], [loneName, mv.bonds[loneName], loneName], 'the lone field: its player, the observing pill names them');
// the host on its pair field, 全景: own
st = await stripOf(host);
assert.equal(st.owner, null, 'the host on 全景: own bonds');
assert.deepEqual(problemsOf([host, guest]), []);
} finally {
for (const c of [host, guest]) if (c?.problems.length) console.log(c.label, c.problems.slice(0, 20).join('\n'));
await host?.close();
await guest?.close();
await srv.stop();
}
});
});
+437
View File
@@ -0,0 +1,437 @@
// The bond strip follows the watched player (DESIGN §20.15, user report after playtest #6: "观看队友时(不仅是休整时候,
// 还有自己战斗结束时,联防时,最终boss战)盟约栏应该变成当前队友的盟约以及他的层数"). Node tests of the selection logic
// (public/js/ui/watchBonds.js): which field is on screen, whose bonds the strip shows in every watch situation — prep
// scouting, a teammate's battle after the own one, an eliminated player's auto-observed field, 联防 and the Final Assault
// (the ‹ › half the camera shows; 全景 = yourself when you fight there, else the teammate picked with 前往查看 / the
// field's first player — never the viewer's own bonds on a field they do not fight on), the game screen's state →
// strip (screenStrip, incl. a shared field watched with 前往查看), the camera half of a picked player, the live layers
// laid over (capped at 999), the teammate's member list, the strip / popup labelling and the ‹ › pill with 返回战场,
// and the popup of a card's bond chip (the UNIT owner's bond, not the strip's: detailBondOwner / toggleBond / popupView).
// Server side: test/match/watch-bonds.test.js; on screen: test/ui/watch-bonds.e2e.test.js.
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const read = (p) => readFileSync(path.join(ROOT, p), 'utf8');
// the browser data store reads the real data files from disk
globalThis.fetch = async (url) => {
const name = String(url).split('/').pop();
try {
const body = readFileSync(path.join(ROOT, 'data', name), 'utf8');
return { ok: true, status: 200, json: async () => JSON.parse(body) };
} catch {
return { ok: false, status: 404, json: async () => ({}) };
}
};
const { screenFieldId, bondOwnerId, ownerBonds, withLiveLayers, ownerBoard, stripView, playerBonds, watchedPlayer, playerLayer, screenStrip,
detailBondOwner, toggleBond, popupView } = await import('../../public/js/ui/watchBonds.js');
const { BondStrip, BondPopup } = await import('../../public/js/ui/bondStrip.js');
const { CombatHud } = await import('../../public/js/ui/combatHud.js');
const { cameraLayers } = await import('../../public/js/battle/observe.js');
const { BOND_LAYER_CAP } = await import('../../shared/constants.js');
const { data } = await import('../../public/js/data.js');
await data.loadAll('bonds', 'chess', 'assets');
/** Every vnode of a preact tree (htm output), depth first; function components are expanded. */
function* walk(v) {
if (Array.isArray(v)) { for (const x of v) yield* walk(x); return; }
if (!v || typeof v !== 'object') return;
yield v;
if (typeof v.type === 'function' && /^[A-Z]/.test(v.type.name) && ['OwnerTag'].includes(v.type.name)) yield* walk(v.type(v.props));
yield* walk(v.props?.children);
}
const hasClass = (v, c) => typeof v?.props?.class === 'string' && v.props.class.split(/\s+/).includes(c);
const textOf = (v) => {
if (v == null || typeof v === 'boolean') return '';
if (typeof v === 'string' || typeof v === 'number') return String(v);
if (Array.isArray(v)) return v.map(textOf).join('');
if (typeof v.type === 'function' && v.type.name === 'OwnerTag') return textOf(v.type(v.props));
return textOf(v.props?.children);
};
// ---- a 4-player co-op match as m.public / m.private carry it ----------------------------------------------------------
const ME = 'p1';
const bond = (bondId, count, layers, tier = count >= 2 ? 1 : 0) => ({ bondId, count, active: tier > 0, tier, layers });
const PUB = (extra = {}) => ({
phase: 'PREP',
players: [
{ playerId: 'p1', seat: 0, name: '凯尔希', alive: true, bonds: [bond('yanShip', 3, 12, 1)] },
{ playerId: 'p2', seat: 1, name: '阿米娅', alive: true, bonds: [bond('sargonShip', 2, 40, 1), bond('kjeragShip', 0, 7, 0)] },
{ playerId: 'p3', seat: 2, name: '华法琳', alive: true, bonds: [bond('lateranoShip', 4, 120, 2)] },
{ playerId: 'ai_4', seat: 3, name: 'AI·煌', alive: true, isBot: true, bonds: [] },
],
fields: [],
...extra,
});
const PRIV = { playerId: ME, bonds: [{ ...bond('yanShip', 3, 12, 1), thresholds: [3, 6, 9], countsHand: false }], board: [{ kind: 'chess', id: 'x' }], hand: [], temp: [] };
describe('§20.15 screenFieldId: the field on screen', () => {
test('prep: the own board (null) unless a teammate is scouted', () => {
assert.equal(screenFieldId({ combat: false, watchingOther: false, watching: null, home: 'n:p1' }), null);
assert.equal(screenFieldId({ combat: false, watchingOther: true, watching: 'n:p2', home: 'n:p1' }), 'n:p2');
});
test('battle: the watched field, else the battle the view shows (the auto-observed one of an eliminated player), else home', () => {
assert.equal(screenFieldId({ combat: true, watchingOther: true, watching: 'n:p3', battleFieldId: 'n:p1', home: 'n:p1' }), 'n:p3');
assert.equal(screenFieldId({ combat: true, battleFieldId: 'n:p2', home: 'n:p1' }), 'n:p2', 'eliminated: the runner\'s auto-observed field');
assert.equal(screenFieldId({ combat: true, battleFieldId: 'u', home: 'n:p1' }), 'u', '联防 observer');
assert.equal(screenFieldId({ combat: true, battleFieldId: null, home: 'b1' }), 'b1', 'before the runner shows anything: home');
});
test('SETTLE: the field the last battle left on screen', () => {
assert.equal(screenFieldId({ settle: true, battleFieldId: 'n:p2', home: 'n:p1' }), 'n:p2');
assert.equal(screenFieldId({ settle: true, home: 'n:p1' }), 'n:p1');
});
});
describe('§20.15 bondOwnerId: whose bonds the strip shows', () => {
test('own board / own battle → yourself', () => {
assert.equal(bondOwnerId({ pub: PUB(), myId: ME, fieldId: null }), ME);
assert.equal(bondOwnerId({ pub: PUB(), myId: ME, fieldId: 'n:p1' }), ME);
});
test('prep scouting, a teammate\'s battle after the own one, an eliminated player\'s auto-observed field → that teammate', () => {
assert.equal(bondOwnerId({ pub: PUB(), myId: ME, fieldId: 'n:p2' }), 'p2');
assert.equal(bondOwnerId({ pub: PUB({ phase: 'COMBAT' }), myId: ME, fieldId: 'n:ai_4' }), 'ai_4', 'an AI teammate too');
});
test('an unknown field owner falls back to yourself', () => {
assert.equal(bondOwnerId({ pub: PUB(), myId: ME, fieldId: 'n:ghost' }), ME);
assert.equal(bondOwnerId({ pub: PUB(), myId: ME, fieldId: '' }), ME);
});
// 联防 with two helpers: the first helper holds the right half (runner sides), research 09 §3.1
const unite = (helpers, sides) => ({
pub: PUB({ phase: 'UNITE', fields: [{ fieldId: 'u', kind: 'unite', players: helpers, live: true }], unite: { helpers, leakers: ['p3'] } }),
field: { fieldId: 'u', kind: 'unite', players: helpers, sides, local: true },
});
test('联防 helper: the ‹ › half → the player on it ("你自己" → yours, "👁 name" → theirs); 全景 → yours', () => {
const { pub, field } = unite(['p2', 'p1'], { p2: 'R', p1: 'L' });
const at = (layer) => bondOwnerId({ pub, myId: ME, fieldId: 'u', field, layer, halves: true });
assert.equal(at('R'), 'p2');
assert.equal(at('L'), ME);
assert.equal(at('ALL'), ME);
});
test('联防 observer (a leaker, not on the field): a half → its helper; 全景 → helper 1 (two helpers) / the lone helper (one) — never yours', () => {
const two = unite(['p2', 'ai_4'], { p2: 'R', ai_4: 'L' });
assert.equal(bondOwnerId({ pub: two.pub, myId: 'p3', fieldId: 'u', field: two.field, layer: 'L', halves: true }), 'ai_4');
assert.equal(bondOwnerId({ pub: two.pub, myId: 'p3', fieldId: 'u', field: two.field, layer: 'R', halves: true }), 'p2');
assert.equal(bondOwnerId({ pub: two.pub, myId: 'p3', fieldId: 'u', field: two.field, layer: 'ALL', halves: true }), 'p2', '全景: the first helper listed, not the leaker\'s own');
assert.equal(bondOwnerId({ pub: two.pub, myId: 'p3', fieldId: 'u', field: two.field, layer: 'ALL', halves: true, watched: 'ai_4' }), 'ai_4', '全景: the helper picked with 前往查看');
assert.equal(bondOwnerId({ pub: two.pub, myId: 'p3', fieldId: 'u', field: two.field, layer: 'L', halves: true, watched: 'p2' }), 'ai_4', 'a ‹ › half beats the pick');
// the avatar watch of the 联防 field before the pill is offered (halves = false): the picked helper, else helper 1
assert.equal(bondOwnerId({ pub: two.pub, myId: 'p3', fieldId: 'u', field: two.field, layer: 'ALL', halves: false, watched: 'ai_4' }), 'ai_4');
assert.equal(bondOwnerId({ pub: two.pub, myId: 'p3', fieldId: 'u', field: null, halves: false }), 'p2', 'no meta yet: the m.public listing');
assert.equal(bondOwnerId({ pub: two.pub, myId: 'p3', fieldId: 'u', field: two.field, halves: false, watched: 'p1' }), 'p2', 'a pick not on that field is ignored');
const one = unite(['p2'], { p2: 'R' });
assert.equal(bondOwnerId({ pub: one.pub, myId: 'p3', fieldId: 'u', field: one.field, layer: 'ALL', halves: true }), 'p2', 'the lone helper\'s field');
assert.equal(bondOwnerId({ pub: one.pub, myId: 'p3', fieldId: 'u', field: one.field, layer: 'L', halves: true }), 'p2', 'an empty half (无人在家) → as 全景');
assert.equal(bondOwnerId({ pub: one.pub, myId: 'p2', fieldId: 'u', field: one.field, layer: 'L', halves: true }), 'p2', 'the lone helper himself');
});
test('Final Assault pair: the partner\'s half → the partner; own half / 全景 → yours; an eliminated spectator on 全景 → the picked / first player', () => {
const pub = PUB({ phase: 'FINAL_ASSAULT', fields: [{ fieldId: 'b1', kind: 'boss', players: ['p1', 'p3'], live: true }, { fieldId: 'b2', kind: 'boss', players: ['p2', 'ai_4'], live: true }] });
const field = { fieldId: 'b1', kind: 'boss', players: ['p1', 'p3'], sides: { p1: 'L', p3: 'R' }, local: true };
const at = (layer, myId = ME, halves = true) => bondOwnerId({ pub, myId, fieldId: 'b1', field, layer, halves });
assert.equal(at('R'), 'p3');
assert.equal(at('L'), ME);
assert.equal(at('ALL'), ME);
assert.equal(at('R', ME, false), ME, 'no ‹ › pill (a lone field / server-run combat): the field rule');
assert.equal(at('ALL', 'p2'), 'p1', 'eliminated spectator, not on the pair, 全景: the pair\'s first player — never your own');
assert.equal(at('R', 'p2'), 'p3', 'eliminated spectator on a half: that player');
assert.equal(bondOwnerId({ pub, myId: 'p2', fieldId: 'b1', field, layer: 'ALL', halves: true, watched: 'p3' }), 'p3', 'the player picked with 前往查看');
assert.equal(bondOwnerId({ pub, myId: 'p2', fieldId: 'b1', field, halves: false, watched: 'p3' }), 'p3', 'no ‹ › pill: the pick still decides');
assert.equal(bondOwnerId({ pub, myId: 'p1', fieldId: 'b1', field, halves: false, watched: 'p3' }), ME, 'a pair member on 全景: own (a member never picks a player of its own field)');
// a stale field meta (another field) never decides the half
assert.equal(bondOwnerId({ pub, myId: ME, fieldId: 'b1', field: { ...field, fieldId: 'b2' }, layer: 'R', halves: true }), ME);
});
test('a lone player\'s boss field watched by an eliminated player → that player', () => {
const pub = PUB({ phase: 'FINAL_ASSAULT', fields: [{ fieldId: 'b2', kind: 'boss', players: ['p3'], live: true }] });
assert.equal(bondOwnerId({ pub, myId: 'p2', fieldId: 'b2', field: null }), 'p3');
});
});
describe('§20.15 the picked teammate and the camera half', () => {
test('watchedPlayer: the row tapped while that watch is on screen, else null', () => {
assert.equal(watchedPlayer({ fieldId: 'u', playerId: 'p2' }, 'u'), 'p2');
assert.equal(watchedPlayer({ fieldId: 'u', playerId: 'p2' }, 'b1'), null, 'another field is watched now');
assert.equal(watchedPlayer({ fieldId: 'u', playerId: 'p2' }, null), null, 'back home');
assert.equal(watchedPlayer(null, 'u'), null);
assert.equal(watchedPlayer({ fieldId: 'u' }, 'u'), null);
});
test('playerLayer: the ‹ › half of a picked player on a two-half field; null for a normal / lone field or a stranger', () => {
const pub = PUB({ phase: 'UNITE' });
const two = { fieldId: 'u', kind: 'unite', players: ['p2', 'ai_4'], sides: { p2: 'R', ai_4: 'L' }, local: true };
assert.equal(playerLayer(two, pub, 'p3', 'p2'), 'R');
assert.equal(playerLayer(two, pub, 'p3', 'ai_4'), 'L');
assert.equal(playerLayer(two, pub, 'p3', 'p1'), null, 'not on that field');
const pair = { fieldId: 'b1', kind: 'boss', players: ['p1', 'p3'], sides: { p1: 'L', p3: 'R' }, local: true };
assert.equal(playerLayer(pair, pub, 'p2', 'p3'), 'R');
assert.equal(playerLayer({ fieldId: 'b2', kind: 'boss', players: ['p3'], sides: { p3: 'L' } }, pub, 'p2', 'p3'), null, 'a lone boss field has no halves');
assert.equal(playerLayer({ fieldId: 'n:p2', kind: 'normal', players: ['p2'] }, pub, ME, 'p2'), null);
assert.equal(playerLayer(null, pub, ME, 'p2'), null);
});
});
describe('§20.15 screenStrip: the game screen\'s state → whose bonds (every watch situation)', () => {
const helpers = ['p2', 'ai_4'];
const UNITE = PUB({ phase: 'UNITE', fields: [{ fieldId: 'u', kind: 'unite', players: helpers, live: true }], unite: { helpers, leakers: ['p1', 'p3'] } });
const uField = { fieldId: 'u', kind: 'unite', players: helpers, sides: { p2: 'R', ai_4: 'L' }, local: true };
const FA = PUB({ phase: 'FINAL_ASSAULT', players: PUB().players.map((p) => (p.playerId === ME ? { ...p, alive: false } : p)),
fields: [{ fieldId: 'b1', kind: 'boss', players: ['p2', 'p3'], live: true }, { fieldId: 'b2', kind: 'boss', players: ['ai_4'], live: true }] });
const b1 = { fieldId: 'b1', kind: 'boss', players: ['p2', 'p3'], sides: { p2: 'L', p3: 'R' }, local: true };
// what game.js passes: layers = cameraLayers(field) on the field on screen (also a watched one), [] otherwise
const S = (o) => screenStrip({ pub: o.pub, priv: PRIV, myId: ME, home: 'n:p1', ...o, layers: o.layers ?? (o.field ? cameraLayers(o.field, o.pub, ME) : []) });
test('prep: the own board, then the scouted teammate (前往查看), back home → own again', () => {
assert.deepEqual([S({ pub: PUB() }).ownerId, S({ pub: PUB() }).name], [ME, null]);
const scout = S({ pub: PUB(), watchingOther: true, watching: 'n:p2', who: { fieldId: 'n:p2', playerId: 'p2' } });
assert.deepEqual([scout.fieldId, scout.ownerId, scout.name], ['n:p2', 'p2', '阿米娅']);
assert.deepEqual(scout.bonds.map((b) => b.bondId), ['sargonShip', 'kjeragShip']);
});
test('own battle over → a teammate\'s battle watched; an eliminated player\'s auto-observed normal field', () => {
const pub = PUB({ phase: 'COMBAT', fields: [{ fieldId: 'n:p1', kind: 'normal', players: ['p1'], live: false }, { fieldId: 'n:p3', kind: 'normal', players: ['p3'], live: true }] });
const live = { p3: { lateranoShip: 130 } };
const w = S({ pub, combat: true, watchingOther: true, watching: 'n:p3', battleFieldId: 'n:p3', who: { fieldId: 'n:p3', playerId: 'p3' }, bondLayers: live });
assert.deepEqual([w.ownerId, w.bonds[0].layers], ['p3', 130], 'their live layers');
const auto = S({ pub, combat: true, battleFieldId: 'n:p3', bondLayers: live });
assert.equal(auto.ownerId, 'p3', 'eliminated: the runner\'s auto-observed field');
});
test('联防 leaker auto-observing (全景): helper 1, tagged — not the leaker\'s own bonds', () => {
const st = S({ pub: UNITE, combat: true, battleFieldId: 'u', field: uField, layer: 'ALL' });
assert.deepEqual([st.fieldId, st.ownerId, st.self, st.name], ['u', 'p2', false, '阿米娅']);
assert.equal(S({ pub: UNITE, combat: true, battleFieldId: 'u', field: uField, layer: 'L' }).ownerId, 'ai_4', 'the ‹ › pill on helper 2\'s half');
});
test('联防 leaker taps a helper\'s avatar (前往查看 → watching \'u\'): that helper, with or without the ‹ › pill', () => {
const watch = (pid, o = {}) => S({ pub: UNITE, combat: true, watchingOther: true, watching: 'u', battleFieldId: 'u', field: uField, who: { fieldId: 'u', playerId: pid }, ...o });
assert.equal(watch('ai_4', { layer: 'ALL' }).ownerId, 'ai_4', '全景 after the pick: the picked helper');
assert.equal(watch('ai_4', { layer: 'L' }).ownerId, 'ai_4', 'the camera moved to the picked helper\'s half');
assert.equal(watch('p2', { layer: 'R' }).ownerId, 'p2');
assert.equal(watch('ai_4', { layers: [], layer: 'ALL' }).ownerId, 'ai_4', 'no ‹ › pill (halves = false): the pick decides');
assert.equal(watch('ai_4', { layer: 'R' }).ownerId, 'p2', 'the viewer stepped the pill to the other half: that helper');
// a pick of another watch (stale) never decides
assert.equal(S({ pub: UNITE, combat: true, watchingOther: true, watching: 'u', battleFieldId: 'u', field: uField, layer: 'ALL', who: { fieldId: 'n:ai_4', playerId: 'ai_4' } }).ownerId, 'p2');
});
test('联防 helper: 全景 / own half → own; the partner\'s half → the partner', () => {
const at = (layer) => screenStrip({ pub: UNITE, priv: PRIV, myId: 'p2', home: 'u', combat: true, battleFieldId: 'u', field: uField, layers: cameraLayers(uField, UNITE, 'p2'), layer }).ownerId;
assert.deepEqual([at('ALL'), at('R'), at('L')], ['p2', 'p2', 'ai_4']);
});
test('Final Assault, eliminated spectator: auto-observed pair 全景 → the first player; a picked player → them; the lone field → its player', () => {
assert.equal(S({ pub: FA, combat: true, battleFieldId: 'b1', field: b1, layer: 'ALL' }).ownerId, 'p2', 'never the spectator\'s own');
assert.equal(S({ pub: FA, combat: true, battleFieldId: 'b1', field: b1, layer: 'R' }).ownerId, 'p3');
const pick = S({ pub: FA, combat: true, watchingOther: true, watching: 'b1', battleFieldId: 'b1', field: b1, layer: 'ALL', who: { fieldId: 'b1', playerId: 'p3' } });
assert.deepEqual([pick.ownerId, pick.name], ['p3', '华法琳']);
const lone = { fieldId: 'b2', kind: 'boss', players: ['ai_4'], sides: { ai_4: 'L' }, local: true };
assert.equal(S({ pub: FA, combat: true, watchingOther: true, watching: 'b2', battleFieldId: 'b2', field: lone, who: { fieldId: 'b2', playerId: 'ai_4' } }).ownerId, 'ai_4');
});
test('live layers only in the battle phases and SETTLE (prep shows the views)', () => {
const live = { p2: { sargonShip: 99 } };
assert.equal(S({ pub: PUB(), watchingOther: true, watching: 'n:p2', bondLayers: live }).bonds[0].layers, 40, 'prep: the view');
assert.equal(S({ pub: PUB({ phase: 'SETTLE' }), settle: true, battleFieldId: 'n:p2', bondLayers: live }).bonds[0].layers, 99, 'SETTLE: live');
});
});
describe('§20.15 the ‹ › pill of a shared field watched with 前往查看 (ui/combatHud.js)', () => {
const layers = [{ key: 'L', label: '华法琳', self: false, watch: true }, { key: 'ALL', label: '全景', self: false, watch: false }, { key: 'R', label: '阿米娅', self: false, watch: true }];
test('observing + halves: the ‹ › pill (the picked player\'s half) with 返回战场 — no separate observing pill', () => {
const v = CombatHud({ pub: {}, myId: ME, watching: 'u', hud: null, myDone: false, client: { observing: { name: '阿米娅' }, onBack() {}, layers, layer: 'R', onLayer() {} } });
const pill = [...walk(v)].find((x) => hasClass(x, 'chud__layers'));
assert.ok(pill && hasClass(pill, 'is-observing'));
assert.match(textOf(pill), /阿米娅/);
assert.match(textOf(pill), /返回战场/);
assert.ok(![...walk(v)].some((x) => hasClass(x, 'chud__observe')));
});
test('observing a normal field: the "👁 name" pill + 返回战场; a helper\'s own pill has no 返回战场', () => {
const v = CombatHud({ pub: {}, myId: ME, watching: 'n:p2', hud: null, myDone: true, client: { observing: { name: '阿米娅' }, onBack() {}, layers: [], layer: 'ALL' } });
const pill = [...walk(v)].find((x) => hasClass(x, 'chud__observe'));
assert.ok(pill && /阿米娅/.test(textOf(pill)) && /返回战场/.test(textOf(pill)));
const own = CombatHud({ pub: {}, myId: ME, watching: null, hud: null, myDone: false, client: { observing: null, onBack() {}, layers, layer: 'ALL', onLayer() {} } });
const p2 = [...walk(own)].find((x) => hasClass(x, 'chud__layers'));
assert.ok(p2 && !hasClass(p2, 'is-observing') && !/返回战场/.test(textOf(p2)));
});
});
describe('§20.15 the bonds: views + live layers', () => {
test('ownerBonds: your m.private list, a teammate\'s m.public list', () => {
assert.equal(ownerBonds({ pub: PUB(), priv: PRIV, myId: ME, ownerId: ME }), PRIV.bonds);
assert.deepEqual(ownerBonds({ pub: PUB(), priv: null, myId: ME, ownerId: ME }), PUB().players[0].bonds, 'no m.private yet: the public row');
assert.deepEqual(ownerBonds({ pub: PUB(), priv: PRIV, myId: ME, ownerId: 'p2' }).map((b) => b.bondId), ['sargonShip', 'kjeragShip']);
assert.deepEqual(ownerBonds({ pub: PUB(), priv: PRIV, myId: ME, ownerId: 'nobody' }), []);
});
test('withLiveLayers: the battle\'s absolute counts lift the view\'s, never lower them, never past 999; a new bond joins', () => {
const list = [bond('sargonShip', 2, 40, 1), bond('kjeragShip', 0, 7, 0)];
assert.equal(withLiveLayers(list, null), list, 'nothing to lay over: the same list');
assert.equal(withLiveLayers(list, {}), list);
const out = withLiveLayers(list, { sargonShip: 46, kjeragShip: 3, victoriaShip: 2 });
assert.deepEqual(out.map((b) => [b.bondId, b.layers]), [['sargonShip', 46], ['kjeragShip', 7], ['victoriaShip', 2]]);
assert.deepEqual(out[2], { bondId: 'victoriaShip', count: 0, active: false, tier: 0, layers: 2 });
assert.equal(list[0].layers, 40, 'the view is not mutated');
assert.equal(withLiveLayers(list, { sargonShip: 5000 })[0].layers, BOND_LAYER_CAP, 'capped at 999');
assert.equal(withLiveLayers(list, { sargonShip: 40 }), list, 'equal counts: the same list');
});
test('stripView / playerBonds: the owner, the name (null for yourself) and the list with the owner\'s live layers', () => {
const pub = PUB({ phase: 'COMBAT', fields: [{ fieldId: 'n:p1', kind: 'normal', players: ['p1'], live: false }, { fieldId: 'n:p2', kind: 'normal', players: ['p2'], live: true }] });
const live = { p1: { yanShip: 15 }, p2: { sargonShip: 44 } };
const own = stripView({ pub, priv: PRIV, myId: ME, fieldId: 'n:p1', live });
assert.deepEqual([own.ownerId, own.self, own.name], [ME, true, null]);
assert.equal(own.bonds[0].layers, 15, 'the own battle\'s live count');
assert.deepEqual(own.bonds[0].thresholds, [3, 6, 9], 'm.private extras kept');
const mate = stripView({ pub, priv: PRIV, myId: ME, fieldId: 'n:p2', live });
assert.deepEqual([mate.ownerId, mate.self, mate.name], ['p2', false, '阿米娅']);
assert.deepEqual(mate.bonds.map((b) => [b.bondId, b.layers]), [['sargonShip', 44], ['kjeragShip', 7]], 'THEIR bonds, THEIR live count');
assert.deepEqual(playerBonds({ pub, priv: PRIV, myId: ME, ownerId: 'p3', live }).map((b) => b.layers), [120]);
});
test('ownerBoard: the teammate\'s operators on the field on screen feed the popup\'s member list (no hand)', () => {
const field = { fieldId: 'n:p2', prep: true, units: [
{ id: 1, kind: 'op', side: 'ally', ownerId: 'p2', defId: 'chess_char_1_19_a' },
{ id: 2, kind: 'token', side: 'ally', ownerId: 'p2', defId: 'tok' },
{ id: 3, kind: 'op', side: 'ally', ownerId: 'p3', defId: 'chess_other' },
{ id: 4, kind: 'enemy', side: 'enemy', ownerId: null, defId: 'enemy_1' },
] };
assert.deepEqual(ownerBoard(field, 'p2'), { board: [{ kind: 'chess', id: 'chess_char_1_19_a' }], hand: [], temp: [] });
assert.equal(ownerBoard(null, 'p2'), null);
});
test('ownerBoard: under client-side combat the battle on screen\'s operators join (the runner\'s meta predates their deploy)', () => {
const meta = { fieldId: 'b1', local: true, units: [{ id: 90, kind: 'enemy', side: 'enemy', ownerId: null, defId: 'boss' }] };
const ops = [{ kind: 'op', ownerId: 'p2', defId: 'chess_char_1_09_a' }, { kind: 'op', ownerId: 'p2', defId: 'chess_char_1_03_a' }];
assert.deepEqual(ownerBoard(meta, 'p2'), { board: [], hand: [], temp: [] }, 'the meta alone: nobody in play');
assert.deepEqual(ownerBoard(meta, 'p2', ops).board.map((x) => x.id), ['chess_char_1_09_a', 'chess_char_1_03_a']);
assert.deepEqual(ownerBoard(null, 'p2', ops).board.length, 2, 'the battle alone');
assert.deepEqual(ownerBoard(meta, 'p3', ops).board, [], 'only that owner\'s');
const v = popupView({ open: { id: 'sargonShip', ownerId: 'p2' }, pub: PUB(), priv: PRIV, myId: ME, field: meta, units: ops });
assert.deepEqual(v.priv.board.map((x) => x.id), ['chess_char_1_09_a', 'chess_char_1_03_a'], 'popupView passes them on');
assert.equal(popupView({ open: { id: 'yanShip', ownerId: ME }, pub: PUB(), priv: PRIV, myId: ME, field: meta, units: ops }).priv, PRIV, 'your own: your pieces');
});
});
describe('§20.15 the strip and the popup say whose bonds they show', () => {
const ids = Object.keys(data.get('bonds') || {}).slice(0, 2);
test('a teammate\'s strip: the amber "👁 name" tag, data-owner, aria-label; your own: none', () => {
assert.equal(ids.length, 2, 'bonds.json loaded');
const list = ids.map((id, i) => bond(id, 2 + i, 10 * (i + 1), 1));
const other = BondStrip({ bonds: list, owner: '阿米娅', onOpen() {} });
const root = [...walk(other)].find((v) => hasClass(v, 'bstrip'));
assert.ok(root && hasClass(root, 'is-other'));
assert.equal(root.props['data-owner'], '阿米娅');
assert.equal(root.props['aria-label'], '阿米娅 的盟约');
const tag = [...walk(other)].find((v) => hasClass(v, 'bstrip__owner'));
assert.ok(tag, 'the owner tag');
assert.match(textOf(tag), /阿米娅/);
assert.deepEqual([...walk(other)].filter((v) => hasClass(v, 'bslot')).map((v) => v.props['data-bond']).sort(), [...ids].sort(), 'one slot per bond (data-bond)');
const mine = BondStrip({ bonds: list, owner: null, onOpen() {} });
const r2 = [...walk(mine)].find((v) => hasClass(v, 'bstrip'));
assert.ok(!hasClass(r2, 'is-other') && r2.props['data-owner'] == null);
assert.ok(![...walk(mine)].some((v) => hasClass(v, 'bstrip__owner')));
});
test('a teammate without bonds: "name 尚未激活盟约" with the tag', () => {
const v = BondStrip({ bonds: [], owner: 'AI·煌', onOpen() {} });
assert.match(textOf(v), /AI·煌 尚未激活盟约/);
assert.ok([...walk(v)].some((x) => hasClass(x, 'bstrip__owner')));
assert.match(textOf(BondStrip({ bonds: [], onOpen() {} })), /部署干员以激活盟约/);
});
test('the popup of a teammate\'s bond: "👁 name 的盟约", their count / layers, members from their board', () => {
const id = ids[0];
const rec = data.lookup('bonds', id);
const member = (rec.visibleMembers && rec.visibleMembers[0]) || rec.members[0];
const v = BondPopup({ bondId: id, entry: bond(id, 2, 333, 1), priv: { board: [{ kind: 'chess', id: member }], hand: [], temp: [] }, owner: '阿米娅', onClose() {} });
const root = [...walk(v)].find((x) => hasClass(x, 'bpop'));
assert.ok(hasClass(root, 'is-other') && root.props['data-owner'] === '阿米娅');
assert.match(root.props['aria-label'], /^阿米娅 的盟约:/);
assert.match(textOf([...walk(v)].find((x) => hasClass(x, 'bpop__owner'))), /阿米娅.*的盟约/);
assert.match(textOf([...walk(v)].find((x) => hasClass(x, 'bpop__facts'))), /333/);
const on = [...walk(v)].filter((x) => hasClass(x, 'bpop__member') && hasClass(x, 'is-on'));
assert.equal(on.length, 1, 'the teammate\'s operator on the field is the one member in play');
const own = BondPopup({ bondId: id, entry: bond(id, 2, 5, 1), priv: PRIV, onClose() {} });
assert.ok(![...walk(own)].some((x) => hasClass(x, 'bpop__owner')));
});
});
test('§20.15 wiring: the game screen feeds the strip, the popup and the detail card from screenStrip (no viewer data for a teammate)', () => {
const src = read('public/js/screens/game.js');
assert.match(src, /const strip = screenStrip\(\{[^}]*field, layers, layer, who: watchWho, bondLayers:/, 'the field on screen, the ‹ › layers, the picked teammate');
assert.match(src, /<\$\{BondStrip\} bonds=\$\{stripBonds\}[^>]*owner=\$\{strip\.name\}/);
// the popup carries the player it was opened for: the strip's owner from the strip, the unit owner from a card's chip
assert.match(src, /const bondPop = popupView\(\{ open: bondOpen, pub, priv, myId, field, units: popOps, live: liveLayers \}\)/);
assert.match(src, /battleRunner\.ownerOps\(bondOpen\.ownerId, field\.fieldId\)/, 'a teammate\'s members in play: the battle on screen\'s operators');
assert.match(src, /<\$\{BondPopup\} bondId=\$\{bondPop\.bondId\} entry=\$\{bondPop\.entry\} priv=\$\{bondPop\.priv\}[^>]*owner=\$\{bondPop\.name\}/);
assert.match(src, /onOpen=\$\{\(id\) => openBond\(id, strip\.ownerId, 'strip'\)\}/, 'the strip opens its owner\'s bond');
assert.match(src, /const detailOwner = detailBondOwner\(detailTarget, \{ pub, myId, stripOwnerId: strip\.ownerId \}\)/);
assert.match(src, /onBond=\$\{\(id\) => openBond\(id, detailOwner, 'detail'\)\}/, 'a card\'s chip opens the same owner\'s bond as its chips show');
assert.match(src, /onMember=\$\{\(id\) => setDetail\(\{ kind: 'chess', id, owner: bondPop\.ownerId \}\)\}/, 'a member card keeps the popup\'s player');
assert.match(src, /openId=\$\{bondPop && bondPop\.ownerId === strip\.ownerId \? bondPop\.bondId : null\}/, 'the strip marks only its own owner\'s popup');
assert.match(src, /setBondOpen\(\(b\) => \(b && b\.from === 'strip' \? null : b\)\)/, 'a strip popup closes when the strip changes hands');
assert.ok(!/stripBonds\.find\(\(b\) => b\.bondId === bondOpen\)/.test(src), 'no popup entry read from the strip regardless of its owner');
assert.match(src, /requestWatch\(t\.fieldId, p\.playerId\)/, 'a team-row pick remembers the player');
assert.match(src, /\(!watchingOther \|\| field\.fieldId === watching\) \? cameraLayers\(field, pub, myId\)/, 'the ‹ › pill on a watched shared field');
// a reload while watching a teammate's battle: the resent field becomes the watched one again (observing pill, 返回战场)
assert.match(src, /const r = resumedWatch\(battleState, \{ pub, myId, alive, watching, seen: seenBattleRef\.current \}\);\s*seenBattleRef\.current = r\.seen;\s*if \(r\.fieldId\) setWatching\(r\.fieldId\);/);
});
describe('§20.15 the popup of a card\'s bond chip: the UNIT owner\'s bond (a shared field shows both halves\' units)', () => {
// a Final Assault pair: p1 (the viewer, left) and p2 (the partner, right) on 'b1'
const pub = PUB({ phase: 'FINAL_ASSAULT', fields: [{ fieldId: 'b1', kind: 'boss', players: ['p1', 'p2'], live: true }, { fieldId: 'b2', kind: 'boss', players: ['p3', 'ai_4'], live: true }] });
const units = [
{ id: 11, uid: 1, ownerId: 'p1', side: 'player', kind: 'op', defId: 'chess_char_1_10_a' },
{ id: 21, uid: 1, ownerId: 'p2', side: 'player', kind: 'op', defId: 'chess_char_1_12_a' },
{ id: 22, uid: 2, ownerId: 'p2', side: 'player', kind: 'op', defId: 'chess_char_1_09_a' },
{ id: 90, side: 'enemy', defId: 'enemy_1007_slime' },
];
const b1 = { fieldId: 'b1', kind: 'boss', players: ['p1', 'p2'], sides: { p1: 'L', p2: 'R' }, local: true, units };
const unit = (id) => ({ kind: 'unit', unitId: id, unit: units.find((u) => u.id === id) });
test('detailBondOwner: a unit → its owner (a known player); your piece → yours; a member card → the popup\'s player; else the strip\'s', () => {
assert.equal(detailBondOwner(unit(21), { pub, myId: ME, stripOwnerId: ME }), 'p2', '全景 (strip = yours): the partner\'s unit is the partner\'s');
assert.equal(detailBondOwner(unit(11), { pub, myId: ME, stripOwnerId: 'p2' }), ME, 'the partner\'s half (strip = theirs): your own unit is yours');
assert.equal(detailBondOwner(unit(90), { pub, myId: ME, stripOwnerId: 'p2' }), 'p2', 'an enemy (no owner): the strip\'s');
assert.equal(detailBondOwner({ kind: 'unit', unit: { id: 5, ownerId: 'ghost', defId: 'x' } }, { pub, myId: ME, stripOwnerId: 'p2' }), 'p2', 'an unknown owner: the strip\'s');
assert.equal(detailBondOwner({ kind: 'unit', unitId: 99, unit: null }, { pub, myId: ME, stripOwnerId: 'p3' }), 'p3', 'an unresolved unit: the strip\'s');
assert.equal(detailBondOwner({ kind: 'piece', uid: 3 }, { pub, myId: ME, stripOwnerId: 'p2' }), ME, 'your own piece (board / bench)');
assert.equal(detailBondOwner({ kind: 'chess', id: 'c', owner: 'p2' }, { pub, myId: ME, stripOwnerId: ME }), 'p2', 'a member card of the partner\'s popup');
assert.equal(detailBondOwner({ kind: 'chess', id: 'c' }, { pub, myId: ME, stripOwnerId: 'p3' }), 'p3', 'a shop / drawer card: the strip\'s');
assert.equal(detailBondOwner(null, { pub, myId: ME, stripOwnerId: null }), ME);
});
test('toggleBond: the same bond of the same player closes; another player\'s or another bond switches', () => {
const a = toggleBond(null, 'sargonShip', 'p2', 'detail');
assert.deepEqual(a, { id: 'sargonShip', ownerId: 'p2', from: 'detail' });
assert.equal(toggleBond(a, 'sargonShip', 'p2', 'strip'), null);
assert.deepEqual(toggleBond(a, 'sargonShip', ME, 'strip'), { id: 'sargonShip', ownerId: ME, from: 'strip' }, 'the same bond of the viewer: their popup instead');
assert.deepEqual(toggleBond(a, 'kjeragShip', 'p2', 'detail'), { id: 'kjeragShip', ownerId: 'p2', from: 'detail' });
});
test('popupView: the partner\'s bond (entry + live layers, name, their operators as members), never the viewer\'s', () => {
const v = popupView({ open: { id: 'sargonShip', ownerId: 'p2', from: 'detail' }, pub, priv: PRIV, myId: ME, field: b1, live: { p2: { sargonShip: 55 }, p1: { sargonShip: 3 } } });
assert.equal(v.ownerId, 'p2');
assert.equal(v.self, false);
assert.equal(v.name, '阿米娅');
assert.deepEqual([v.entry.bondId, v.entry.count, v.entry.active, v.entry.layers], ['sargonShip', 2, true, 55], 'the partner\'s count / tier, their live layers');
assert.notEqual(v.priv, PRIV, 'not the viewer\'s pieces');
assert.deepEqual(v.priv.board.map((x) => x.id).sort(), ['chess_char_1_09_a', 'chess_char_1_12_a'], 'the partner\'s operators on the field on screen');
assert.deepEqual([v.priv.hand, v.priv.temp], [[], []], 'their hand is never sent');
});
test('popupView: your own bond (m.private entry with thresholds, your pieces, no name); a bond not listed → no entry; nothing open → null', () => {
const own = popupView({ open: { id: 'yanShip', ownerId: ME, from: 'detail' }, pub, priv: PRIV, myId: ME, field: b1, live: null });
assert.deepEqual([own.self, own.name, own.priv], [true, null, PRIV]);
assert.deepEqual(own.entry.thresholds, [3, 6, 9], 'the m.private entry');
assert.equal(popupView({ open: { id: 'lateranoShip', ownerId: 'p2' }, pub, priv: PRIV, myId: ME, field: b1 }).entry, null, 'the partner has no such bond');
assert.equal(popupView({ open: null, pub, priv: PRIV, myId: ME }), null);
assert.equal(popupView({ open: { id: 'yanShip' }, pub, priv: PRIV, myId: ME }).ownerId, ME, 'no owner: yours');
const capped = popupView({ open: { id: 'sargonShip', ownerId: 'p2' }, pub, priv: PRIV, myId: ME, field: b1, live: { p2: { sargonShip: 5000 } } });
assert.equal(capped.entry.layers, BOND_LAYER_CAP, 'never above the 999 cap');
assert.equal(popupView({ open: { id: 'sargonShip', ownerId: 'p2' }, pub, priv: PRIV, myId: ME, field: null }).priv, null, 'no field on screen: no members');
});
test('the reported case end to end: on 全景 the strip is yours, the partner\'s unit chip opens the PARTNER\'s popup — and back', () => {
const layers = cameraLayers(b1, pub, ME);
const S = (layer) => screenStrip({ pub, priv: PRIV, myId: ME, combat: true, battleFieldId: 'b1', home: 'b1', field: b1, layers, layer });
const all = S('ALL');
assert.deepEqual([all.ownerId, all.self], [ME, true], '全景: your strip');
const owner = detailBondOwner(unit(21), { pub, myId: ME, stripOwnerId: all.ownerId });
const chips = owner === all.ownerId ? all.bonds : playerBonds({ pub, priv: PRIV, myId: ME, ownerId: owner });
assert.deepEqual(chips.map((b) => b.bondId), ['sargonShip', 'kjeragShip'], 'the card\'s chips: the partner\'s bonds');
const pop = popupView({ open: toggleBond(null, 'sargonShip', owner, 'detail'), pub, priv: PRIV, myId: ME, field: b1 });
assert.deepEqual([pop.name, pop.entry.count, pop.entry.layers], ['阿米娅', 2, 40], 'the popup: the partner\'s, labelled');
// the partner's half (strip = theirs): your own unit's chip opens YOUR popup, unlabelled
const right = S('R');
assert.equal(right.ownerId, 'p2');
const mine = detailBondOwner(unit(11), { pub, myId: ME, stripOwnerId: right.ownerId });
const pop2 = popupView({ open: toggleBond(null, 'yanShip', mine, 'detail'), pub, priv: PRIV, myId: ME, field: b1 });
assert.deepEqual([pop2.self, pop2.name, pop2.entry.layers], [true, null, 12]);
});
});