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:
1 parent
620f88c0d8
commit
82c64086d5
21 files changed
+2087
-57
No files matched your search
+81
-1
@@ -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
@@ -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
@@ -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. 结算称号
|
||||
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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 ------------------------------------------------------------------------------------------------ */
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>`;
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 = {};
|
||||
|
||||
@@ -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
@@ -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
@@ -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 */ }
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user