feedback5: merge W5G — 补位 end to end: the 干员持有 tab (drop NORMAL chess), room.ownership, stand-ins in every field, 「替补:X」 badges, the stand-in's body on the board and detail card, golden standins family
Golden regenerated on the merged code (the textual merge had no conflict and equals the regenerated files): against feedback5 only the new match coop2-NORMAL-14-standins and the new standins.json (10 battles); against fb5-standin-game exactly the W5F and W5J scenarios (roster-001/002/007/009/016/026/027/028/038/040/041/042/044, bond-egirShip-low / siracusaShip-high / suntShip-high, boss-boss_2-pair / boss_6-pair, solo-NORMAL-1, coop2-NORMAL-3, coop3-ABYSS-5, coop4-FUNNY-6, coop2-NORMAL-8-serverrun); standins unchanged. test/match 536/536, the stand-in / golden / docs suites 175/175.
This commit is contained in:
commit
ce0d85c78a
51 files changed
+2741
-188
No files matched your search
+17
-8
@@ -146,7 +146,7 @@ Top level: `{ season, seasonName, modes, economy, lpCapPerRound, bossOvertimeAft
|
||||
| `identifier`, `shopSortId` | `133`, `1` | official ordering |
|
||||
| `isHidden`, `isDiy`, `visible` | | `visible = !isHidden && !isDiy` (only visible chess enter the shop pool) |
|
||||
| `chessType` | `"PRESET"` | `PRESET` / `NORMAL` / `DIY` |
|
||||
| `backup` | `{"charId":"char_611_acnipe","tmplId":null,"skillIndex":2,"uniEquipId":"uniequip_002_acnipe","potRank":0}` (`chess_char_5_22_a/_b`, 妮芙) | the shop row's official stand-in fields, verbatim (`backupCharId`, `backupTmplId`, `backupCharSkillIndex`, `backupCharUniEquipId`, `backupCharPotRank`), the same on both forms: PRESET (特许) = itself, NORMAL = the 补位 stand-in fielded when the player does not own the operator (§18), DIY = none (`charId` null). The current match ignores it (every chess fields its real operator) |
|
||||
| `backup` | `{"charId":"char_611_acnipe","tmplId":null,"skillIndex":2,"uniEquipId":"uniequip_002_acnipe","potRank":0}` (`chess_char_5_22_a/_b`, 妮芙) | the shop row's official stand-in fields, verbatim (`backupCharId`, `backupTmplId`, `backupCharSkillIndex`, `backupCharUniEquipId`, `backupCharPotRank`), the same on both forms: PRESET (特许) = itself, NORMAL = the 补位 stand-in fielded when the player marked the operator as not owned (干员持有, §18), DIY = none (`charId` null) |
|
||||
| `charId` | `"char_498_inside"` | operator (null for DIY) |
|
||||
| `name`, `appellation` | `"隐现"`, `"Insider"` | |
|
||||
| `rarity` | `5` | stars 1–6 |
|
||||
@@ -591,9 +591,11 @@ preference; against the pure official algorithm: identical route lengths, never
|
||||
|
||||
## 18. `backups.json` — 补位 stand-ins and 自选 (DIY) data — `{ units, diy }`
|
||||
|
||||
The data of two official features whose gameplay is not implemented yet (DESIGN 0.2.0 draft): **补位** — a NORMAL chess
|
||||
whose operator the player does not own is fielded as its official stand-in (原型干员) — and **自选编队** — two tier-5 and
|
||||
two tier-6 DIY slots, each filled with a 6★ the player owns or a prototype. Built by `tools/build-data.mjs buildBackups`;
|
||||
The data of two official features: **补位** — a NORMAL chess whose operator the player does not own is fielded as its
|
||||
official stand-in (原型干员); played since 0.2.0: the player marks operators as not owned on the 干员持有 tab
|
||||
(`room.ownership { notOwned }`, DESIGN 0.2.0 draft, docs/PLAYING.md §3), the match fields those chess with
|
||||
`standIn: true` — and **自选编队** — two tier-5 and two tier-6 DIY slots, each filled with a 6★ the player owns or a
|
||||
prototype (gameplay not implemented yet). Built by `tools/build-data.mjs buildBackups`;
|
||||
`shared/standIn.js` composes it into chess-shaped records. The rules in the data (activity_table act2autochess
|
||||
`charShopChessDatas`; PRTS 卫戍协议, 卫戍协议:盟约 下半/PRTS盟约记录):
|
||||
|
||||
@@ -631,10 +633,17 @@ the NORMAL chess as its stand-in — `IDENTITY_FIELDS` (ids, tier, `isHidden` /
|
||||
with `backup.skillIndex` / `backup.uniEquipId` as the defaults, plus `standInFor` (the replaced charId: the official 补位
|
||||
mark on the avatar). The result is shaped exactly like a chess record (`skill` = the `isDefault` entry of `skills[]`;
|
||||
elite `statsBase` / `traitBase` / `talentsBase` / `modules[]`), so `normalizeChess`, `resolveLoadout` (no loadout ⇒ the
|
||||
backup selection) and `loadoutRecord` read it unchanged; null for a PRESET or DIY chess. **In battle** a PlayerBattleInput
|
||||
entry with `standIn: true` is fielded as `getChess(chessId, { standIn: true })` (docs/SIM.md §12: this record, normalised),
|
||||
and its kit is found by its `charId` (`server/sim/content/index.js kitOf`; kits/README.md "Stand-in kits") — never by
|
||||
the chess id it keeps, which names the replaced operator's kit. `diyRecord(slot, charId, backups, { skillIndex, moduleId })` fills a DIY slot with a prototype (the slot's
|
||||
backup selection) and `loadoutRecord` read it unchanged; null for a PRESET or DIY chess. `isDroppableChess(chess)` says
|
||||
which chess a player may mark as not owned (the 55 NORMAL base chess; `shared/protocol.js checkNotOwned` keeps those of a
|
||||
`room.ownership` list). **In the match** (`server/match/player/basics.js`: `PlayerState.standIns`, `fieldsStandIn`,
|
||||
`fieldRecord` = `gd.standIn(id)`) the player's piece keeps the chess's identity for every meta rule (price, bonds,
|
||||
特质, merges, pools) and is deployed as the stand-in (placement class, summon / bot ranges, the prep scouting art; no
|
||||
summons — none of the 17 has one). **In battle** a PlayerBattleInput entry with `standIn: true` (PlayerState.battleInput:
|
||||
the player's own field, 联防 and the boss fields alike) is fielded as `getChess(chessId, { standIn: true })` (docs/SIM.md
|
||||
§12: this record, normalised), and its kit is found by its `charId` (`server/sim/content/index.js kitOf`;
|
||||
kits/README.md "Stand-in kits") — never by the chess id it keeps, which names the replaced operator's kit. The client
|
||||
composes the same record (`public/js/ui/gameLogic/standIn.js standInOf`, the renderer's `data.standIn`) for the cards'
|
||||
「替补:X」 badge, the board model and the detail card. `diyRecord(slot, charId, backups, { skillIndex, moduleId })` fills a DIY slot with a prototype (the slot's
|
||||
tier and price, no 特质, the pick's derived bonds); which skill a prototype carries in a slot is not in the data.
|
||||
|
||||
`diy`:
|
||||
|
||||
+4
-1
@@ -433,7 +433,7 @@ Each domain file `server/sim/content/{tokens,bonds,garrisons,items,bands,enemies
|
||||
Transport: one WebSocket per tab at `/ws`, JSON text frames `{ t: '<type>', ...payload }`. Client requests may carry `rid` (request id); the server's direct reply echoes `rid` (`ok` or `error`). Server pushes have no `rid`. Max frame 64 KB inbound; rate limit 40 msgs/s per connection (excess dropped with `error RATE`).
|
||||
|
||||
### 8.1 Session & lobby
|
||||
C→S: `hello {name, token?, version}` · `room.create {mode:'solo'|'coop', difficulty}` · `room.join {code}` · `room.leave` · `room.ready {ready}` · `room.setDifficulty {difficulty}` (host) · `room.addBot` / `room.removeBot {seat}` (host) · `room.kick {seat, playerId}` (host, before the match: another human leaves the room and gets `room.closed {reason:'kicked'}`; may join again; `playerId` = the player the host confirmed, a seat that changed hands meanwhile → `BAD_TARGET`) · `room.start` (host) · `room.loadout {entries}` (§16: any time in the lobby / room and during INFO_CHECK, then `WRONG_PHASE`; heavy rate-limit bucket; the match exposes it as `m.private.loadout`) · `ping {c}`
|
||||
C→S: `hello {name, token?, version}` · `room.create {mode:'solo'|'coop', difficulty}` · `room.join {code}` · `room.leave` · `room.ready {ready}` · `room.setDifficulty {difficulty}` (host) · `room.addBot` / `room.removeBot {seat}` (host) · `room.kick {seat, playerId}` (host, before the match: another human leaves the room and gets `room.closed {reason:'kicked'}`; may join again; `playerId` = the player the host confirmed, a seat that changed hands meanwhile → `BAD_TARGET`) · `room.start` (host) · `room.loadout {entries}` (§16: any time in the lobby / room and during INFO_CHECK, then `WRONG_PHASE`; heavy rate-limit bucket; the match exposes it as `m.private.loadout`) · `room.ownership {notOwned}` (0.2.0 补位, the 干员持有 tab: the base chess ids marked as not owned — ≤ 160 ids, kept leniently: anything but a droppable NORMAL chess is dropped; stored on the session and the seat, a match takes the seat's list when it starts and never changes it — during a match `ROOM_STARTED` 'stored for the next match'; heavy bucket; the match exposes it as `m.private.standIns`) · `ping {c}`
|
||||
S→C: `welcome {playerId, token, name, serverNow}` · `room.state {code, hostId, mode, difficulty, seats:[{seat, playerId, name, isBot, ready, connected}|null ×4]}` · `room.closed {reason}` · `pong {c, s}` · `error {rid?, code, msg}` · `ok {rid}`
|
||||
|
||||
Platform additions (implemented): `welcome.{resumed, version}`, `room.state.inMatch`, `error.detail`; `room.closed.reason ∈ 'timeout'|'kicked'|'empty'|'shutdown'` (`kicked`: room.kick / room.removeSpectator; `empty`: a spectator whose room lost its last player); WebSocket close codes 4001 (session taken over by another socket — client must not auto-reconnect), 4002 (no hello in time), 1008 (flooding), 1001 (shutdown). The host's `room.start` counts as the host being ready. After any (re)`hello` the server pushes `room.state` and, if a match runs, the match resends `m.public`/`m.private` (+`m.field`, or the field's `b.start` under client-side combat). **Reconnect window:** a dropped session stays resumable for 10 min (`net.js NET_DEFAULTS.reconnectWindowMs`; co-op seats keep playing their last lineup meanwhile, §6.6); a session that drops while its **solo** room's match runs stays resumable for the official `config.constants.singleReconnectTime` (86 400 s = 24 h; lobby option `soloReconnectWindowMs`; research 01 §1 "24小时内随时返回") — the untimed solo run simply waits. Only when the window expires does the drop become `match.onLeave` (co-op: 中途退出 = elimination; solo: the run ends `'abandoned'`). The client keeps its token in sessionStorage plus a recent-token list in localStorage, so reopening the tab resumes. Everything lives in server memory: a server restart ends every room and run. The lobby⇄match interface (constructor opts, `start/handle/onDisconnect/onReconnect/onLeave/dispose`) is documented verbatim at the top of `server/match/Match.js` and is normative. `server/data.js` provides `getData()` + index getters (`getChess`, `getBond`, `getGarrison`, `getItem`, `getBand`, `getEffect`, `getEnemy`, `getWave`, `getStage`, `getBoss`, `getToken`, `getConfig`, `getMode`). Note: the skill trigger rule lives at `chess.skill.trigger.rule` in data.
|
||||
@@ -469,6 +469,8 @@ S→C:
|
||||
bonds: [ { bondId, count, active, tier, layers, harmony? /* 调和's +1 is in count, §21.26 */, thresholds, countsHand } ], // sorted: active first, then layers desc; an `off` entry (a bond the mode never activates, with members: count only, never active — the strip's grey 本局禁用 disc, bondsMeta.offBondCounts) comes last
|
||||
effects: [ { id, name, desc, iconKind: 'band'|'choice'|'team'|'item'|'garrison', iconId, counter?, counterText? /* a bounty: 还剩 N 场作战, §20.6 */ } ],
|
||||
nextEnemies: [ { enemyKey, count, tag, start? } ], // preview of the upcoming round's wave (after waves.js generation); `start` = a leader's spawn tile [row, col] on the boss field (the boss-field prep shows it standing there, community report #12)
|
||||
loadout: { [baseChessId]: { skill, module } }, // §16: the effective loadout (chess not listed: defaults)
|
||||
standIns: [ baseChessId ], // 0.2.0 补位: the chess this player fields as their stand-ins in this match (sorted; [] = everything owned)
|
||||
stats: { dmgDealt, kills, leaks, gold, refreshes, merges } }
|
||||
Piece = { uid, kind: 'chess'|'item'|'token', id, golden: bool, tier, items: [ { uid, id } ] /* chess only */, count /* token stacks */, ownerUid /* token */ }
|
||||
```
|
||||
@@ -626,6 +628,7 @@ Official rule (bwiki 盟约, 更新公告 5114): before a match the player canno
|
||||
**UI:** a 干员调配 screen reachable from the lobby, room and briefing (INFO_CHECK): filter by tier/bond/class, per chess choose skill (icon, name, SP info, desc at Lv4 and Lv7) and elite module (official type icon from the local-client art when extracted (§13), else a lettered tile; name/type, stat bonus, trait/talent changes), 恢复默认, persisted; a 局内数值 section between the skills and the modules (普通 / 精锐 toggle, 精锐 first) draws the detail card's own stats block, 特性 and 天赋 for the chosen skill / module (§22.16); the chosen skill/module shown on shop cards and in the detail panel (a teammate's unit — 前往查看 in prep, 联防 / 最终攻势 fields, an observed battle — shows **its owner's** skill/module from its UnitInfo `skillIndex` / `moduleId`, `gameLogic.js unitLoadout`) — which also shows the stats, 特性, talents and attack range the unit fights with (`shared/loadoutRecord.js` `loadoutRecord` / `attackRangeGrid`, the same composition the sim's `getChess(id, loadout)` uses; the deploy wheel's range preview too) — the range it is deployed with: a selected passive range skill (引星棘刺 S3 3-9) and the 特性's permanent 攻击距离 (信仰搅拌机 SPT-Y +1) included, over all 1284 loadouts equal to the battle's range at deployment; in battle and prep the card draws the live `range` (§21.16). In battle the renderer plays the equipped skill's own Spine clip and ON_SKILL_START sound (`UnitInfo.skillIndex`; `data/assets.json` lists icons, clips and sounds for every skill index).
|
||||
|
||||
**导出 / 导入 a loadout:** 导出 hands the current loadout out as a versioned envelope `{ kind: 'stronghold.loadout', v, exportedAt, count, entries }` — copied through `ui/clipboard.js copyText` or downloaded as a file. 导入 accepts that envelope, the stored `{ v, entries }` form and a bare `{ [chessId]: { skill, module } }` map (serialised text too), and refuses another tool's `kind`, junk, an empty result and a **newer** `v` (`parseImport` — a newer payload is never mis-read). Both go through `loadoutSync.applyLoadoutEntries` → `sanitizeEntries`, so a preset from another build never sends the server an entry it would refuse, an import that keeps nothing changes nothing, and the result is reported as N imported / M not (`ui/loadoutModel.js`, `screens/loadout.js`).
|
||||
**干员持有 (0.2.0 补位):** the overlay's second tab marks operators as not owned (the 55 NORMAL chess; per-browser `sp.pref.ownership` = `{ v, notOwned }`, synced as `room.ownership { notOwned }` by the same sync engine as the loadout, `ui/loadoutSync.js installPrefSync`; 导出 / 导入 as `{ kind: 'stronghold.ownership', v, exportedAt, count, notOwned }`). A not-owned chess fights as its official stand-in with the backup skill / module — the loadout never applies to it ("对于补位干员其技能不可更改") — while keeping its card (name, bonds, 特质, tier, price, merge); the 0.2.0 补位 design subsection has the rules and sources.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
- **位置上限**:棋盘最多部署 **8** 名;整备区 **10** 格。整备区满时**不能购买或领取**任何干员、装备(包括能凑齐 3 名合成精锐的那一张,和官方一样),先出售、部署或配发腾出格子;新获得的单位(合成的精锐、奖励、退回的装备等)进入**临时整备区**(整备区前面的 5 格),整备区一空出格子,临时整备区的单位就**自动补进去**(从右往左)。临时整备区有单位时**不能准备就绪**,棋盘上会用红色虚线框标出这一排并说明规则:把它们放进整备区或战场、配发或使用,或者出售 / 销毁。休整期中进入临时整备区的单位,若到**本休整期结束**时仍留在那里就会被自动处理(干员返还卡池、装备销毁);作战、结算期间或准备就绪之后才进入的(例如战斗奖励、结算时合成的精锐),会保留到**下一个休整期**结束,你一定有机会处理它们。
|
||||
- **晋升精锐**:3 名同名干员(无论是否部署)自动合成**精锐**(等级、技能、模组大幅提升;风丸只需 2 名),精锐不会再合成。如果被合成的干员里有**已经部署在战场上**的,精锐会直接出现在**那名干员的位置**(保持原来的朝向,不多占部署位;有两三名都在场上时,取最先部署的那名——最左一列优先,同一列从上到下);否则精锐进入整备区(满了进入临时整备区)。原先配发的装备都退回整备区,需要重新配发;被合成干员的召唤物随之移除,场上的精锐会把自己的召唤物送进整备区。点选一张能合成的商店卡片时,精锐将出现的那一格会亮起金色边框,同时打开的详情卡里也会写明去向(「可晋升:精锐干员将出现在作战区原位置 / 将进入整备区」;详情卡会自动停靠在不遮挡这一格的一侧)。在回合开始或结算时因策略、奖励等完成的合成,精锐出现时同样会播放金色的晋升特效。
|
||||
- **晋级奖励**:每次晋升后获得一次特殊招募:从 3 名 **高一阶**(最高 VI)、**互不相同**的随机干员中**免费**挑 1 名(该阶剩余干员不足 3 名时由低一阶补足)。它不能刷新、不能冻结、本回合结束失效。
|
||||
- **干员持有(补位)**:大厅、房间和「确认本局信息」里的「干员调配」按钮打开的界面有两个标签页:「干员调配」选择每名干员携带的技能和精锐的模组;「**干员持有**」可以把你没有的干员**下掉**(点一下卡片切换「持有 / 未持有」)。和官方一样只有 55 张「普通」棋子能下掉(界面按阶级列出本赛季商店里的 53 名,每张写着由谁替补、用几技能);74 张「预设」棋子(多是 1–2 阶的常见干员)无论是否持有都由本人上场,不在其中。默认全部持有。下掉的干员照常出现在商店和整备区,**名字、盟约、特质、阶级、价格、合成都按原干员**,卡片和整备区里的单位下方标着「替补:Sharp」这样的标记;部署到战场上后换成**替补干员**的模型,作战时的属性、技能、天赋、模组、攻击范围、近战位 / 远程位都是替补干员的(详情卡显示替补的技能和天赋,例如银灰 → Sharp 的 2 技能「亮剑」)。替补的技能和模组由棋子固定,「干员调配」对它不生效;替补干员没有召唤物(下掉缪尔赛思就没有流形)。这是**局外设置**:下一局开始时生效,对局中修改只保存到下一局;同盟模拟中只影响你自己的棋子,队友的棋子按队友的设置。没有官方的好友借用:想用本人上场,改回「持有」即可。「全部持有」一键恢复,「导出 / 导入」可以把设置带到别的浏览器;大厅的「干员调配」按钮上会显示「替补 N」提醒你有几名干员被下掉。
|
||||
- 每回合商店会自动刷新未冻结的格子。
|
||||
- **「本回合首次主动刷新」按每名干员各自计算**(拉普兰德):她在本回合遇到的第一次主动刷新生效,在整备区里也算——先刷新、再买下拉普兰德,下一次刷新她仍会叠层;同一回合内用已经生效过的拉普兰德合成的精锐,本回合不再生效。
|
||||
|
||||
|
||||
+1
-1
@@ -1203,7 +1203,7 @@ Unknown subprofessions fall back to the profession default (test `professions.te
|
||||
`['fx', kind, x, y, extra]` (`hitCap` `{ id, n }`: a leader's hit cancelled by 限伤 — the renderer draws nothing;
|
||||
`extra.form` = the unit's model form from then on — an enemy's `content/enemies/helpers.js setForm`, a 傀儡师's 替身 — `shared/protocol.js fxForm`),
|
||||
`['layer', playerId, bondId, n]` (n = the layers actually added, capped at 999), `['bounty', playerId, coins]`.
|
||||
- `UnitInfo = { id, kind, side, ownerId, defId, name, tier, golden, spine, avatar, x, y, facing, dir, maxHp, motion?, boss?, uid?, form?, skillIndex?, moduleId?, items?, standInFor? }` (`standInFor`: a 补位 stand-in's replaced operator charId — `spine` / `avatar` / `name` are the stand-in's, DATA.md §18; `skillIndex`: an ally's equipped skill, DESIGN §16; `form`: the unit's current model form — `content/enemies/helpers.js setForm`: 掠海漂移体 `'crawl'`, 暴鸰 `'bombed'` after its drop, 转译基底·α's forms …; a 傀儡师 fighting as its 替身 `'doll'` — so a view built mid-battle from `fieldMeta()` starts on that clip set; `items`: an ally operator's equipped item ids — a 变形同构体 wearer is a member of the bond it grants on the client too, the bond popup and the detail card's chips)
|
||||
- `UnitInfo = { id, kind, side, ownerId, defId, name, tier, golden, spine, avatar, x, y, facing, dir, maxHp, motion?, boss?, uid?, form?, skillIndex?, moduleId?, items?, standInFor? }` (`standInFor`: a 补位 stand-in's replaced operator charId — `spine` / `avatar` / `name` are the stand-in's, DATA.md §18; the prep scouting units of `Match.prepFieldMeta` carry it too: a board piece with the stand-in's art, a held one with the chess's own; `skillIndex`: an ally's equipped skill, DESIGN §16; `form`: the unit's current model form — `content/enemies/helpers.js setForm`: 掠海漂移体 `'crawl'`, 暴鸰 `'bombed'` after its drop, 转译基底·α's forms …; a 傀儡师 fighting as its 替身 `'doll'` — so a view built mid-battle from `fieldMeta()` starts on that clip set; `items`: an ally operator's equipped item ids — a 变形同构体 wearer is a member of the bond it grants on the client too, the bond popup and the detail card's chips)
|
||||
(`dir` = the unit direction, allies meaningful, enemies 'RIGHT'; `facing` = its horizontal sign for sprite flipping)
|
||||
(`spine`/`avatar` are asset ids from data).
|
||||
- flags: UF bits (blocked 1, stunned 2, frozen 4, stealth 8 — 隐匿 (an enemy's only while not blocked / revealed and not within 3 s of a block's end) or an ally's 迷彩 — skill 16, shield 32, invuln 64, cold 128, sleep 256, flying 512);
|
||||
|
||||
@@ -90,6 +90,8 @@
|
||||
.dtag-elite, .dtag-token, .dtag-kind { font-size: .13rem; font-weight: 700; padding: .01rem .08rem; letter-spacing: .08em; }
|
||||
.dtag-elite { background: var(--gold); color: #1d1400; }
|
||||
.dtag-token { background: #a9c3d8; color: #0b1520; }
|
||||
/* 0.2.0 补位: the card of a chess fielded as its stand-in (ui/detailPanel.js ChessDetail) */
|
||||
.dtag-standin { font-size: .13rem; font-weight: 700; padding: .01rem .08rem; letter-spacing: .04em; background: var(--ice); color: var(--ice-900); }
|
||||
.dtag-kind { border: 1px solid var(--line-2); color: var(--text-lo); font-weight: 500; }
|
||||
.drank { font-size: .13rem; font-weight: 700; padding: .01rem .08rem; }
|
||||
.drank--normal { background: var(--bg-4); color: var(--text-md); }
|
||||
@@ -551,6 +553,12 @@ button.bpop__harmony:hover { border-style: solid; color: #fff; }
|
||||
.tempnote__rule { font-size: .12rem; line-height: 1.45; color: rgba(255, 220, 214, .88); }
|
||||
@media (prefers-reduced-motion: reduce) { .tempnote__frame polygon, .tempnote__label { animation: none; } }
|
||||
.sp-reduced-motion .tempnote__frame polygon, .sp-reduced-motion .tempnote__label { animation: none; }
|
||||
/* 0.2.0 补位: 「替补:X」 under the player's own prep pieces of a chess they do not own (screens/game/standInTags.js) */
|
||||
.sitags { position: fixed; inset: 0; z-index: 2; pointer-events: none; }
|
||||
.sitag { position: absolute; transform: translate(-50%, .02rem); pointer-events: none; white-space: nowrap; font-size: max(.12rem, 9px); font-weight: 700;
|
||||
line-height: 1.35; padding: 0 .05rem; letter-spacing: .03em; background: var(--ice); color: var(--ice-900); box-shadow: 0 1px 3px rgba(0, 0, 0, .7); opacity: .92; }
|
||||
.sitag--board { background: #c2e3ff; }
|
||||
.gm.is-dragging .sitags { display: none; }
|
||||
|
||||
/* ---- underframe: the selection diamond of a tapped own piece (ui/underframe.js): 撤退 / 出售 +N / 销毁 ----
|
||||
It sits above every HUD panel (detail card z 9, bond popup, drawer, shop bar): only its buttons take pointer events,
|
||||
|
||||
@@ -130,6 +130,9 @@
|
||||
.scard__class { display: flex; align-items: center; gap: .05rem; font-size: .13rem; color: var(--text-lo); }
|
||||
.scard__prof { width: .18rem; height: .18rem; object-fit: contain; opacity: .85; }
|
||||
.scard__mergetag { position: absolute; z-index: 3; right: 0; top: .38rem; font-size: .12rem; font-weight: 700; padding: .01rem .06rem; background: var(--gold); color: #1d1400; letter-spacing: .08em; }
|
||||
/* 0.2.0 补位: a chess the player does not own — its card stays, the stand-in fights (ui/shopBar.js ChessCard) */
|
||||
.scard__standin { position: absolute; z-index: 3; left: 0; top: .38rem; max-width: calc(100% - .1rem); font-size: .12rem; font-weight: 700; padding: .01rem .06rem;
|
||||
background: var(--ice); color: var(--ice-900); letter-spacing: .04em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; box-shadow: 0 1px 3px rgba(0, 0, 0, .6); }
|
||||
.scard:not(.is-disabled):not(.scard--sold):hover { transform: translateY(-.08rem); border-color: var(--mint-400); box-shadow: 0 0 0 1px var(--mint-400), 0 .1rem .24rem rgba(23, 249, 183, .22); }
|
||||
.scard:not(.is-disabled):not(.scard--sold):active { transform: translateY(-.04rem); }
|
||||
.scard.is-disabled { cursor: not-allowed; }
|
||||
|
||||
@@ -162,6 +162,9 @@
|
||||
position: absolute; right: 0; top: 0; z-index: 1; width: .22rem; height: .22rem;
|
||||
background: linear-gradient(225deg, var(--mint-400) 50%, transparent 50%);
|
||||
}
|
||||
/* 0.2.0 补位: a chess marked 未持有 on the 干员持有 tab (its stand-in fights with a fixed skill) */
|
||||
.lo-card__sub { position: absolute; right: .03rem; top: .26rem; z-index: 1; padding: 0 .04rem; font-size: max(.11rem, 8px); font-weight: 700; line-height: 1.4; color: var(--ice-900); background: var(--ice); }
|
||||
.lo-locknote--standin { color: var(--ice); }
|
||||
.lo-card__name {
|
||||
position: absolute; left: .05rem; right: .05rem; top: calc(100% - max(.64rem, 34px)); z-index: 1;
|
||||
font-size: max(.15rem, 10px); font-weight: 700; line-height: 1.2; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
@@ -326,6 +329,62 @@
|
||||
.lo-locknote { display: flex; align-items: center; gap: .06rem; margin: .16rem 0 0; font-size: max(.14rem, 10px); color: var(--amber); }
|
||||
.lo-locknote .icon { width: .16rem; height: .16rem; }
|
||||
|
||||
/* ---- tabs: 干员调配 / 干员持有 (0.2.0 补位) ---------------------------------------------------------------------------- */
|
||||
.lo-tabs { display: flex; align-items: stretch; gap: .04rem; }
|
||||
.lo-tab {
|
||||
display: inline-flex; align-items: center; gap: .08rem; padding: .02rem .16rem; font: inherit; font-size: max(.24rem, 13px); font-weight: 900;
|
||||
letter-spacing: .12em; line-height: 1.2; color: var(--text-lo); background: transparent; border: 0; border-bottom: 2px solid transparent; cursor: pointer;
|
||||
touch-action: manipulation; white-space: nowrap;
|
||||
}
|
||||
.lo-tab .lo-top__icon { width: max(.24rem, 14px); height: max(.24rem, 14px); }
|
||||
.lo-tab:hover { color: var(--text-hi); }
|
||||
.lo-tab.is-on { color: var(--text-hi); border-bottom-color: var(--mint-500); }
|
||||
.lo-tab__n { display: inline-grid; place-items: center; min-width: 1.5em; height: 1.5em; padding: 0 .3em; font-size: .62em; font-weight: 700; letter-spacing: 0; color: var(--ice-900); background: var(--ice); border-radius: 1em; }
|
||||
|
||||
/* ---- 干员持有 (screens/ownership.js): the NORMAL chess by tier, each operator → its stand-in, a 持有 switch -------------- */
|
||||
.own { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; gap: .12rem; padding: .16rem .3rem .2rem; }
|
||||
.own__bar { flex: none; display: flex; align-items: center; gap: .2rem; }
|
||||
.own__lead { flex: 1; margin: 0; font-size: max(.15rem, 10px); line-height: 1.5; color: var(--text-md); }
|
||||
.own__lead b { color: var(--ice); font-weight: 700; }
|
||||
.own__list { flex: 1 1 auto; min-height: 0; overflow: auto; -webkit-overflow-scrolling: touch; overscroll-behavior: contain; display: flex; flex-direction: column; gap: .18rem; padding: 0 .06rem .2rem 0;
|
||||
scrollbar-width: thin; scrollbar-color: var(--line-3) transparent; }
|
||||
.own-tier__head { display: flex; align-items: center; gap: .08rem; margin: 0 0 .08rem; font-size: max(.17rem, 12px); font-weight: 700; color: var(--text-md); letter-spacing: .06em; }
|
||||
.own-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(max(4.4rem, 270px), 1fr)); gap: .08rem; }
|
||||
.own-card {
|
||||
display: grid; grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr) auto; align-items: center; gap: .08rem; min-width: 0;
|
||||
padding: .07rem .1rem .07rem .07rem; font: inherit; color: var(--text-hi); text-align: left; cursor: pointer; touch-action: manipulation;
|
||||
background: rgba(255, 255, 255, .03); border: 1px solid var(--line); border-left: 3px solid var(--tc, var(--line-3));
|
||||
transition: background var(--t-fast), border-color var(--t-fast);
|
||||
}
|
||||
.own-card--t1 { --tc: var(--tier-1); } .own-card--t2 { --tc: var(--tier-2); } .own-card--t3 { --tc: var(--tier-3); }
|
||||
.own-card--t4 { --tc: var(--tier-4); } .own-card--t5 { --tc: var(--tier-5); } .own-card--t6 { --tc: var(--tier-6); }
|
||||
.own-card:hover { background: rgba(255, 255, 255, .06); border-color: var(--line-3); }
|
||||
.own-card__op, .own-card__sub { display: flex; align-items: center; gap: .07rem; min-width: 0; }
|
||||
.own-card__ava { flex: none; display: grid; place-items: center; width: max(.46rem, 28px); height: max(.46rem, 28px); overflow: hidden; background: #0a0d0c; border: 1px solid var(--line-2); }
|
||||
.own-card__ava img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.own-card__ava b { font-size: max(.18rem, 12px); color: var(--text-lo); }
|
||||
.own-card__txt { display: flex; flex-direction: column; min-width: 0; }
|
||||
.own-card__name { font-size: max(.16rem, 11px); font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.own-card__txt small { font-size: max(.12rem, 9px); color: var(--text-lo); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.own-card__arrow .icon { width: max(.18rem, 12px); height: max(.18rem, 12px); color: var(--text-dim); }
|
||||
.own-card__sub { opacity: .55; }
|
||||
.own-card__state { display: inline-flex; flex-direction: column; align-items: center; gap: .03rem; min-width: max(.5rem, 30px); font-size: max(.12rem, 9px); font-weight: 700; color: var(--mint-400); white-space: nowrap; }
|
||||
.own-switch { position: relative; display: inline-block; width: max(.36rem, 24px); height: max(.2rem, 13px); border-radius: 1em; background: var(--mint-700); }
|
||||
.own-switch > i { position: absolute; top: 2px; right: 2px; width: calc(max(.2rem, 13px) - 4px); height: calc(max(.2rem, 13px) - 4px); border-radius: 50%; background: #fff; transition: right var(--t-fast), left var(--t-fast); }
|
||||
/* 未持有: the operator greys out, the stand-in (who is deployed) lights up */
|
||||
.own-card.is-off { background: rgba(159, 212, 255, .07); border-color: rgba(159, 212, 255, .35); }
|
||||
.own-card.is-off .own-card__op { opacity: .5; filter: grayscale(.8); }
|
||||
.own-card.is-off .own-card__sub { opacity: 1; }
|
||||
.own-card.is-off .own-card__sub .own-card__ava { border-color: var(--ice); }
|
||||
.own-card.is-off .own-card__state { color: var(--ice); }
|
||||
.own-card.is-off .own-switch { background: var(--bg-4); }
|
||||
.own-card.is-off .own-switch > i { right: auto; left: 2px; background: var(--ice); }
|
||||
@media (max-width: 760px) {
|
||||
.own { padding: .1rem .16rem .16rem; }
|
||||
.own__lead { display: none; }
|
||||
.lo-tab { padding: .02rem .08rem; letter-spacing: .04em; }
|
||||
}
|
||||
|
||||
/* ---- entry button (lobby / room / briefing) --------------------------------------------------------------------------- */
|
||||
.lo-entry { position: relative; gap: .08rem; }
|
||||
.lo-entry__icon { width: 1.25em; height: 1.25em; object-fit: contain; flex: none; }
|
||||
@@ -333,6 +392,8 @@
|
||||
display: inline-grid; place-items: center; min-width: 1.5em; height: 1.5em; padding: 0 .3em; margin-left: .02rem;
|
||||
font-size: .8em; font-weight: 700; color: var(--text-on-mint); background: var(--mint-500); border-radius: 1em;
|
||||
}
|
||||
/* 0.2.0 补位: operators marked 未持有 on the 干员持有 tab */
|
||||
.lo-entry__own { display: inline-flex; align-items: center; gap: .2em; height: 1.5em; padding: 0 .4em; font-size: .75em; font-weight: 700; color: var(--ice-900); background: var(--ice); border-radius: 1em; white-space: nowrap; }
|
||||
|
||||
/* Untimed phases (user playtest #11 / #4 item 3: the official 独立模拟 shows no countdown outside combat — the server
|
||||
sends deadline 0 for them, Match.soloUntimed): ui/components.js Countdown now renders nothing without a deadline;
|
||||
|
||||
@@ -33,6 +33,8 @@ export const DATA_FILES = Object.freeze({
|
||||
choices: 'choices.json',
|
||||
config: 'config.json',
|
||||
assets: 'assets.json',
|
||||
// 补位 stand-ins (DATA.md §18): the bodies the 干员持有 screen, the cards and the detail card compose (shared/standIn.js)
|
||||
backups: 'backups.json',
|
||||
// Optional art extracted from a local game client (DESIGN §13): { groups: { '<subdir>': { name: { path, w, h } } } }.
|
||||
// The emotes and the 玩法说明 pages are in data/assets.json too (downloaded from the mirror): artUrls().
|
||||
local: 'local-assets.json',
|
||||
|
||||
+4
-2
@@ -20,7 +20,8 @@
|
||||
// Multi-device support (ui/device.js + css/devices.css): feature classes on <html>, no page zoom, safe areas, rotation
|
||||
// re-layout; ui/compat.js polyfills are imported before anything else.
|
||||
// 干员调配 (DESIGN §16): an overlay over any route (<LoadoutHost/>, opened from lobby / room / briefing); its loadout is
|
||||
// kept in sync with the server by installLoadoutSync (room.loadout after every welcome and edit).
|
||||
// kept in sync with the server by installLoadoutSync (room.loadout after every welcome and edit), its 干员持有 tab's
|
||||
// not-owned list (0.2.0 补位) by installOwnershipSync (room.ownership, likewise).
|
||||
// Game data: every text of the game is static data (/data/*.json) downloaded once per page; the in-match files are
|
||||
// warmed in the background as soon as the player is in a room (warmGameData), before the match needs them.
|
||||
|
||||
@@ -44,7 +45,7 @@ import { settingsStore } from './ui/settings.js';
|
||||
import { GuideHost } from './ui/guide.js';
|
||||
import { installDeviceSupport } from './ui/device.js';
|
||||
import { LoadoutHost } from './screens/loadout.js';
|
||||
import { installLoadoutSync } from './ui/loadoutSync.js';
|
||||
import { installLoadoutSync, installOwnershipSync } from './ui/loadoutSync.js';
|
||||
import { startBuildGuard } from './ui/buildGuard.js';
|
||||
|
||||
const RESTORE_GRACE_MS = 1500;
|
||||
@@ -329,6 +330,7 @@ async function boot() {
|
||||
|
||||
wireNet();
|
||||
installLoadoutSync({ net });
|
||||
installOwnershipSync({ net });
|
||||
net.attachBrowserHooks();
|
||||
// Audio: unlock on first gesture, BGM follows the route / match phase (js/audio.js).
|
||||
installAudio({ getManifest: () => data.get('assets'), subscribe: store.subscribe, getState: store.get, selectRoute, settings: settingsStore.get() });
|
||||
|
||||
+15
-5
@@ -275,6 +275,7 @@ export async function createFieldView(host, options = {}) {
|
||||
let penHidden = true; // the pen's figures are shown only by the pen camera (setPenHidden)
|
||||
let penList = null;
|
||||
let ownPen = null; // the own m.private.nextEnemies (fallback composition of a scouted teammate's pen)
|
||||
let standInList = []; // the own m.private.standIns (0.2.0 补位): board pieces of these chess draw the stand-in
|
||||
let camBeforePen = null; // { kind, opts } the camera the pen returns to
|
||||
let leader = null; // { key, view, stand, area } the round leader standing on the boss field in the prep (setLeader)
|
||||
let leaderHidden = true; // shown only by the boss-field prep camera (leaderShown)
|
||||
@@ -285,7 +286,9 @@ export async function createFieldView(host, options = {}) {
|
||||
cam: () => cam, heightAt,
|
||||
animRate: () => (mode === 'battle' ? interp.rate : 1),
|
||||
timeScale: () => (mode === 'battle' ? interp.rate : 1),
|
||||
lookupDef: (info) => (info.side === 'enemy' ? data.enemy(info.defId) : data.chess(info.defId) || data.token(info.defId)),
|
||||
// (a chess fighting as its 补位 stand-in — `standInFor` — reads the stand-in's record: its attack interval)
|
||||
lookupDef: (info) => (info.side === 'enemy' ? data.enemy(info.defId)
|
||||
: (info.standInFor && data.standIn(info.defId)) || data.chess(info.defId) || data.token(info.defId)),
|
||||
crowded: () => views.size > 90,
|
||||
renderer: app.renderer,
|
||||
frameNo: () => frameNo,
|
||||
@@ -301,7 +304,7 @@ export async function createFieldView(host, options = {}) {
|
||||
timeScale: () => ctx.timeScale(),
|
||||
loadLevel: () => loadLevel,
|
||||
fieldRect: () => (mode === 'battle' && battleMeta ? battleMeta.rect : null),
|
||||
subProfOf: (defId) => data.chess(defId)?.subProfessionId || null,
|
||||
subProfOf: (defId, info = null) => ((info && info.standInFor && data.standIn(defId)) || data.chess(defId))?.subProfessionId || null,
|
||||
view: (id) => views.get(id) || null,
|
||||
screenSize: size,
|
||||
fieldTop: () => {
|
||||
@@ -626,11 +629,16 @@ export async function createFieldView(host, options = {}) {
|
||||
const rec = data.token(piece.id);
|
||||
return { kind: 'token', side: 'ally', defId: piece.id, spine: rec?.assets?.spine || piece.id, avatar: rec?.assets?.avatar || piece.id, tier: piece.tier || 1, golden: false, dir };
|
||||
}
|
||||
const rec = data.chess(piece.id);
|
||||
const chess = data.chess(piece.id);
|
||||
// 0.2.0 补位: a board piece of a chess the player does not own (m.private.standIns) is deployed as its stand-in —
|
||||
// the stand-in's model; on the bench it keeps the chess's own (the hand shows the original operator)
|
||||
const si = area === 'board' && chess && standInList.includes(chess.baseId || chess.chessId) ? data.standIn(piece.id) : null;
|
||||
const rec = si || chess;
|
||||
return {
|
||||
kind: 'op', side: 'ally', defId: piece.id,
|
||||
spine: rec?.assets?.spine || rec?.charId || null, avatar: rec?.assets?.avatar || rec?.charId || null,
|
||||
tier: rec?.tier || piece.tier || 1, golden: !!(piece.golden || rec?.isGolden), dir,
|
||||
tier: chess?.tier || piece.tier || 1, golden: !!(piece.golden || chess?.isGolden), dir,
|
||||
...(si ? { standInFor: si.standInFor } : null),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -678,6 +686,7 @@ export async function createFieldView(host, options = {}) {
|
||||
});
|
||||
};
|
||||
const src = ps && typeof ps === 'object' ? ps : {};
|
||||
standInList = Array.isArray(src.standIns) ? src.standIns.filter((x) => typeof x === 'string') : [];
|
||||
ownPen = Array.isArray(src.nextEnemies) ? src.nextEnemies : null;
|
||||
setPenList(ownPen);
|
||||
addList(src.hand, 'hand');
|
||||
@@ -705,7 +714,8 @@ export async function createFieldView(host, options = {}) {
|
||||
const key = 'p:' + e.uid;
|
||||
e.key = key;
|
||||
const info = pieceInfo(e.piece, e.area);
|
||||
const sig = `${info.kind}|${info.defId}|${info.golden ? 1 : 0}`;
|
||||
// (the model is part of it: a 补位 chess changes body between the bench and the board — pieceInfo)
|
||||
const sig = `${info.kind}|${info.defId}|${info.golden ? 1 : 0}|${info.spine || ''}`;
|
||||
let v = views.get(key);
|
||||
if (v && v._sig !== sig) { dropView(key); v = null; }
|
||||
const w = slotWorld(e);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// public/js/render/app/host.js — asset lookup, data lookup and GL teardown for one field view.
|
||||
|
||||
import { createAssets, assets as defaultAssets } from '../../assets.js';
|
||||
import { standInRecord } from '../../../../shared/standIn.js';
|
||||
|
||||
function withTimeout(p, ms) {
|
||||
return Promise.race([p, new Promise((resolve) => setTimeout(resolve, ms))]);
|
||||
@@ -24,9 +25,25 @@ function makeData(src) {
|
||||
} catch { /* ignore */ }
|
||||
return null;
|
||||
};
|
||||
// 0.2.0 补位: the composed stand-in record of a chess (shared/standIn.js over data/backups.json), cached per chess
|
||||
const standIns = new Map();
|
||||
const backups = () => {
|
||||
try { return (src && typeof src.get === 'function' ? src.get('backups') : src && src.backups) || null; } catch { return null; }
|
||||
};
|
||||
const standIn = (id) => {
|
||||
const c = look('chess', id);
|
||||
const b = backups();
|
||||
if (!c || !b) return null;
|
||||
if (standIns.has(c)) return standIns.get(c);
|
||||
let rec;
|
||||
try { rec = standInRecord(c, b); } catch { rec = null; }
|
||||
standIns.set(c, rec);
|
||||
return rec;
|
||||
};
|
||||
return {
|
||||
chess: (id) => look('chess', id), token: (id) => look('tokens', id), item: (id) => look('items', id),
|
||||
enemy: (id) => look('enemies', id), stage: (id) => look('stages', id), bond: (id) => look('bonds', id),
|
||||
standIn,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -42,5 +42,8 @@ export function renderInfo(u) {
|
||||
// unit (resolveDetail `unitItems` → the read-only 装备 section and the 变形同构体 pairing chips); the owner's own
|
||||
// unit takes its items from the piece instead, so only other players' boards ever read this field
|
||||
items: Array.isArray(u.items) ? u.items.filter((x) => typeof x === 'string') : undefined,
|
||||
// 0.2.0 补位: the replaced operator's charId of a chess fighting as its stand-in (UnitInfo.standInFor; spine / avatar /
|
||||
// name are already the stand-in's) — the detail card and the view's data lookups (attack interval, splash FX) follow it
|
||||
standInFor: typeof u.standInFor === 'string' && u.standInFor ? u.standInFor : undefined,
|
||||
};
|
||||
}
|
||||
@@ -59,7 +59,7 @@ export class FxNumbers {
|
||||
this.particle('glow', px, py, { tint, life: 0.18, s0: (s / 128) * (big ? 1.0 : 0.6), s1: (s / 128) * (big ? 1.5 : 0.9), a0: 0.9, a1: 0 });
|
||||
if (melee) { this._slashAt = null; this._slash(view, srcView, px, py, s, style, big); }
|
||||
this.burst(px, py, s, big ? 8 : melee ? 6 : 4, tint, { speed: melee ? 2.8 : 2.4, size: big ? 0.6 : 0.46 });
|
||||
if (srcView && this.ctx.subProfOf && SPLASH_SUBS.has(this.ctx.subProfOf(srcView.info?.defId))) {
|
||||
if (srcView && this.ctx.subProfOf && SPLASH_SUBS.has(this.ctx.subProfOf(srcView.info?.defId, srcView.info))) {
|
||||
if (!this._lastRing || this.time - this._lastRing > 0.08) {
|
||||
this._lastRing = this.time;
|
||||
this.ring(view.x, view.y, view.z || 0, 0.1, 1.1, tint, 0.3);
|
||||
|
||||
@@ -86,6 +86,7 @@ import { openGuide } from '../ui/guide.js';
|
||||
import { actions } from '../ui/gameActions.js';
|
||||
import { FacingWheel, holdPiece, setPieceDir, syncPieceDirs, showRange, useTileScreen } from '../ui/facingWheel.js';
|
||||
import { Underframe, underframeRect, TempRowNotice } from '../ui/underframe.js';
|
||||
import { StandInTags } from './game/standInTags.js';
|
||||
import { needsFacing, facingIntent, previewGrid, pieceDir, underframeActions, retreatSlot, itemDestroyable } from '../ui/facing.js';
|
||||
import { EquipReplaceDialog, replaceRequest, replaceIntent } from '../ui/equipReplace.js';
|
||||
import { pauseAvailable, isPaused, frozenNow } from '../ui/matchStatus.js';
|
||||
@@ -94,7 +95,7 @@ import {
|
||||
phaseMode, phaseBanner, isCombatPhase, showDeadPill, isBossPhase, placementContext, canPlace, boardTargets, dropIntent,
|
||||
snapHud, activeBubbles, shortcutFor, shortcutBlocked, closesOnFieldPress, phaseTotalSeconds, homeFieldId, ownFieldId, normalizeSp, sortedPlayers,
|
||||
countdownState, shopBlockReason, stageOverrides, effectiveStage, watchTarget, dropFailureReason,
|
||||
previewEnemyKey, prepCamera, prepCameraFor, foldCamera, deployFieldOf, panelSide, panelSlots, bondPopupPlace, chessLoadout, unitLoadout,
|
||||
previewEnemyKey, prepCamera, prepCameraFor, foldCamera, deployFieldOf, panelSide, panelSlots, bondPopupPlace, unitLoadout, deployedRecord,
|
||||
mergeTarget, modeOffBonds, readyFundsPrompt, ownerBandId,
|
||||
} from '../ui/gameLogic.js';
|
||||
import { toast } from '../ui/toasts.js';
|
||||
@@ -237,7 +238,7 @@ function MatchScreen() {
|
||||
const deployField = deployFieldOf(pub, myId);
|
||||
const placeCtx = useMemo(() => placementContext({
|
||||
priv, stage: gd.stage(pub?.stageId), editable, field: deployField,
|
||||
getChess: gd.chess, getToken: gd.token, getItem: gd.item, getEffect: gd.effect,
|
||||
getChess: gd.chess, getToken: gd.token, getItem: gd.item, getEffect: gd.effect, backups: gd.backups,
|
||||
}), [priv, pub?.stageId, editable, gd.ready, deployField]);
|
||||
live.current = { pub, priv, field, editable, placeCtx, watching, watchWho, home, myId, detail, drawer, bondOpen, emoteOpen, settingsOpen, exitOpen, drag, facing, sel, selBusy, pen, collapsedNow: collapsed, localDone: false, canPause: false, paused };
|
||||
|
||||
@@ -718,7 +719,7 @@ function MatchScreen() {
|
||||
// the drop target is the tile under the pointer (render/drag.js; an item dropped on a unit's tile equips that unit —
|
||||
// user playtest #4 item 1); `tile` = the last target tile (tileHover), `released` = the pointer went up (not a cancel)
|
||||
const ptr = { released: false, tile: null };
|
||||
const lookups = { getChess: gd.chess, getToken: gd.token, getItem: gd.item, chessRecord: (rec) => chessLoadout(rec, live.current.priv?.loadout ?? null, gd.chess)?.record };
|
||||
const lookups = { getChess: gd.chess, getToken: gd.token, getItem: gd.item, chessRecord: (rec) => deployedRecord(rec, live.current.priv, gd.chess, gd.backups) };
|
||||
const runIntent = async (intent) => {
|
||||
const L = live.current;
|
||||
if (intent.confirmReplace) {
|
||||
@@ -886,9 +887,10 @@ function MatchScreen() {
|
||||
}, []);
|
||||
|
||||
// ---- direction step (research 09 §1.2) and the selected piece's underframe ------------------------------------
|
||||
// DESIGN §16: previews show the range the unit fights with under the player's loadout (an elite's module grid)
|
||||
// DESIGN §16: previews show the range the unit fights with under the player's loadout (an elite's module grid); a
|
||||
// chess the player does not own the range of its stand-in (0.2.0 补位, gameLogic deployedRecord)
|
||||
const lookups = useMemo(() => ({ getChess: gd.chess, getToken: gd.token, getItem: gd.item,
|
||||
chessRecord: (rec) => chessLoadout(rec, live.current.priv?.loadout ?? null, gd.chess)?.record }), [gd.ready]);
|
||||
chessRecord: (rec) => deployedRecord(rec, live.current.priv, gd.chess, gd.backups) }), [gd.ready]);
|
||||
const heldRef = useRef(new Map()); // uid → { row, col, t } committed placements awaiting m.private
|
||||
const releaseHold = useCallback((uid) => {
|
||||
heldRef.current.delete(uid);
|
||||
@@ -984,7 +986,7 @@ function MatchScreen() {
|
||||
const u = (Array.isArray(field?.units) ? field.units : []).find((x) => x && x.id === detail.unitId);
|
||||
return u ? { ...detail, unit: u } : detail;
|
||||
}, [detail, field]);
|
||||
const resolved = useMemo(() => resolveDetail(detailTarget, placeCtx.pieces), [detailTarget, placeCtx]);
|
||||
const resolved = useMemo(() => resolveDetail(detailTarget, placeCtx.pieces, { priv, backups: gd.backups }), [detailTarget, placeCtx, gd.ready]);
|
||||
useEffect(() => { if (detail && !resolved && detail.kind === 'piece') setDetail(null); }, [resolved]);
|
||||
const snapHp = (() => {
|
||||
const id = resolved?.unitId;
|
||||
@@ -1198,6 +1200,7 @@ function MatchScreen() {
|
||||
<div class="gm__vignette" aria-hidden="true"></div>
|
||||
${tempNotice ? html`<${TempRowNotice} view=${view} count=${temp.count} items=${temp.items} label=${!drag && !facing}
|
||||
ready=${phase === PHASE.PREP && !!priv?.ready} />` : null}
|
||||
${showPrep && view && viewKind !== 'loading' && priv && !pen ? html`<${StandInTags} view=${view} priv=${priv} getChess=${gd.chess} backups=${gd.backups} />` : null}
|
||||
|
||||
<div class="gm__hud" ref=${hudElRef}>
|
||||
<${TopBar} pub=${pub} priv=${priv} conn=${conn} hud=${hud} total=${total} drawer=${drawer}
|
||||
@@ -1254,7 +1257,7 @@ function MatchScreen() {
|
||||
|
||||
${drawer ? html`<${EnemyDrawer} tab=${drawer} onTab=${setDrawer} pub=${pub} priv=${priv} onClose=${() => setDrawer(null)}
|
||||
bandId=${scoutBandId} bandOwner=${scoutBandOwner}
|
||||
onEnemy=${(k, n) => setDetail({ kind: 'enemy', id: k, count: n })} onChess=${(id) => setDetail({ kind: 'chess', id })} />` : null}
|
||||
onEnemy=${(k, n) => setDetail({ kind: 'enemy', id: k, count: n })} onChess=${(id) => setDetail({ kind: 'chess', id, foreign: true })} />` : null}
|
||||
|
||||
${bondPop ? html`<${BondPopup} bondId=${bondPop.bondId} entry=${bondPop.entry} priv=${bondPop.priv} banned=${pub?.bannedChess || []} owner=${bondPop.name}
|
||||
off=${offBonds.has(bondPop.bondId)}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
// public/js/screens/game/standInTags.js — 0.2.0 补位: the 「替补:X」 tag under the player's own prep pieces (hand,
|
||||
// 临时整备区, board) of a chess they do not own (m.private.standIns). The hand keeps the chess's own model (the card shows
|
||||
// the original operator); on the board the model is the stand-in's (render/app.js pieceInfo) — the tag says which
|
||||
// chess it is either way. Anchored to each piece's drawn body (view.pieceScreenRect, followed every frame like the
|
||||
// direction wheel follows its tile); never takes the pointer, so the pieces stay draggable.
|
||||
|
||||
import { useEffect, useRef, useState } from '../../../vendor/hooks.module.js';
|
||||
import { html } from '../../ui/components.js';
|
||||
import { fieldsStandIn, standInOf, standInLabel } from '../../ui/gameLogic.js';
|
||||
|
||||
const rawOf = (view) => (view && view.raw) || view || null;
|
||||
|
||||
/**
|
||||
* The player's own prep pieces that fight as stand-ins: [{ uid, label, name, area }] (hand, temp, board).
|
||||
* @param {any} priv m.private @param {(id: string) => any} getChess @param {any} backups data/backups.json
|
||||
*/
|
||||
export function standInPieces(priv, getChess, backups) {
|
||||
const out = [];
|
||||
if (!priv || !Array.isArray(priv.standIns) || !priv.standIns.length) return out;
|
||||
const add = (p, area) => {
|
||||
if (!p || p.kind !== 'chess' || !Number.isInteger(p.uid)) return;
|
||||
const c = getChess(p.id);
|
||||
if (!fieldsStandIn(priv, c)) return;
|
||||
const si = standInOf(c, backups);
|
||||
if (si) out.push({ uid: p.uid, label: standInLabel(si), name: si.name, area });
|
||||
};
|
||||
for (const p of Array.isArray(priv.hand) ? priv.hand : []) add(p, 'hand');
|
||||
for (const p of Array.isArray(priv.temp) ? priv.temp : []) add(p, 'temp');
|
||||
for (const p of Array.isArray(priv.board) ? priv.board : []) add(p, 'board');
|
||||
return out;
|
||||
}
|
||||
|
||||
/** @param {{ view: any, priv: any, getChess: (id: string) => any, backups: any }} props */
|
||||
export function StandInTags({ view, priv, getChess, backups }) {
|
||||
const items = standInPieces(priv, getChess, backups);
|
||||
const [pos, setPos] = useState({});
|
||||
const last = useRef('');
|
||||
const key = items.map((it) => it.uid).join(',');
|
||||
useEffect(() => {
|
||||
const raw = rawOf(view);
|
||||
if (!raw || typeof raw.pieceScreenRect !== 'function' || !key) { last.current = ''; setPos({}); return undefined; }
|
||||
const uids = key.split(',').map(Number);
|
||||
let raf = 0;
|
||||
let alive = true;
|
||||
const tick = () => {
|
||||
if (!alive) return;
|
||||
const next = {};
|
||||
let sig = '';
|
||||
for (const uid of uids) {
|
||||
let r;
|
||||
try { r = raw.pieceScreenRect(uid); } catch { r = null; }
|
||||
if (!r || !(r.width > 0)) continue;
|
||||
// under the feet: the bottom centre of the drawn body
|
||||
next[uid] = { x: (r.left + r.right) / 2, y: r.bottom };
|
||||
sig += `${uid}:${next[uid].x.toFixed(1)},${next[uid].y.toFixed(1)};`;
|
||||
}
|
||||
if (sig !== last.current) { last.current = sig; setPos(next); }
|
||||
raf = requestAnimationFrame(tick);
|
||||
};
|
||||
tick();
|
||||
return () => { alive = false; cancelAnimationFrame(raf); };
|
||||
}, [view, key]);
|
||||
if (!items.length) return null;
|
||||
return html`<div class="sitags" aria-hidden="true" data-testid="standin-tags">
|
||||
${items.map((it) => {
|
||||
const p = pos[it.uid];
|
||||
return p ? html`<span key=${it.uid} class=${`sitag sitag--${it.area}`} data-uid=${it.uid} title=${`未持有:由 ${it.name} 上场`}
|
||||
style=${`left:${p.x.toFixed(1)}px;top:${p.y.toFixed(1)}px`}>${it.label}</span>` : null;
|
||||
})}
|
||||
</div>`;
|
||||
}
|
||||
@@ -12,7 +12,9 @@
|
||||
// card's own block and pure functions, GitHub issue #64) and the elite's modules (不装备 / X / Y … with the stat bonus,
|
||||
// the trait upgrade and the talent changes), 恢复默认; 全部恢复默认 in the top bar.
|
||||
// The loadout lives in ui/loadoutSync.js (localStorage + room.loadout); the model is ui/loadoutModel.js.
|
||||
// Keyboard: Esc closes, ←/→ move through the (filtered) roster when focus is not in the search field.
|
||||
// The second tab, 干员持有 (0.2.0 补位, screens/ownership.js): which NORMAL chess the player owns — a chess marked 未持有 is
|
||||
// deployed as its official stand-in (room.ownership; ui/ownershipModel.js) — with its own 导出 / 导入 / 全部持有.
|
||||
// Keyboard: Esc closes, ←/→ move through the (filtered) roster when focus is not in the search field (干员调配 tab).
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from '../../vendor/hooks.module.js';
|
||||
import { html, Icon, MicroLabel, Button, TierChip, TextField, Countdown, Spinner, confirmDialog, hasDeadline, Modal, Fragment } from '../ui/components.js';
|
||||
@@ -27,7 +29,9 @@ import {
|
||||
MODULE_NONE, PROF_ORDER, PROF_NAME, rosterOf, filterRoster, recordsOf, chessOptions, effectiveChoice, setChoice, resetChoice,
|
||||
changedCount, skillLabel, moduleBadge, attrRows, skillTags, serializeExport, parseImport, LOADOUT_IMPORT_MAX_BYTES,
|
||||
} from '../ui/loadoutModel.js';
|
||||
import { loadoutStore, openLoadout, closeLoadout, setEntries, applyLoadoutEntries } from '../ui/loadoutSync.js';
|
||||
import { loadoutStore, openLoadout, closeLoadout, setEntries, applyLoadoutEntries, setNotOwned, applyOwnershipImport } from '../ui/loadoutSync.js';
|
||||
import { setOwned, notOwnedCount, serializeOwnership, parseOwnershipImport, OWNERSHIP_IMPORT_MAX_BYTES } from '../ui/ownershipModel.js';
|
||||
import { OwnershipPanel, useOwnershipRoster } from './ownership.js';
|
||||
import { copyText } from '../ui/clipboard.js';
|
||||
import { toast } from '../ui/toasts.js';
|
||||
|
||||
@@ -67,10 +71,10 @@ function readFileText(file) {
|
||||
});
|
||||
}
|
||||
|
||||
/** `stronghold-loadout-20261003-1245.json` */
|
||||
function exportFilename(now = new Date()) {
|
||||
/** `stronghold-loadout-20261003-1245.json` (`stronghold-ownership-…` for the 干员持有 list) */
|
||||
function exportFilename(now = new Date(), what = 'loadout') {
|
||||
const p = (n) => String(n).padStart(2, '0');
|
||||
return `stronghold-loadout-${now.getFullYear()}${p(now.getMonth() + 1)}${p(now.getDate())}-${p(now.getHours())}${p(now.getMinutes())}.json`;
|
||||
return `stronghold-${what}-${now.getFullYear()}${p(now.getMonth() + 1)}${p(now.getDate())}-${p(now.getHours())}${p(now.getMinutes())}.json`;
|
||||
}
|
||||
|
||||
/** Square profession glyph (manifest prof.large: black glyph on white, drawn as a white glyph by loadout.css). */
|
||||
@@ -133,7 +137,7 @@ export function ModuleGlyph({ m, rec, id, size = 'md' }) {
|
||||
|
||||
// ---- roster card ---------------------------------------------------------------------------------------------------
|
||||
|
||||
function RosterCard({ m, chess, golden, entries, selected, onPick }) {
|
||||
function RosterCard({ m, chess, golden, entries, selected, onPick, notOwned = false }) {
|
||||
const choice = effectiveChoice(entries, chess, golden);
|
||||
const opt = chessOptions(chess, golden);
|
||||
const skillRec = opt.skillOptions.find((s) => s.index === choice.skill)?.normal || chess.skill;
|
||||
@@ -147,6 +151,7 @@ function RosterCard({ m, chess, golden, entries, selected, onPick }) {
|
||||
</span>
|
||||
<${TierChip} tier=${chess.tier} size="sm" class="lo-card__tier" />
|
||||
${choice.changed ? html`<span class="lo-card__flag" aria-label="已调整"></span>` : null}
|
||||
${notOwned ? html`<span class="lo-card__sub" title="未持有(干员持有):由替补干员上场" aria-label="未持有">替补</span>` : null}
|
||||
<span class="lo-card__name">${chess.name}</span>
|
||||
<span class="lo-card__kit">
|
||||
<span class=${cx('lo-card__sk', choice.skill !== opt.defaultSkill && 'is-alt')}>
|
||||
@@ -216,6 +221,8 @@ function ModuleInfo({ m, golden, opt }) {
|
||||
}
|
||||
|
||||
const getChessRec = (id) => data.lookup('chess', id);
|
||||
/** The name of a chess's 补位 stand-in (data/backups.json), or null. */
|
||||
const standInName = (chess) => data.get('backups')?.units?.[chess?.backup?.charId]?.name ?? null;
|
||||
|
||||
/**
|
||||
* What 局内数值 shows (GitHub issue #64): the chess variant — the 精锐 record when asked for and the chess has one, else
|
||||
@@ -266,7 +273,7 @@ export function LoadoutStats({ base, golden, entries, level, onLevel, getChess =
|
||||
</section>`;
|
||||
}
|
||||
|
||||
function Detail({ m, chess, golden, entries, onChange, onReset, locked }) {
|
||||
function Detail({ m, chess, golden, entries, onChange, onReset, locked, notOwned = false }) {
|
||||
const [level, setLevel] = useState('normal');
|
||||
const [statLevel, setStatLevel] = useState('elite'); // 局内数值: the 精锐 shows the chosen module's effect
|
||||
const bodyRef = useRef(null);
|
||||
@@ -329,6 +336,7 @@ function Detail({ m, chess, golden, entries, onChange, onReset, locked }) {
|
||||
</div>
|
||||
${modOpt ? html`<${ModuleInfo} m=${m} golden=${golden} opt=${modOpt} />` : null}
|
||||
</section>` : null}
|
||||
${notOwned ? html`<p class="lo-locknote lo-locknote--standin" data-testid="loadout-standin-note"><${Icon} name="info" />干员持有中标记为未持有:此棋子由替补干员${standInName(chess) ? ` ${standInName(chess)} ` : ''}上场,技能与模组固定(补位干员技能不可更改);这里的调配在改回「持有」后生效</p>` : null}
|
||||
${locked ? html`<p class="lo-locknote"><${Icon} name="info" />本局的调配已锁定,修改将在下一局生效</p>` : null}
|
||||
</div>
|
||||
</aside>`;
|
||||
@@ -373,6 +381,8 @@ const SYNC_TEXT = {
|
||||
idle: ['', ''], pending: ['保存中…', 'is-busy'], sending: ['同步中…', 'is-busy'], synced: ['已同步', 'is-ok'],
|
||||
locked: ['本局已锁定 · 下一局生效', 'is-warn'], error: ['同步失败', 'is-bad'],
|
||||
};
|
||||
/** The 干员持有 tab's status line: the setting never applies to a running match. */
|
||||
const OWN_SYNC_TEXT = { ...SYNC_TEXT, locked: ['下一局生效', 'is-warn'] };
|
||||
|
||||
/** The overlay screen. */
|
||||
function LoadoutScreen({ st }) {
|
||||
@@ -401,6 +411,16 @@ function LoadoutScreen({ st }) {
|
||||
const [narrowDetail, setNarrowDetail] = useState(false); // phones: the detail slides over the roster
|
||||
const [io, setIo] = useState(null); // 导出 / 导入 dialog: { mode, text } | null
|
||||
|
||||
const tab = st.tab === 'ownership' ? 'ownership' : 'loadout';
|
||||
const ownRoster = useOwnershipRoster(ready);
|
||||
const nNotOwned = notOwnedCount(st.notOwned, ownRoster);
|
||||
const setTab = (t) => loadoutStore.set({ tab: t });
|
||||
const toggleOwned = (id, owned) => setNotOwned(setOwned(loadoutStore.get().notOwned, id, owned));
|
||||
const ownAll = async () => {
|
||||
if (!nNotOwned) return;
|
||||
const ok = await confirmDialog({ title: '全部持有', text: `将 ${nNotOwned} 名未持有的干员恢复为持有(由本人上场)?`, okText: '全部持有' });
|
||||
if (ok) setNotOwned([]);
|
||||
};
|
||||
const pick = (id) => { loadoutStore.set({ sel: id }); setNarrowDetail(true); };
|
||||
const change = (patch) => { if (base) setEntries(setChoice(loadoutStore.get().entries, base, golden, patch)); };
|
||||
const resetOne = () => { if (base) setEntries(resetChoice(loadoutStore.get().entries, base.chessId)); };
|
||||
@@ -410,27 +430,39 @@ function LoadoutScreen({ st }) {
|
||||
if (ok) setEntries({});
|
||||
};
|
||||
|
||||
// 导出 / 导入 the loadout as the versioned payload (a downloaded file, the clipboard, or the textarea)
|
||||
// 导出 / 导入 the loadout (or, on the 干员持有 tab, the not-owned list) as the versioned payload (a downloaded file,
|
||||
// the clipboard, or the textarea); `io.kind` says which
|
||||
const ioText = io?.text ?? '';
|
||||
const openExport = () => setIo({ mode: 'export', text: serializeExport(loadoutStore.get().entries) });
|
||||
const openImport = () => setIo({ mode: 'import', text: '' });
|
||||
const ioOwn = io?.kind === 'ownership';
|
||||
const openExport = () => setIo(tab === 'ownership'
|
||||
? { mode: 'export', kind: 'ownership', text: serializeOwnership(loadoutStore.get().notOwned) }
|
||||
: { mode: 'export', kind: 'loadout', text: serializeExport(loadoutStore.get().entries) });
|
||||
const openImport = () => setIo({ mode: 'import', kind: tab, text: '' });
|
||||
const ioCopy = async () => {
|
||||
const ok = await copyText(ioText);
|
||||
toast(ok ? '已复制到剪贴板' : '复制失败,请在文本框中手动全选复制', ok ? 'success' : 'warn');
|
||||
};
|
||||
const ioDownload = () => downloadText(exportFilename(), ioText);
|
||||
const ioDownload = () => downloadText(exportFilename(new Date(), ioOwn ? 'ownership' : 'loadout'), ioText);
|
||||
const ioPick = () => fileRef.current?.click();
|
||||
const ioFile = async (e) => {
|
||||
const f = e.currentTarget.files && e.currentTarget.files[0];
|
||||
e.currentTarget.value = ''; // picking the same file twice must fire again
|
||||
if (!f) return;
|
||||
// refuse a huge pick before reading it into memory (a real payload is a few KB)
|
||||
if (f.size > LOADOUT_IMPORT_MAX_BYTES) { toast('文件过大,请选择「导出」下载的调配文件', 'error'); return; }
|
||||
try { setIo({ mode: 'import', text: await readFileText(f) }); } catch { toast('读取文件失败', 'error'); }
|
||||
if (f.size > (ioOwn ? OWNERSHIP_IMPORT_MAX_BYTES : LOADOUT_IMPORT_MAX_BYTES)) { toast(`文件过大,请选择「导出」下载的${ioOwn ? '干员持有' : '调配'}文件`, 'error'); return; }
|
||||
try { setIo({ ...io, mode: 'import', text: await readFileText(f) }); } catch { toast('读取文件失败', 'error'); }
|
||||
};
|
||||
const ioApply = () => {
|
||||
// an import before chess.json is loaded would sanitise every entry away — refuse instead of wiping the loadout
|
||||
if (!ready) { toast('干员数据仍在载入,请稍候再导入', 'warn'); return; }
|
||||
if (ioOwn) {
|
||||
const r = parseOwnershipImport(ioText);
|
||||
if (!r.ok) { toast(`导入失败:${r.error}`, 'error'); return; }
|
||||
const { applied, dropped } = applyOwnershipImport(r.notOwned, getChess);
|
||||
setIo(null);
|
||||
toast(applied ? `已导入:${applied} 名干员未持有${dropped ? `(另有 ${dropped} 项无效,未导入)` : ''}` : '已导入:全部持有', dropped ? 'warn' : 'success');
|
||||
return;
|
||||
}
|
||||
const res = parseImport(ioText);
|
||||
if (!res.ok) { toast(`导入失败:${res.error}`, 'error'); return; }
|
||||
const { applied, dropped } = applyLoadoutEntries(res.entries, getChess);
|
||||
@@ -450,7 +482,7 @@ function LoadoutScreen({ st }) {
|
||||
const typing = e.target && /^(INPUT|SELECT|TEXTAREA)$/.test(e.target.tagName);
|
||||
if (e.key === 'Escape') { e.preventDefault(); e.stopImmediatePropagation(); closeLoadout(); return; }
|
||||
if (typing) return;
|
||||
if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') {
|
||||
if ((e.key === 'ArrowRight' || e.key === 'ArrowLeft') && loadoutStore.get().tab !== 'ownership') {
|
||||
const ids = filterRoster(rosterOf(data.list('chess')), loadoutStore.get().filters, loadoutStore.get().entries, getChess, getBond).map((c) => c.chessId);
|
||||
if (!ids.length) return;
|
||||
const cur = Math.max(0, ids.indexOf(loadoutStore.get().sel));
|
||||
@@ -471,8 +503,12 @@ function LoadoutScreen({ st }) {
|
||||
if (el && typeof el.scrollIntoView === 'function') el.scrollIntoView({ block: 'nearest' });
|
||||
}, [selId]);
|
||||
|
||||
const [syncText, syncCls] = SYNC_TEXT[st.sync] || SYNC_TEXT.idle;
|
||||
const [syncText, syncCls] = tab === 'ownership' ? OWN_SYNC_TEXT[st.ownSync] || OWN_SYNC_TEXT.idle : SYNC_TEXT[st.sync] || SYNC_TEXT.idle;
|
||||
const fromText = st.from === 'briefing' ? '确认本局信息阶段结束前可调整本局配置' : '开始模拟前可调整干员携带的技能与模组,干员等级不可调整';
|
||||
// 干员持有 is out of match: a running match keeps the list its seat had at its start
|
||||
const ownLocked = inMatch && !!phase && phase !== PHASE.LOBBY;
|
||||
const ownText = ownLocked ? '干员持有是局外设置:本局按开局时的设置进行,修改将在下一局生效'
|
||||
: '局外设置,下一局生效 · 联机时只影响你自己的棋子 · 默认全部持有';
|
||||
|
||||
return html`<${Fragment}>
|
||||
<div class="lo" role="dialog" aria-modal="true" aria-label="干员调配">
|
||||
@@ -482,35 +518,51 @@ function LoadoutScreen({ st }) {
|
||||
<${Button} variant="ghost" size="md" icon="chevronLeft" class="lo-back" onClick=${closeLoadout} aria-label="返回" title="返回 (Esc)">返回<//>
|
||||
</div>
|
||||
<div class="lo-top__center">
|
||||
<${MicroLabel} tone="mint">OPERATOR LOADOUT<//>
|
||||
<h1 class="lo-top__title"><${Img} src=${localAsset('ui/outer', 'operator_preset')} class="lo-top__icon" fallback=${html`<${Icon} name="edit" class="lo-top__icon" />`} />干员调配</h1>
|
||||
<${MicroLabel} tone="mint">${tab === 'ownership' ? 'OPERATOR ROSTER' : 'OPERATOR LOADOUT'}<//>
|
||||
<div class="lo-tabs" role="tablist" aria-label="干员调配 / 干员持有">
|
||||
<button type="button" role="tab" aria-selected=${tab === 'loadout' ? 'true' : 'false'} data-tab="loadout" class=${cx('lo-tab', tab === 'loadout' && 'is-on')} onClick=${() => setTab('loadout')}>
|
||||
<${Img} src=${localAsset('ui/outer', 'operator_preset')} class="lo-top__icon" fallback=${html`<${Icon} name="edit" class="lo-top__icon" />`} />干员调配</button>
|
||||
<button type="button" role="tab" aria-selected=${tab === 'ownership' ? 'true' : 'false'} data-tab="ownership" class=${cx('lo-tab', tab === 'ownership' && 'is-on')} onClick=${() => setTab('ownership')}
|
||||
title="标记未持有的干员:由官方指定的替补干员上场">干员持有${nNotOwned ? html`<span class="lo-tab__n num" aria-label=${`${nNotOwned} 名未持有`}>${nNotOwned}</span>` : null}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="lo-top__right">
|
||||
${tab === 'ownership' ? html`<div class="lo-top__right">
|
||||
${syncText ? html`<span class=${cx('lo-sync', syncCls)} role="status" data-testid="ownership-sync">${syncText}</span>` : null}
|
||||
<span class="lo-count">未持有 <b class="num">${nNotOwned}</b><span class="num t-dim">/${ownRoster.length}</span></span>
|
||||
<${Button} variant="ghost" size="sm" data-testid="ownership-export" disabled=${!nNotOwned} onClick=${openExport} title="导出干员持有(可复制或下载)">导出<//>
|
||||
<${Button} variant="ghost" size="sm" data-testid="ownership-import" disabled=${!ready} onClick=${openImport} title="导入干员持有(粘贴或选择文件)">导入<//>
|
||||
<${Button} variant="secondary" size="sm" icon="refresh" data-testid="ownership-reset" disabled=${!nNotOwned} onClick=${ownAll}>全部持有<//>
|
||||
</div>` : html`<div class="lo-top__right">
|
||||
${inMatch && hasDeadline(infoDeadline) ? html`<${Countdown} deadline=${infoDeadline} size="sm" gauge=${false} label="调配截止" class="lo-deadline" />` : null}
|
||||
${syncText ? html`<span class=${cx('lo-sync', syncCls)} role="status">${syncText}</span>` : null}
|
||||
<span class="lo-count">已调整 <b class="num">${nChanged}</b><span class="num t-dim">/${roster.length}</span></span>
|
||||
<${Button} variant="ghost" size="sm" data-testid="loadout-export" disabled=${!nChanged} onClick=${openExport} title="导出当前调配(可复制或下载)">导出<//>
|
||||
<${Button} variant="ghost" size="sm" data-testid="loadout-import" disabled=${!ready} onClick=${openImport} title="导入调配(粘贴或选择文件)">导入<//>
|
||||
<${Button} variant="secondary" size="sm" icon="refresh" disabled=${!nChanged} onClick=${resetAll}>全部恢复默认<//>
|
||||
</div>
|
||||
</div>`}
|
||||
</header>
|
||||
<p class=${cx('lo-note', locked && 'is-locked')}><${Icon} name="info" />${locked ? '本局的调配已锁定(确认本局信息后无法修改),修改将在下一局生效' : fromText}</p>
|
||||
${!ready ? html`<div class="lo-loading"><${Spinner} size="sm" />正在载入干员数据(打开页面后仅载入一次)…</div>` : html`<main class=${cx('lo-body', narrowDetail && 'is-detail')}>
|
||||
${tab === 'ownership'
|
||||
? html`<p class=${cx('lo-note', ownLocked && 'is-locked')}><${Icon} name="info" />${ownText}</p>`
|
||||
: html`<p class=${cx('lo-note', locked && 'is-locked')}><${Icon} name="info" />${locked ? '本局的调配已锁定(确认本局信息后无法修改),修改将在下一局生效' : fromText}</p>`}
|
||||
${!ready ? html`<div class="lo-loading"><${Spinner} size="sm" />正在载入干员数据(打开页面后仅载入一次)…</div>` : tab === 'ownership'
|
||||
? html`<${OwnershipPanel} m=${m} roster=${ownRoster} notOwned=${st.notOwned} onToggle=${toggleOwned} />`
|
||||
: html`<main class=${cx('lo-body', narrowDetail && 'is-detail')}>
|
||||
<section class="lo-roster">
|
||||
<${Filters} m=${m} filters=${st.filters} bonds=${bonds} onFilters=${(filters) => loadoutStore.set({ filters })} />
|
||||
<div class="lo-grid" role="listbox" aria-label="干员列表" ref=${gridRef}>
|
||||
${list.length ? list.map((c) => html`<${RosterCard} key=${c.chessId} m=${m} chess=${c} golden=${c.goldenId ? getChess(c.goldenId) : null}
|
||||
entries=${st.entries} selected=${c.chessId === selId} onPick=${pick} />`) : html`<p class="lo-empty t-dim">没有符合条件的干员</p>`}
|
||||
entries=${st.entries} selected=${c.chessId === selId} onPick=${pick} notOwned=${(st.notOwned || []).includes(c.chessId)} />`) : html`<p class="lo-empty t-dim">没有符合条件的干员</p>`}
|
||||
</div>
|
||||
</section>
|
||||
<div class="lo-detail-wrap">
|
||||
<button type="button" class="lo-detail-back tapx" onClick=${() => setNarrowDetail(false)}><${Icon} name="chevronLeft" />干员列表</button>
|
||||
<${Detail} m=${m} chess=${base} golden=${golden} entries=${st.entries} onChange=${change} onReset=${resetOne} locked=${locked} />
|
||||
<${Detail} m=${m} chess=${base} golden=${golden} entries=${st.entries} onChange=${change} onReset=${resetOne} locked=${locked}
|
||||
notOwned=${!!base && (st.notOwned || []).includes(base.chessId)} />
|
||||
</div>
|
||||
</main>`}
|
||||
</div>
|
||||
${io ? html`<${Modal} open=${true} onClose=${() => setIo(null)}
|
||||
title=${io.mode === 'export' ? '导出干员调配' : '导入干员调配'} micro="OPERATOR LOADOUT"
|
||||
title=${`${io.mode === 'export' ? '导出' : '导入'}${ioOwn ? '干员持有' : '干员调配'}`} micro=${ioOwn ? 'OPERATOR ROSTER' : 'OPERATOR LOADOUT'}
|
||||
actions=${io.mode === 'export'
|
||||
? html`<${Button} variant="ghost" onClick=${() => setIo(null)}>关闭<//>
|
||||
<${Button} variant="secondary" icon="copy" data-testid="loadout-io-copy" onClick=${ioCopy}>复制<//>
|
||||
@@ -518,12 +570,16 @@ function LoadoutScreen({ st }) {
|
||||
: html`<${Button} variant="ghost" onClick=${() => setIo(null)}>取消<//>
|
||||
<${Button} variant="secondary" data-testid="loadout-io-pick" onClick=${ioPick}>选择文件<//>
|
||||
<${Button} variant="primary" icon="check" data-testid="loadout-io-apply" disabled=${!ioText.trim() || !ready} onClick=${ioApply}>导入<//>`}>
|
||||
<p class="lo-io__hint">${io.mode === 'export'
|
||||
? html`共 <b class="num">${nChanged}</b> 名干员已调整。复制或下载这份数据,即可在别的设备或浏览器上导入。`
|
||||
: html`把导出的内容粘贴到下方,或点「选择文件」。${nChanged ? html`导入会<strong>覆盖</strong>当前的 ${nChanged} 名干员调配。` : null}`}</p>
|
||||
<p class="lo-io__hint">${ioOwn
|
||||
? (io.mode === 'export'
|
||||
? html`共 <b class="num">${nNotOwned}</b> 名干员未持有。复制或下载这份数据,即可在别的设备或浏览器上导入。`
|
||||
: html`把导出的干员持有数据粘贴到下方,或点「选择文件」。导入会<strong>覆盖</strong>当前的干员持有设置。`)
|
||||
: io.mode === 'export'
|
||||
? html`共 <b class="num">${nChanged}</b> 名干员已调整。复制或下载这份数据,即可在别的设备或浏览器上导入。`
|
||||
: html`把导出的内容粘贴到下方,或点「选择文件」。${nChanged ? html`导入会<strong>覆盖</strong>当前的 ${nChanged} 名干员调配。` : null}`}</p>
|
||||
<textarea class="lo-io__text" data-testid="loadout-io-text" spellcheck=${false} readOnly=${io.mode === 'export'} value=${ioText}
|
||||
placeholder=${io.mode === 'export' ? '' : '在此粘贴导出的调配内容…'}
|
||||
onInput=${(e) => setIo({ mode: io.mode, text: e.currentTarget.value })}></textarea>
|
||||
placeholder=${io.mode === 'export' ? '' : ioOwn ? '在此粘贴导出的干员持有内容…' : '在此粘贴导出的调配内容…'}
|
||||
onInput=${(e) => setIo({ ...io, text: e.currentTarget.value })}></textarea>
|
||||
<input type="file" accept=".json,application/json,text/plain" class="lo-io__file" ref=${fileRef} onChange=${ioFile} />
|
||||
<//>` : null}
|
||||
<//>`;
|
||||
@@ -575,11 +631,15 @@ export function badgeCount(entries, getChess) {
|
||||
export function LoadoutButton({ from, size = 'md', variant = 'secondary', class: cls, label = '干员调配' }) {
|
||||
useData('local'); // the official preset icon (re-render once the local-art manifest arrives)
|
||||
const entries = useStore((s) => s.entries, Object.is, loadoutStore);
|
||||
const notOwned = useStore((s) => s.notOwned, Object.is, loadoutStore);
|
||||
const n = badgeCount(entries, data.status('chess') === 'ready' ? (id) => data.lookup('chess', id) : null);
|
||||
// 0.2.0 补位: how many operators the player marked as not owned (the 干员持有 tab) — easy to forget between sessions
|
||||
const off = Array.isArray(notOwned) ? notOwned.length : 0;
|
||||
return html`<button type="button" class=${cx('btn', `btn--${variant}`, `btn--${size}`, 'lo-entry', cls)} data-testid="loadout-open"
|
||||
onClick=${() => openLoadout(from)} title="调整干员携带的技能与模组">
|
||||
onClick=${() => openLoadout(from)} title="调整干员携带的技能与模组 · 干员持有">
|
||||
<${Img} src=${localAsset('ui/outer', 'operator_preset')} class="lo-entry__icon" fallback=${html`<${Icon} name="edit" class="btn__icon" />`} />
|
||||
<span class="btn__label">${label}</span>
|
||||
${n ? html`<span class="lo-entry__n num" aria-label=${`${n} 名干员已调整`}>${n}</span>` : null}
|
||||
${off ? html`<span class="lo-entry__own" aria-label=${`${off} 名干员未持有,由替补干员上场`} title=${`${off} 名干员未持有(由替补干员上场)`}>替补 <b class="num">${off}</b></span>` : null}
|
||||
</button>`;
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
// 干员持有 (operator ownership, 0.2.0 补位 — the approved plan, owner's decision 2026-10-05): the second tab of the
|
||||
// 干员调配 overlay (screens/loadout.js). The 53 NORMAL chess of this season's shop, grouped by tier — each card shows the
|
||||
// operator and its official stand-in (avatar, name, the skill it fields) and switches between 持有 and 未持有 with a
|
||||
// tap. A chess marked 未持有 keeps its shop / hand card (name, bonds, price, with a 「替补:X」 badge) and is deployed
|
||||
// as the stand-in (model, skills, talents, range). PRESET chess always field their own operator and are not listed.
|
||||
// Default: everything owned; 全部持有 resets. Out of match: the next match takes the list (in co-op it only affects the
|
||||
// player's own pieces). The list lives in ui/loadoutSync.js (localStorage + room.ownership); the model is
|
||||
// ui/ownershipModel.js. Styles: css/screens/loadout.css (own-*).
|
||||
|
||||
import { useMemo, useState } from '../../vendor/hooks.module.js';
|
||||
import { html, TierChip, Icon } from '../ui/components.js';
|
||||
import { Img } from '../ui/gameComponents.js';
|
||||
import { chessAvatarUrl } from '../ui/assetUrls.js';
|
||||
import { data } from '../data.js';
|
||||
import { PROF_NAME } from '../ui/loadoutModel.js';
|
||||
import { ownershipRoster, rosterByTier, isOwned, standInSummary } from '../ui/ownershipModel.js';
|
||||
|
||||
const cx = (...p) => p.flat().filter(Boolean).join(' ');
|
||||
const ROMAN = ['', 'I', 'II', 'III', 'IV', 'V', 'VI'];
|
||||
|
||||
/** The roster of the tab (data.list('chess'), memoised on the data being ready). */
|
||||
export function useOwnershipRoster(ready) {
|
||||
return useMemo(() => ownershipRoster(data.list('chess')), [ready]);
|
||||
}
|
||||
|
||||
/** One chess: the operator → its stand-in, and the 持有 switch (the whole card is the switch). */
|
||||
export function OwnCard({ m, chess, backups, owned, onToggle }) {
|
||||
const si = standInSummary(chess, backups);
|
||||
const skill = si?.skill;
|
||||
const slot = skill && Number.isInteger(skill.index) ? `S${skill.index + 1}` : '';
|
||||
const label = `${chess.name}:${owned ? '持有' : `未持有,由 ${si?.name || '替补干员'} 上场`}`;
|
||||
return html`<button type="button" role="switch" aria-checked=${owned ? 'true' : 'false'} aria-label=${label} data-chess=${chess.chessId}
|
||||
class=${cx('own-card', `own-card--t${chess.tier}`, !owned && 'is-off')} onClick=${() => onToggle(chess.chessId, !owned)}
|
||||
title=${owned ? `点击标记为未持有:由 ${si?.name || '替补干员'} 上场` : '点击恢复为持有'}>
|
||||
<span class="own-card__op">
|
||||
<span class="own-card__ava"><${Img} src=${chessAvatarUrl(m, chess)} fallback=${html`<b>${[...(chess.name || '?')][0]}</b>`} /></span>
|
||||
<span class="own-card__txt">
|
||||
<b class="own-card__name">${chess.name}</b>
|
||||
<small>${PROF_NAME[chess.profession] || ''}${chess.subProfessionName ? ` · ${chess.subProfessionName}` : ''}</small>
|
||||
</span>
|
||||
</span>
|
||||
<span class="own-card__arrow" aria-hidden="true"><${Icon} name="chevronRight" /></span>
|
||||
<span class="own-card__sub" data-standin=${si?.charId || ''}>
|
||||
<span class="own-card__ava own-card__ava--sub">${si ? html`<${Img} src=${chessAvatarUrl(m, si.record)} fallback=${html`<b>${[...(si.name || '?')][0]}</b>`} />` : null}</span>
|
||||
<span class="own-card__txt">
|
||||
<b class="own-card__name">${si ? si.name : '—'}</b>
|
||||
<small>${si ? `${PROF_NAME[si.profession] || ''}${slot ? ` · ${slot} ${skill.name || ''}` : ''}` : '无替补数据'}</small>
|
||||
</span>
|
||||
</span>
|
||||
<span class="own-card__state"><i class="own-switch" aria-hidden="true"><i></i></i>${owned ? '持有' : '未持有'}</span>
|
||||
</button>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The tab's body.
|
||||
* @param {{ m: any, roster: any[], notOwned: string[], onToggle: (chessId: string, owned: boolean) => void }} props
|
||||
*/
|
||||
export function OwnershipPanel({ m, roster, notOwned, onToggle }) {
|
||||
const [offOnly, setOffOnly] = useState(false);
|
||||
const backups = data.get('backups');
|
||||
const list = offOnly ? roster.filter((c) => !isOwned(notOwned, c.chessId)) : roster;
|
||||
const groups = rosterByTier(list);
|
||||
return html`<main class="own" data-testid="ownership">
|
||||
<div class="own__bar">
|
||||
<p class="own__lead">未持有的干员由官方指定的<b>替补干员</b>上场:棋子的名字、盟约、特质、阶级与价格不变,上场的是替补干员(模型、技能、天赋、攻击范围)。预设干员无论是否持有都由本人上场,不在此列。</p>
|
||||
<button type="button" class=${cx('lo-toggle', offOnly && 'is-on')} aria-pressed=${offOnly ? 'true' : 'false'} onClick=${() => setOffOnly(!offOnly)}>
|
||||
<i class="lo-toggle__box"><${Icon} name="check" /></i>仅看未持有</button>
|
||||
</div>
|
||||
<div class="own__list">
|
||||
${groups.length ? groups.map((g) => html`<section key=${g.tier} class="own-tier" data-tier=${g.tier} aria-label=${`${g.tier}阶`}>
|
||||
<h3 class="own-tier__head"><${TierChip} tier=${g.tier} size="sm" /><span class="num">${ROMAN[g.tier]}</span><span>阶</span><span class="num t-dim">${g.list.length}</span></h3>
|
||||
<div class="own-grid">
|
||||
${g.list.map((c) => html`<${OwnCard} key=${c.chessId} m=${m} chess=${c} backups=${backups} owned=${isOwned(notOwned, c.chessId)} onToggle=${onToggle} />`)}
|
||||
</div>
|
||||
</section>`) : html`<p class="lo-empty t-dim">${offOnly ? '全部持有:没有由替补干员上场的棋子' : '没有可下掉的干员'}</p>`}
|
||||
</div>
|
||||
</main>`;
|
||||
}
|
||||
+41
-21
@@ -33,7 +33,7 @@
|
||||
|
||||
import { html, Icon, TierChip, MicroLabel, Button, confirmDialog, useTicker } from './components.js';
|
||||
import { Img, RichText, UnitThumb, BondGlyph, GIcon } from './gameComponents.js';
|
||||
import { attackInterval, rangeGridBox, fmtNum, tileKey, chessLoadout, nextThreshold, bondTier, briefingBondTip, pieceBondIds, grantedBonds, morphPairings } from './gameLogic.js';
|
||||
import { attackInterval, rangeGridBox, fmtNum, tileKey, chessLoadout, nextThreshold, bondTier, briefingBondTip, pieceBondIds, grantedBonds, morphPairings, fieldsStandIn, standInOf, standInLoadout, standInLabel } from './gameLogic.js';
|
||||
import { chessPortraitUrl, skillIconUrl, skillRecordIconUrl, profIconUrl, subProfIconUrl, itemIconUrl, enemyIconUrl, tokenAvatarUrl, factionIconUrl, uiUrl, moduleTypeIconUrl } from './assetUrls.js';
|
||||
import { abilityRows } from './abilityLines.js';
|
||||
import { data } from '../data.js';
|
||||
@@ -354,17 +354,22 @@ export function chessStatsBlock({ rec, chess, live = null }) {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
export function ChessDetail({ chess, piece, unit, snapHp, editable, onSell, bonds, offBonds = null, loadout, onBond, live = null, hint = null, unitItems = null }) {
|
||||
export function ChessDetail({ chess, piece, unit, snapHp, editable, onSell, bonds, offBonds = null, loadout, onBond, live = null, hint = null, unitItems = null, standIn = null }) {
|
||||
const m = data.get('assets');
|
||||
const hp = hpOf(live, snapHp);
|
||||
const lo = chessLoadout(chess, loadout, (id) => data.lookup('chess', id));
|
||||
const getChess = (id) => data.lookup('chess', id);
|
||||
// 0.2.0 补位: a chess fielded as its stand-in keeps its identity (name, tier, bonds, 特质, sell price) and shows the
|
||||
// stand-in's body — portrait, class, 特性, stats, range, skill, talents, module (its backup selection, no loadout)
|
||||
const si = standIn && standIn.standInFor ? standIn : null;
|
||||
const body = si || chess;
|
||||
const lo = si ? standInLoadout(si, getChess, data.get('backups')) : chessLoadout(chess, loadout, getChess);
|
||||
const c = chess;
|
||||
// stats / talents the unit fights with: the chosen module's (or none — statsBase) for an elite (DESIGN §16)
|
||||
const fr = lo?.record || c;
|
||||
const fr = lo?.record || body;
|
||||
const golden = !!(c.isGolden || piece?.golden);
|
||||
const sk = lo?.skill || c.skill || null;
|
||||
const sk = lo?.skill || body.skill || null;
|
||||
// a chosen skill the manifest has no icon for (only the default skills' icons are fetched): its slot letter
|
||||
const skIcon = sk && lo && !lo.defaultSkill ? skillRecordIconUrl(m, sk, { empty: false }) : skillIconUrl(m, c);
|
||||
const skIcon = sk && lo && !lo.defaultSkill ? skillRecordIconUrl(m, sk, { empty: false }) : skillIconUrl(m, body);
|
||||
const skSlot = sk && Number.isInteger(sk.index) ? `S${sk.index + 1}` : null;
|
||||
const garrison = Array.isArray(c.garrisonIds) && c.garrisonIds[0] ? data.lookup('garrisons', c.garrisonIds[0]) : null;
|
||||
const items = Array.isArray(piece?.items) ? piece.items : [];
|
||||
@@ -379,36 +384,38 @@ export function ChessDetail({ chess, piece, unit, snapHp, editable, onSell, bond
|
||||
blocks.head = html`
|
||||
<div key="head" class="dhead">
|
||||
<div class=${cx('dhead__art', golden && 'is-golden', `dhead__art--t${c.tier}`)}>
|
||||
<${Img} src=${chessPortraitUrl(m, c)} fallback=${html`<${UnitThumb} kind="chess" id=${c.chessId} size="lg" />`} />
|
||||
<${Img} src=${chessPortraitUrl(m, body)} fallback=${html`<${UnitThumb} kind="chess" id=${c.chessId} size="lg" />`} />
|
||||
</div>
|
||||
<div class="dhead__info">
|
||||
<div class="dhead__chips">
|
||||
<${TierChip} tier=${c.tier} golden=${golden} size="lg" />
|
||||
${golden ? html`<span class="dtag-elite">精锐</span>` : null}
|
||||
${piece?.kind === 'token' ? html`<span class="dtag-token">召唤物</span>` : null}
|
||||
${si ? html`<span class="dtag-standin" data-standin=${si.charId} title=${`未持有${c.name}:由替补干员 ${si.name} 上场(盟约、特质、阶级与价格不变)`}>${standInLabel(si)}</span>` : null}
|
||||
</div>
|
||||
<h3 class="dhead__name">${c.name}</h3>
|
||||
<span class="dhead__en">${c.appellation || ''}</span>
|
||||
<span class="dhead__en">${si ? `${si.name}${si.appellation && si.appellation !== si.name ? ` · ${si.appellation}` : ''}` : c.appellation || ''}</span>
|
||||
<div class="dhead__class">
|
||||
<${Img} src=${profIconUrl(m, c.profession)} class="dhead__prof" />
|
||||
<span>${PROF_NAME[c.profession] || c.profession || ''}</span>
|
||||
<${Img} src=${profIconUrl(m, body.profession)} class="dhead__prof" />
|
||||
<span>${PROF_NAME[body.profession] || body.profession || ''}</span>
|
||||
<i class="sep"></i>
|
||||
<${Img} src=${subProfIconUrl(m, c)} class="dhead__sub" />
|
||||
<span>${c.subProfessionName || ''}</span>
|
||||
<span class="dhead__pos">${c.position === 'MELEE' ? '近战位' : '远程位'}</span>
|
||||
<${Img} src=${subProfIconUrl(m, body)} class="dhead__sub" />
|
||||
<span>${body.subProfessionName || ''}</span>
|
||||
<span class="dhead__pos">${body.position === 'MELEE' ? '近战位' : '远程位'}</span>
|
||||
</div>
|
||||
${hp ? html`<div class="dhp"><i style=${`width:${Math.max(0, Math.min(100, (hp.hp / Math.max(1, hp.max)) * 100))}%`}></i><span class="num">${fmtNum(hp.hp)} / ${fmtNum(hp.max)}</span></div>` : null}
|
||||
<${BondChips} bondIds=${bondIds} bonds=${bonds} off=${offBonds} onBond=${onBond} granted=${grantedIds} />
|
||||
</div>
|
||||
</div>`;
|
||||
blocks.garrison = garrison ? html`<${GarrisonBlock} key="garrison" garrison=${garrison} m=${m} />` : null;
|
||||
blocks.trait = c.trait?.desc ? html`<p key="trait" class="dtrait"><${Icon} name="info" /><${RichText} text=${traitText(c, golden, lo)} /></p>` : null;
|
||||
blocks.trait = body.trait?.desc ? html`<p key="trait" class="dtrait"><${Icon} name="info" /><${RichText} text=${traitText(body, golden, lo)} /></p>` : null;
|
||||
// (`fr` is the body the unit fights with — a stand-in's record for a 补位 chess; `chess` is only the range's fallback)
|
||||
blocks.stats = chessStatsBlock({ rec: fr, chess: c, live });
|
||||
blocks.skill = sk ? html`<${Section} key="skill" title="技能" micro="SKILL" class="dsec--skill">
|
||||
<div class="dskill" data-skill=${sk.skillId || ''}>
|
||||
<${Img} src=${skIcon} class="dskill__icon" fallback=${html`<span class="dskill__icon dskill__icon--empty">${skSlot ? html`<b class="num">${skSlot}</b>` : null}</span>`} />
|
||||
<div class="dskill__meta">
|
||||
<b class="dskill__name">${skSlot && (lo?.choices || 0) > 1 ? html`<span class="dskill__slot num" title=${`技能 ${skSlot}`}>${skSlot}</span>` : null}${sk.name}${lo && !lo.defaultSkill ? html`<span class="dtag-loadout" title="干员调配中选择的技能">已调配</span>` : null}</b>
|
||||
<b class="dskill__name">${skSlot && (lo?.choices || 0) > 1 ? html`<span class="dskill__slot num" title=${`技能 ${skSlot}`}>${skSlot}</span>` : null}${sk.name}${lo && !lo.defaultSkill && !si ? html`<span class="dtag-loadout" title="干员调配中选择的技能">已调配</span>` : null}</b>
|
||||
<div class="dskill__tags">
|
||||
<span class="dsp dsp--${sk.spType === 'INCREASE_WHEN_ATTACK' ? 'atk' : sk.spType === 'INCREASE_WHEN_TAKEN_DAMAGE' ? 'def' : 'time'}">${SP_TYPE[sk.spType] || '技力'}</span>
|
||||
<span class="dsp dsp--trig">${SKILL_TYPE[sk.skillType] || '自动触发'}</span>
|
||||
@@ -426,7 +433,7 @@ export function ChessDetail({ chess, piece, unit, snapHp, editable, onSell, bond
|
||||
<${Img} src=${moduleTypeIconUrl(data.get('local'), lo.module.typeName)} fallback=${html`<b class="num">${moduleBadge(lo.module)}</b>`} /></span>` : null}
|
||||
<b class="dmodule__name">${lo.module.name}</b>
|
||||
${lo.module.typeName ? html`<span class="dmodule__type">${lo.module.typeName}</span>` : null}
|
||||
${!lo.defaultModule ? html`<span class="dtag-loadout" title="干员调配中选择的模组">已调配</span>` : null}
|
||||
${!lo.defaultModule && !si ? html`<span class="dtag-loadout" title="干员调配中选择的模组">已调配</span>` : null}
|
||||
</div>
|
||||
<//>` : null;
|
||||
// (a 变形同构体 / bond item row shows its pairing against what this operator carries: ItemRow `carried`)
|
||||
@@ -604,9 +611,13 @@ export function TokenDetail({ token, piece, ownerId = null, snapHp = null, live
|
||||
* Resolve what a detail target shows.
|
||||
* @param {{ kind:'piece'|'chess'|'item'|'enemy'|'unit'|'token', id?:string, uid?:number, unit?:any, count?:number }} target
|
||||
* @param {Map<number, any>} pieces indexPieces(priv)
|
||||
* @param {{ priv?: any, backups?: any }} [opts] 0.2.0 补位: the player's own pieces and cards of a chess in
|
||||
* m.private.standIns — and a unit carrying `standInFor` — resolve with `standIn` (the composed stand-in record the
|
||||
* card shows as the body)
|
||||
*/
|
||||
export function resolveDetail(target, pieces) {
|
||||
export function resolveDetail(target, pieces, { priv = null, backups = data.get('backups') } = {}) {
|
||||
if (!target) return null;
|
||||
const ownStandIn = (c) => (fieldsStandIn(priv, c) ? standInOf(c, backups) : null);
|
||||
if (target.kind === 'piece') {
|
||||
const e = pieces?.get(target.uid);
|
||||
if (!e) return null;
|
||||
@@ -614,13 +625,16 @@ export function resolveDetail(target, pieces) {
|
||||
if (p.kind === 'item') { const it = data.lookup('items', p.id); return it ? { type: 'item', item: it, piece: p } : null; }
|
||||
if (p.kind === 'token') { const t = data.lookup('tokens', p.id); return t ? { type: 'token', token: t, piece: p, ownerId: tokenOwnerId(p, pieces) } : null; }
|
||||
const c = data.lookup('chess', p.id);
|
||||
return c ? { type: 'chess', chess: c, piece: p } : null;
|
||||
return c ? { type: 'chess', chess: c, piece: p, standIn: ownStandIn(c) } : null;
|
||||
}
|
||||
if (target.kind === 'chess') {
|
||||
// a bond popup's 变形同构体 row hands the wearer's item ids on (bondStrip onMember): the card shows the pair and the chip
|
||||
const c = data.lookup('chess', target.id);
|
||||
const items = Array.isArray(target.items) ? target.items.filter((x) => typeof x === 'string') : [];
|
||||
return c ? { type: 'chess', chess: c, hint: target.hint || null, ...(items.length ? { unitItems: items } : {}) } : null;
|
||||
// (a bond popup's member card of a teammate's strip — `owner` another player — and the mode's banned list (`foreign`)
|
||||
// show the chess as it is: the viewer's 补位 list is not theirs)
|
||||
const foreign = !!target.foreign || (target.owner != null && !!priv && target.owner !== priv.playerId);
|
||||
return c ? { type: 'chess', chess: c, hint: target.hint || null, standIn: foreign ? null : ownStandIn(c), ...(items.length ? { unitItems: items } : {}) } : null;
|
||||
}
|
||||
if (target.kind === 'item') { const it = data.lookup('items', target.id); return it ? { type: 'item', item: it } : null; }
|
||||
if (target.kind === 'enemy') { const en = data.lookup('enemies', target.id); return en ? { type: 'enemy', enemy: en, count: target.count } : null; }
|
||||
@@ -632,7 +646,12 @@ export function resolveDetail(target, pieces) {
|
||||
// a hand item on a scouted prep board (m.field units, kind 'item'): the item's own card
|
||||
if (u.kind === 'item') { const it = data.lookup('items', u.defId); return it ? { type: 'item', item: it } : null; }
|
||||
const c = data.lookup('chess', u.defId);
|
||||
if (c) return { type: 'chess', chess: c, piece: own?.piece || null, unitId: u.id, unitItems: Array.isArray(u.items) ? u.items : null };
|
||||
// a unit says itself whether it is a stand-in (UnitInfo standInFor: the sim's, prep scouting's); an own piece's unit
|
||||
// follows m.private.standIns like the piece
|
||||
let si = null;
|
||||
if (c && typeof u.standInFor === 'string' && u.standInFor) si = standInOf(c, backups);
|
||||
else if (c && own?.piece) si = ownStandIn(c);
|
||||
if (c) return { type: 'chess', chess: c, piece: own?.piece || null, unitId: u.id, unitItems: Array.isArray(u.items) ? u.items : null, standIn: si };
|
||||
const t = data.lookup('tokens', u.defId);
|
||||
if (t) return { type: 'token', token: t, unitId: u.id, ownerId: tokenOwnerId(own?.piece, pieces) };
|
||||
const en = data.lookup('enemies', u.defId);
|
||||
@@ -675,7 +694,8 @@ export function DetailPanel({ detail, editable, snapHp, onClose, onSell, onDestr
|
||||
<button type="button" class="dpanel__close" aria-label="关闭" onClick=${onClose}><${Icon} name="close" /></button>
|
||||
<div class="dpanel__scroll">
|
||||
${detail.type === 'chess' ? html`<${ChessDetail} chess=${detail.chess} piece=${detail.piece} snapHp=${snapHp} editable=${editable} onSell=${sellIt}
|
||||
bonds=${bonds} offBonds=${offBonds} loadout=${loadout} onBond=${onBond} live=${liveNow} hint=${detail.hint || null} unitItems=${detail.unitItems || null} />` : null}
|
||||
bonds=${bonds} offBonds=${offBonds} loadout=${loadout} onBond=${onBond} live=${liveNow} hint=${detail.hint || null} unitItems=${detail.unitItems || null}
|
||||
standIn=${detail.standIn || null} />` : null}
|
||||
${detail.type === 'item' ? html`<${ItemDetail} item=${detail.item} piece=${detail.piece} editable=${editable} onDestroy=${destroyIt} offBonds=${offBonds} />` : null}
|
||||
${detail.type === 'enemy' ? html`<${EnemyDetail} enemy=${detail.enemy} snapHp=${snapHp} count=${detail.count} live=${liveNow} />` : null}
|
||||
${detail.type === 'token' ? html`<${TokenDetail} token=${detail.token} piece=${detail.piece} ownerId=${detail.ownerId ?? null} snapHp=${snapHp} live=${liveNow} />` : null}
|
||||
|
||||
@@ -21,7 +21,7 @@ import { render } from '../../vendor/preact.module.js';
|
||||
import { html, TierChip } from './components.js';
|
||||
import { GEO } from '../../../shared/constants.js';
|
||||
import { chessAvatarUrl, itemIconUrl, tokenAvatarUrl, enemyIconUrl } from './assetUrls.js';
|
||||
import { tileKey, hasFlag, UF, penPlacement, PEN, fieldTile } from './gameLogic.js';
|
||||
import { tileKey, hasFlag, UF, penPlacement, PEN, fieldTile, fieldsStandIn, standInOf } from './gameLogic.js';
|
||||
|
||||
const DRAG_PX = 6;
|
||||
const DMG_TTL = 900;
|
||||
@@ -119,11 +119,14 @@ export function createFallbackView(host, opts = {}) {
|
||||
const tempPos = (L, idx) => ({ x: (GEO.TEMP_C0 + idx) * L.tile, y: (L.rows + 1.72) * L.tile });
|
||||
|
||||
// ---- pieces (prep) ---------------------------------------------------------------------------------------
|
||||
function pieceArt(p) {
|
||||
function pieceArt(p, area = null) {
|
||||
const mm = m();
|
||||
if (p.kind === 'item') return itemIconUrl(mm, lookup('items', p.id));
|
||||
if (p.kind === 'token') return tokenAvatarUrl(mm, p.id);
|
||||
return chessAvatarUrl(mm, lookup('chess', p.id));
|
||||
const chess = lookup('chess', p.id);
|
||||
// 0.2.0 补位: on the board a chess the player does not own is its stand-in (render/app.js pieceInfo)
|
||||
const si = area === 'board' && fieldsStandIn(st.priv, chess) ? standInOf(chess, dataStore?.get?.('backups') ?? null) : null;
|
||||
return chessAvatarUrl(mm, si || chess);
|
||||
}
|
||||
function pieceName(p) {
|
||||
if (p.kind === 'item') return lookup('items', p.id)?.name || '道具';
|
||||
@@ -132,7 +135,7 @@ export function createFallbackView(host, opts = {}) {
|
||||
}
|
||||
|
||||
function Piece({ p, x, y, L, area }) {
|
||||
const src = pieceArt(p);
|
||||
const src = pieceArt(p, area);
|
||||
const dragging = st.drag?.uid === p.uid && st.drag.moved;
|
||||
const golden = !!p.golden;
|
||||
const tier = p.tier || lookup('chess', p.id)?.tier || lookup('items', p.id)?.tier;
|
||||
|
||||
@@ -11,15 +11,15 @@ import {
|
||||
|
||||
const cx = (...p) => p.flat().filter(Boolean).join(' ');
|
||||
|
||||
/** Data files the in-match screens use. */
|
||||
/** Data files the in-match screens use (`backups`: the 补位 stand-ins' bodies — cards, the board model, the detail card). */
|
||||
export const GAME_FILES = ['config', 'assets', 'chess', 'bonds', 'items', 'bands', 'enemies', 'bosses', 'stages', 'tokens',
|
||||
'choices', 'effects', 'garrisons', 'factions', 'local'];
|
||||
'choices', 'effects', 'garrisons', 'factions', 'local', 'backups'];
|
||||
|
||||
/**
|
||||
* Load every in-match data file; returns lookups (sync, null until loaded).
|
||||
* @returns {{ ready: boolean, m: any, config: any, chess: (id:string)=>any, bond: (id:string)=>any, item: (id:string)=>any,
|
||||
* band: (id:string)=>any, enemy: (k:string)=>any, boss: (id:string)=>any, stage: (id:string)=>any, token: (id:string)=>any,
|
||||
* effect: (id:string)=>any, garrison: (id:string)=>any, factions: any, choices: any, list: (name:string)=>any[] }}
|
||||
* effect: (id:string)=>any, garrison: (id:string)=>any, factions: any, choices: any, backups: any, list: (name:string)=>any[] }}
|
||||
*/
|
||||
export function useGameData() {
|
||||
const ready = useData(...GAME_FILES);
|
||||
@@ -44,6 +44,7 @@ export function makeLookups(ready = true) {
|
||||
garrison: (id) => data.lookup('garrisons', id),
|
||||
factions: data.get('factions'),
|
||||
choices: data.get('choices'),
|
||||
backups: data.get('backups'),
|
||||
list: (name) => data.list(name),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -41,4 +41,5 @@ export { shortcutFor, closesOnFieldPress, shortcutBlocked } from './gameLogic/sh
|
||||
export { DEFAULT_SETTINGS, sanitizeSettings } from './gameLogic/settings.js';
|
||||
export { normalizeResult } from './gameLogic/result.js';
|
||||
export { chessLoadout, unitLoadout } from './gameLogic/loadout.js';
|
||||
export { standInIds, fieldsStandIn, standInOf, cardStandIn, standInGetter, standInLoadout, deployedRecord, deployedModuleId, standInLabel } from './gameLogic/standIn.js';
|
||||
export { panelSide, panelSlots, PANEL_RIGHT_GAP, PANEL_RIGHT_BOTTOM, PANEL_RIGHT_BOTTOM_SHOP, BPOP, bondPopupPlace } from './gameLogic/panel.js';
|
||||
@@ -1,13 +1,12 @@
|
||||
// ui/gameLogic/placement.js — deploy maps and the canPlace mirror of the server rules. Re-exported from ../gameLogic.js.
|
||||
|
||||
import { GEO } from '../../../../shared/constants.js';
|
||||
import { resolveLoadout } from '../../../../shared/protocol.js';
|
||||
import { attackRangeGrid } from '../../../../shared/loadoutRecord.js';
|
||||
import { meleeOnHighGround } from '../../../../shared/highGround.js';
|
||||
import { pieceDir, rangeTiles } from '../facing.js';
|
||||
import { isObj, tileKey } from './shared.js';
|
||||
import { boardTileOf, fieldTile } from './camera.js';
|
||||
import { chessLoadout } from './loadout.js';
|
||||
import { deployedRecord, deployedModuleId, fieldsStandIn, standInOf } from './standIn.js';
|
||||
|
||||
|
||||
// ---- placement (canPlace mirror) ------------------------------------------------------------------------
|
||||
@@ -194,7 +193,7 @@ export function indexPieces(priv) {
|
||||
* @param {{ priv:any, stage:any, editable:boolean, field?:'normal'|'bossL'|'bossR', getChess?:(id:string)=>any,
|
||||
* getToken?:(id:string)=>any, getItem?:(id:string)=>any, getEffect?:(id:string)=>any }} o
|
||||
*/
|
||||
export function placementContext({ priv, stage, editable, field = 'normal', getChess = () => null, getToken = () => null, getItem = () => null, getEffect = () => null }) {
|
||||
export function placementContext({ priv, stage, editable, field = 'normal', getChess = () => null, getToken = () => null, getItem = () => null, getEffect = () => null, backups = null }) {
|
||||
const pieces = indexPieces(priv);
|
||||
const boardAt = new Map();
|
||||
for (const e of pieces.values()) if (e.area === 'board') boardAt.set(tileKey(e.row, e.col), e);
|
||||
@@ -210,7 +209,7 @@ export function placementContext({ priv, stage, editable, field = 'normal', getC
|
||||
const deploy = field === 'bossL' || field === 'bossR'
|
||||
? deploySets(stage, field, ov)
|
||||
: deploySets(effectiveStage(stage, ov));
|
||||
return { priv, pieces, boardAt, handAt, deploy, cap, count, field, editable: !!editable, getChess, getToken, getItem };
|
||||
return { priv, pieces, boardAt, handAt, deploy, cap, count, field, editable: !!editable, getChess, getToken, getItem, backups };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -221,9 +220,10 @@ export function placementContext({ priv, stage, editable, field = 'normal', getC
|
||||
export function piecePosition(ctx, piece) {
|
||||
if (!isObj(piece)) return null;
|
||||
if (piece.kind === 'chess') {
|
||||
const rec = ctx.getChess(piece.id);
|
||||
let moduleId = null;
|
||||
try { moduleId = resolveLoadout(ctx.priv?.loadout ?? null, rec, ctx.getChess)?.moduleId ?? null; } catch { moduleId = null; }
|
||||
const chess = ctx.getChess(piece.id);
|
||||
// 0.2.0 补位: a chess the player fields as its stand-in is placed by the stand-in's position (server placeClass)
|
||||
const rec = (fieldsStandIn(ctx.priv, chess) && standInOf(chess, ctx.backups)) || chess;
|
||||
const moduleId = deployedModuleId(chess, ctx.priv, ctx.getChess, ctx.backups);
|
||||
if (meleeOnHighGround(rec, moduleId)) return 'ALL';
|
||||
return rec?.position === 'MELEE' ? 'MELEE' : 'RANGED';
|
||||
}
|
||||
@@ -256,7 +256,7 @@ export function summonRange(ctx, piece, owner = null) {
|
||||
const rec = at && ctx.getChess(at.piece.id);
|
||||
if (!rec) return null;
|
||||
let grid = null;
|
||||
try { grid = attackRangeGrid(chessLoadout(rec, ctx.priv?.loadout ?? null, ctx.getChess)?.record || rec); } catch { /* the data grid */ }
|
||||
try { grid = attackRangeGrid(deployedRecord(rec, ctx.priv, ctx.getChess, ctx.backups) || rec); } catch { /* the data grid */ }
|
||||
return new Set(rangeTiles(grid || rec.rangeGrid, at.row, at.col, pieceDir(at.piece)).map(([r, c]) => tileKey(r, c)));
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
// ui/gameLogic/standIn.js — 补位 stand-ins in the match UI (0.2.0, the approved plan — owner's decision 2026-10-05).
|
||||
// Re-exported from ../gameLogic.js.
|
||||
//
|
||||
// A chess the player marked as not owned (干员持有) keeps its card — name, bonds, price, the shop / reward / hand art —
|
||||
// with a 「替补:<stand-in>」 badge, and is deployed as its official stand-in: the board model, the battle unit and the
|
||||
// detail card's body (portrait, class, 特性, stats, range, skill, talents, module) are the stand-in's, its identity
|
||||
// (name, tier, bonds, 特质) the chess's. Whose pieces: the player's own come from m.private.standIns (the list the seat
|
||||
// had when the match started — the server's, not this browser's current setting, which applies from the next match);
|
||||
// another player's units say it themselves (UnitInfo `standInFor`: the sim's, and Match.prepFieldMeta's for scouting).
|
||||
// The composed record is shared/standIn.js standInRecord over data/backups.json — the one the sim fields.
|
||||
|
||||
import { standInRecord } from '../../../../shared/standIn.js';
|
||||
import { resolveLoadout } from '../../../../shared/protocol.js';
|
||||
import { chessLoadout } from './loadout.js';
|
||||
import { isObj } from './shared.js';
|
||||
|
||||
/** Base chess ids the player fields as stand-ins in this match (m.private.standIns; [] when absent). */
|
||||
export function standInIds(priv) {
|
||||
const list = isObj(priv) && Array.isArray(priv.standIns) ? priv.standIns : [];
|
||||
return list.filter((x) => typeof x === 'string');
|
||||
}
|
||||
|
||||
/** Whether the player's own piece / card of chess record `chess` (normal or elite) fields its stand-in. */
|
||||
export function fieldsStandIn(priv, chess) {
|
||||
if (!isObj(chess) || typeof chess.chessId !== 'string') return false;
|
||||
const ids = standInIds(priv);
|
||||
return ids.length > 0 && ids.includes(chess.baseId || chess.chessId);
|
||||
}
|
||||
|
||||
/** Per backups object: chess record → composed stand-in record (or null). */
|
||||
const CACHE = new WeakMap();
|
||||
|
||||
/**
|
||||
* The composed 补位 record of a chess record (shared/standIn.js standInRecord: the chess's identity, the stand-in's
|
||||
* body; `standInFor` = the replaced charId), cached per data object; null for a PRESET / 自选 chess or missing data.
|
||||
* @param {any} chess data/chess.json record @param {any} backups data/backups.json
|
||||
*/
|
||||
export function standInOf(chess, backups) {
|
||||
if (!isObj(chess) || !isObj(backups)) return null;
|
||||
let m = CACHE.get(backups);
|
||||
if (!m) { m = new WeakMap(); CACHE.set(backups, m); }
|
||||
if (m.has(chess)) return m.get(chess);
|
||||
let rec;
|
||||
try { rec = standInRecord(chess, backups); } catch { rec = null; }
|
||||
m.set(chess, rec);
|
||||
return rec;
|
||||
}
|
||||
|
||||
/**
|
||||
* The stand-in record the card of a chess shows for this viewer, or null: the player's own piece / card (m.private
|
||||
* standIns), or a unit that carries `standInFor` (another player's — the sim's UnitInfo, prep scouting).
|
||||
* @param {any} chess @param {{ priv?: any, unit?: any, backups?: any }} o
|
||||
*/
|
||||
export function cardStandIn(chess, { priv = null, unit = null, backups = null } = {}) {
|
||||
if (!isObj(chess)) return null;
|
||||
const mine = unit ? typeof unit.standInFor === 'string' && !!unit.standInFor : fieldsStandIn(priv, chess);
|
||||
return mine ? standInOf(chess, backups) : null;
|
||||
}
|
||||
|
||||
/** id → stand-in record (else the chess record): the lookup a stand-in's loadout / options resolve against. */
|
||||
export function standInGetter(getChess, backups) {
|
||||
return (id) => {
|
||||
const c = getChess(id);
|
||||
return standInOf(c, backups) || c;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The skill / module / record a stand-in fights with (chessLoadout of the composed record, no player loadout —
|
||||
* "对于补位干员其技能不可更改"): its backup skill, the elite's backup module (or none).
|
||||
* @param {any} rec standInOf(...) @param {(id: string) => any} getChess @param {any} backups
|
||||
*/
|
||||
export function standInLoadout(rec, getChess, backups) {
|
||||
return rec ? chessLoadout(rec, null, standInGetter(getChess, backups)) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The record the player's own piece of `chess` is deployed with — the stand-in record when the player fields its
|
||||
* stand-in, else the chess as the player's loadout makes it (chessLoadout `.record`): its range (board overlay, deploy
|
||||
* wheel), its position (legal tiles), the board model.
|
||||
* @param {any} chess @param {any} priv m.private @param {(id: string) => any} getChess @param {any} backups
|
||||
*/
|
||||
export function deployedRecord(chess, priv, getChess, backups) {
|
||||
if (!isObj(chess)) return chess;
|
||||
if (fieldsStandIn(priv, chess)) {
|
||||
const si = standInOf(chess, backups);
|
||||
if (si) return standInLoadout(si, getChess, backups)?.record || si;
|
||||
}
|
||||
return chessLoadout(chess, priv?.loadout ?? null, getChess)?.record || chess;
|
||||
}
|
||||
|
||||
/**
|
||||
* The module id a deployed piece carries (the placement rule of elite 歌蕾蒂娅's HOK-Y reads it): a stand-in's backup
|
||||
* module, else the player's loadout.
|
||||
*/
|
||||
export function deployedModuleId(chess, priv, getChess, backups) {
|
||||
if (!isObj(chess)) return null;
|
||||
try {
|
||||
if (fieldsStandIn(priv, chess)) {
|
||||
const si = standInOf(chess, backups);
|
||||
if (si) return resolveLoadout(null, si, standInGetter(getChess, backups))?.moduleId ?? null;
|
||||
}
|
||||
return resolveLoadout(priv?.loadout ?? null, chess, getChess)?.moduleId ?? null;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
/** The 「替补:X」 badge text of a stand-in record (null without one). */
|
||||
export function standInLabel(rec) {
|
||||
return isObj(rec) && rec.standInFor && rec.name ? `替补:${rec.name}` : null;
|
||||
}
|
||||
+107
-43
@@ -1,19 +1,22 @@
|
||||
// Operator loadout state + server sync (DESIGN §16).
|
||||
// Operator loadout + ownership state and server sync (DESIGN §16; 干员持有 — 0.2.0 补位).
|
||||
//
|
||||
// `loadoutStore` holds the per-browser loadout (`entries`, persisted in localStorage through store.js savePref) and the
|
||||
// 干员调配 screen state (open / origin / selection / filters). `installLoadoutSync()` (called once by main.js) keeps the
|
||||
// server's copy current: after every `welcome` (new or resumed session — the server keeps it on the session and on the
|
||||
// seat, so joining a room needs no resend) and after every edit (debounced; a pending edit goes out at once when the
|
||||
// overlay closes), it sends `room.loadout { entries }` with
|
||||
// the entries sanitised against the loaded data/chess.json (ui/loadoutModel.js sanitizeEntries: a stale entry is
|
||||
// dropped, never the whole loadout). Replies: RATE → retried later; WRONG_PHASE / ROOM_STARTED → the running match has
|
||||
// locked its loadout (it applies to the next match, the server stored it) — not an error for the player; anything else
|
||||
// is logged. `sync.state` ∈ 'idle' | 'pending' | 'sending' | 'synced' | 'locked' | 'error' is mirrored into the store
|
||||
// for the screen's status line.
|
||||
// `loadoutStore` holds the per-browser loadout (`entries`, persisted in localStorage through store.js savePref), the
|
||||
// per-browser not-owned list of the 干员持有 tab (`notOwned`, likewise) and the overlay's screen state (open / origin /
|
||||
// tab / selection / filters). `installLoadoutSync()` / `installOwnershipSync()` (called once by main.js) keep the
|
||||
// server's copies current — one sync engine (installPrefSync), two settings: after every `welcome` (new or resumed
|
||||
// session — the server keeps them on the session and on the seat, so joining a room needs no resend) and after every
|
||||
// edit (debounced; a pending edit goes out at once when the overlay closes), they send `room.loadout { entries }`
|
||||
// (sanitised against the loaded data/chess.json: ui/loadoutModel.js sanitizeEntries — a stale entry is dropped, never
|
||||
// the whole loadout) and `room.ownership { notOwned }` (sent as stored: the server keeps the droppable chess and drops
|
||||
// the rest, so no data is needed). Replies: RATE → retried later; WRONG_PHASE / ROOM_STARTED → the running match keeps
|
||||
// what it took (the loadout locks when INFO_CHECK ends; the ownership never changes during a match) — stored for the
|
||||
// next match, not an error for the player; anything else is logged. `sync` / `ownSync` ∈ 'idle' | 'pending' |
|
||||
// 'sending' | 'synced' | 'locked' | 'error' are mirrored into the store for the screen's status line.
|
||||
|
||||
import { createStore, loadPref, savePref } from '../store.js';
|
||||
import { data } from '../data.js';
|
||||
import { LOADOUT_PREF, parseStored, toStored, sanitizeEntries } from './loadoutModel.js';
|
||||
import { OWNERSHIP_PREF, parseStoredOwnership, toStoredOwnership, cleanIds, sanitizeNotOwned } from './ownershipModel.js';
|
||||
import { toast } from './toasts.js';
|
||||
|
||||
export const SYNC_DEBOUNCE_MS = 500;
|
||||
@@ -22,15 +25,21 @@ export const RETRY_MS = 1500;
|
||||
function readStored() {
|
||||
try { return parseStored(loadPref(LOADOUT_PREF, null)); } catch { return {}; }
|
||||
}
|
||||
function readStoredOwnership() {
|
||||
try { return parseStoredOwnership(loadPref(OWNERSHIP_PREF, null)); } catch { return []; }
|
||||
}
|
||||
|
||||
/** Loadout + screen state (separate from the app store: it must survive room / match resets). */
|
||||
/** Loadout + ownership + screen state (separate from the app store: it must survive room / match resets). */
|
||||
export const loadoutStore = createStore({
|
||||
entries: readStored(),
|
||||
notOwned: readStoredOwnership(), // 干员持有: base chess ids marked 未持有 (sorted; [] = every operator owned)
|
||||
open: false,
|
||||
from: null, // 'lobby' | 'room' | 'briefing'
|
||||
tab: 'loadout', // 'loadout' (干员调配) | 'ownership' (干员持有)
|
||||
sel: null, // selected base chess id
|
||||
filters: { tier: null, prof: null, bond: null, query: '', changedOnly: false },
|
||||
sync: 'idle',
|
||||
ownSync: 'idle',
|
||||
});
|
||||
|
||||
/** Replace the stored entries (persisted at once; the sync picks the change up). */
|
||||
@@ -57,35 +66,56 @@ export function applyLoadoutEntries(entries, lookup) {
|
||||
return { applied, dropped: Math.max(0, asked - applied) };
|
||||
}
|
||||
|
||||
/** Open the 干員调配 screen. @param {'lobby'|'room'|'briefing'} from @param {string|null} [sel] */
|
||||
export function openLoadout(from = 'lobby', sel = null) {
|
||||
/** Replace the stored not-owned list (干员持有; persisted at once, the sync picks the change up). */
|
||||
export function setNotOwned(list) {
|
||||
const next = cleanIds(list);
|
||||
savePref(OWNERSHIP_PREF, toStoredOwnership(next));
|
||||
loadoutStore.set({ notOwned: next });
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply an imported not-owned list (parseOwnershipImport(...).notOwned), sanitised against the loaded data (droppable
|
||||
* chess only), persisted and synced like an edit. An empty list is a valid import (= every operator owned).
|
||||
* @param {string[]} list @param {(id: string) => any} lookup chess lookup
|
||||
* @returns {{ applied: number, dropped: number }}
|
||||
*/
|
||||
export function applyOwnershipImport(list, lookup) {
|
||||
const asked = cleanIds(list).length;
|
||||
const clean = sanitizeNotOwned(list, lookup);
|
||||
setNotOwned(clean);
|
||||
return { applied: clean.length, dropped: Math.max(0, asked - clean.length) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the 干员调配 overlay. @param {'lobby'|'room'|'briefing'} from @param {string|null} [sel]
|
||||
* @param {'loadout'|'ownership'|null} [tab] the tab to show (default: the last one)
|
||||
*/
|
||||
export function openLoadout(from = 'lobby', sel = null, tab = null) {
|
||||
data.load('chess');
|
||||
data.load('bonds');
|
||||
data.load('assets');
|
||||
data.load('local');
|
||||
loadoutStore.set({ open: true, from, ...(sel ? { sel } : {}) });
|
||||
data.load('backups');
|
||||
loadoutStore.set({ open: true, from, ...(sel ? { sel } : {}), ...(tab === 'loadout' || tab === 'ownership' ? { tab } : {}) });
|
||||
}
|
||||
export const closeLoadout = () => loadoutStore.set({ open: false });
|
||||
|
||||
/**
|
||||
* Wire the sync once. Dependencies are injectable for tests.
|
||||
* @param {{ net: any, getChessReady?: () => Promise<any>, lookupChess?: (id: string) => any,
|
||||
* timers?: { setTimeout: Function, clearTimeout: Function }, target?: ReturnType<typeof createStore> }} deps
|
||||
* @returns {{ flush: () => Promise<void>, dispose: () => void }}
|
||||
* One setting kept in sync with the server (see the header): `key` = the store field, `stateKey` = its sync state field,
|
||||
* `msgType` / `field` = the C2S message, `prepare()` → the payload to send (may await data; null = the data is missing:
|
||||
* nothing is sent, state 'error'), `lockedText` = what a refused edit tells the player.
|
||||
*/
|
||||
export function installLoadoutSync({ net, getChessReady, lookupChess, timers, target = loadoutStore, notify } = {}) {
|
||||
function installPrefSync({ net, timers, target, notify, key, stateKey, msgType, field, prepare, lockedText, tag }) {
|
||||
const T = timers || { setTimeout: (fn, ms) => globalThis.setTimeout(fn, ms), clearTimeout: (id) => globalThis.clearTimeout(id) };
|
||||
const ready = getChessReady || (() => data.load('chess'));
|
||||
const lookup = lookupChess || ((id) => data.lookup('chess', id));
|
||||
const tell = notify || ((text) => toast(text, 'warn'));
|
||||
let timer = null;
|
||||
let seq = 0; // room.loadout requests sent (the reply of an older one never overrides a newer one's state)
|
||||
let seq = 0; // requests sent (the reply of an older one never overrides a newer one's state)
|
||||
let pendingJson = null; // JSON of the newest request still awaiting its reply
|
||||
let lastSent = null; // JSON of the last entries the server accepted (on this session)
|
||||
let lastSent = null; // JSON of the last payload the server accepted (on this session)
|
||||
let edited = false; // an edit is waiting to be sent (a lock refusal is then worth telling the player)
|
||||
let disposed = false;
|
||||
|
||||
const setState = (sync) => { if (target.get().sync !== sync) target.set({ sync }); };
|
||||
const setState = (sync) => { if (target.get()[stateKey] !== sync) target.set({ [stateKey]: sync }); };
|
||||
|
||||
const schedule = (ms = SYNC_DEBOUNCE_MS) => {
|
||||
if (disposed) return;
|
||||
@@ -95,22 +125,17 @@ export function installLoadoutSync({ net, getChessReady, lookupChess, timers, ta
|
||||
};
|
||||
|
||||
// Review fix: a send is never held back behind one still in flight. The socket is ordered and the server applies
|
||||
// room.loadout frames in order, so the newest entries always win; holding the edit until the previous reply arrived let
|
||||
// a click right after closing the overlay (准备就绪 → INFO_CHECK ends) overtake it, and the edit silently missed the match.
|
||||
// the frames in order, so the newest payload always wins; holding the edit until the previous reply arrived let a
|
||||
// click right after closing the overlay (准备就绪 → INFO_CHECK ends) overtake it, and the edit silently missed the match.
|
||||
async function flush() {
|
||||
if (disposed) return;
|
||||
if (net.status !== 'online') { setState('idle'); return; } // the next welcome resends
|
||||
try {
|
||||
const current = target.get().entries;
|
||||
// an empty loadout needs no data (nothing to sanitise): a player who never opened 干员调配 does not download
|
||||
// chess.json in the lobby just for this
|
||||
const empty = !current || Object.keys(current).length === 0;
|
||||
const loaded = empty ? true : await ready();
|
||||
const payload = await prepare();
|
||||
if (disposed) return;
|
||||
// never sanitise against missing data: every entry would be dropped and the server's copy cleared
|
||||
if (loaded == null) { setState('error'); return; }
|
||||
const entries = empty ? {} : sanitizeEntries(target.get().entries, lookup);
|
||||
const json = JSON.stringify(entries);
|
||||
if (payload == null) { setState('error'); return; }
|
||||
const json = JSON.stringify(payload);
|
||||
if (json === pendingJson) return; // the same content is already on its way
|
||||
if (json === lastSent && pendingJson == null) { edited = false; setState('synced'); return; }
|
||||
const my = ++seq;
|
||||
@@ -119,7 +144,7 @@ export function installLoadoutSync({ net, getChessReady, lookupChess, timers, ta
|
||||
pendingJson = json;
|
||||
setState('sending');
|
||||
try {
|
||||
await net.request('room.loadout', { entries });
|
||||
await net.request(msgType, { [field]: payload });
|
||||
if (my !== seq) return;
|
||||
pendingJson = null;
|
||||
lastSent = json;
|
||||
@@ -129,29 +154,29 @@ export function installLoadoutSync({ net, getChessReady, lookupChess, timers, ta
|
||||
pendingJson = null;
|
||||
const code = err && err.code;
|
||||
if (code === 'WRONG_PHASE' || code === 'ROOM_STARTED') {
|
||||
// the server stored it for the next match; the running one keeps the loadout it locked
|
||||
// the server stored it for the next match; the running one keeps what it took
|
||||
lastSent = json;
|
||||
setState('locked');
|
||||
if (wasEdit) tell('本局的干员调配已锁定,修改将在下一局生效');
|
||||
if (wasEdit) tell(lockedText);
|
||||
} else if (code === 'RATE' || code === 'TIMEOUT' || code === 'OFFLINE') { edited = edited || wasEdit; schedule(RETRY_MS); }
|
||||
else { console.warn('[loadout] room.loadout refused', code, err && err.detail); setState('error'); }
|
||||
else { console.warn(`[${tag}] ${msgType} refused`, code, err && err.detail); setState('error'); }
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[loadout] sync failed', e);
|
||||
console.warn(`[${tag}] sync failed`, e);
|
||||
setState('error');
|
||||
}
|
||||
}
|
||||
|
||||
const offWelcome = net.on('welcome', () => { lastSent = null; pendingJson = null; seq++; schedule(50); });
|
||||
const offStore = target.subscribe((s, prev) => {
|
||||
if (s.entries !== prev.entries) { edited = true; schedule(); }
|
||||
if (s[key] !== prev[key]) { edited = true; schedule(); }
|
||||
// closing the overlay sends a pending edit at once (review fix): the player's next click — 准备就绪 in the solo
|
||||
// briefing, 开始模拟 in the room — must not overtake the debounced room.loadout (the match locks its loadout when
|
||||
// briefing, 开始模拟 in the room — must not overtake the debounced message (the match locks its loadout when
|
||||
// INFO_CHECK ends, so a late edit would silently only apply to the next match). Same socket ⇒ ordered.
|
||||
if (prev.open && !s.open && timer != null) { T.clearTimeout(timer); timer = null; void flush(); }
|
||||
});
|
||||
// a match leaving INFO_CHECK locks the loadout; a new match (the room back in LOBBY / a new INFO_CHECK) accepts it again
|
||||
const offRoom = net.on('room.state', (msg) => { if (msg && !msg.inMatch && target.get().sync === 'locked') { lastSent = null; schedule(); } });
|
||||
const offRoom = net.on('room.state', (msg) => { if (msg && !msg.inMatch && target.get()[stateKey] === 'locked') { lastSent = null; schedule(); } });
|
||||
|
||||
return {
|
||||
flush,
|
||||
@@ -164,3 +189,42 @@ export function installLoadoutSync({ net, getChessReady, lookupChess, timers, ta
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire the loadout sync once. Dependencies are injectable for tests.
|
||||
* @param {{ net: any, getChessReady?: () => Promise<any>, lookupChess?: (id: string) => any,
|
||||
* timers?: { setTimeout: Function, clearTimeout: Function }, target?: ReturnType<typeof createStore> }} deps
|
||||
* @returns {{ flush: () => Promise<void>, dispose: () => void }}
|
||||
*/
|
||||
export function installLoadoutSync({ net, getChessReady, lookupChess, timers, target = loadoutStore, notify } = {}) {
|
||||
const ready = getChessReady || (() => data.load('chess'));
|
||||
const lookup = lookupChess || ((id) => data.lookup('chess', id));
|
||||
return installPrefSync({
|
||||
net, timers, target, notify, key: 'entries', stateKey: 'sync', msgType: 'room.loadout', field: 'entries', tag: 'loadout',
|
||||
lockedText: '本局的干员调配已锁定,修改将在下一局生效',
|
||||
async prepare() {
|
||||
const current = target.get().entries;
|
||||
// an empty loadout needs no data (nothing to sanitise): a player who never opened 干员调配 does not download
|
||||
// chess.json in the lobby just for this
|
||||
if (!current || Object.keys(current).length === 0) return {};
|
||||
const loaded = await ready();
|
||||
if (loaded == null) return null;
|
||||
return sanitizeEntries(target.get().entries, lookup);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire the 干员持有 sync once (0.2.0 补位): `room.ownership { notOwned }` after every welcome and edit. The list goes as
|
||||
* stored (structurally clean); the server keeps its droppable chess. During a match the server stores it for the next
|
||||
* one (ROOM_STARTED → 'locked'): the setting is out of match.
|
||||
* @param {{ net: any, timers?: { setTimeout: Function, clearTimeout: Function }, target?: ReturnType<typeof createStore>,
|
||||
* notify?: (text: string) => void }} deps
|
||||
*/
|
||||
export function installOwnershipSync({ net, timers, target = loadoutStore, notify } = {}) {
|
||||
return installPrefSync({
|
||||
net, timers, target, notify, key: 'notOwned', stateKey: 'ownSync', msgType: 'room.ownership', field: 'notOwned', tag: 'ownership',
|
||||
lockedText: '干员持有是局外设置,修改将在下一局生效',
|
||||
prepare: async () => cleanIds(target.get().notOwned),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
// 干员持有 (operator ownership, 0.2.0 补位) — pure logic of the 干员持有 tab of the 干员调配 overlay (screens/ownership.js),
|
||||
// shared with the sync (ui/loadoutSync.js) and the tests. The approved plan (owner's decision 2026-10-05): a player marks
|
||||
// operators as not owned ("下掉"); only the 55 NORMAL chess can be dropped (shared/standIn.js isDroppableChess — the 74
|
||||
// PRESET chess always field their own operator); default = everything owned; out of match (the next match takes it);
|
||||
// co-op: the player's own pieces only; no friend borrowing. A dropped chess keeps its card (name, bonds, price) and is
|
||||
// deployed as its official stand-in (data/backups.json).
|
||||
//
|
||||
// The per-browser setting is a sorted list of base chess ids, persisted in localStorage (`sp.pref.ownership` =
|
||||
// { v: 1, notOwned }) and sent with C2S `room.ownership { notOwned }` (the server keeps the droppable ids and drops the
|
||||
// rest — shared/protocol.js checkNotOwned — so a stale list never gets refused). 导出 / 导入 use a versioned envelope
|
||||
// like the loadout presets.
|
||||
|
||||
import { checkNotOwned, OWNERSHIP_LIMITS } from '../../../shared/protocol.js';
|
||||
import { isDroppableChess, standInRecord } from '../../../shared/standIn.js';
|
||||
|
||||
/** localStorage key (store.js loadPref/savePref prefix `sp.pref.`) and format version. */
|
||||
export const OWNERSHIP_PREF = 'ownership';
|
||||
export const OWNERSHIP_VERSION = 1;
|
||||
/** `kind` of an exported ownership envelope. */
|
||||
export const OWNERSHIP_EXPORT_KIND = 'stronghold.ownership';
|
||||
/** A picked file / pasted payload longer than this is refused before parsing (a real payload is under 4 KB). */
|
||||
export const OWNERSHIP_IMPORT_MAX_BYTES = 64 * 1024;
|
||||
|
||||
const ID_RE = /^[A-Za-z0-9_\-.:]{1,64}$/;
|
||||
const UNSAFE_IDS = new Set(['__proto__', 'constructor', 'prototype']);
|
||||
const isObj = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
||||
|
||||
/**
|
||||
* Structurally valid, deduplicated, sorted ids of a raw list (junk dropped; at most OWNERSHIP_LIMITS.notOwned).
|
||||
* @param {any} list
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export function cleanIds(list) {
|
||||
const out = new Set();
|
||||
for (const id of Array.isArray(list) ? list : []) {
|
||||
if (out.size >= OWNERSHIP_LIMITS.notOwned) break;
|
||||
if (typeof id === 'string' && ID_RE.test(id) && !UNSAFE_IDS.has(id)) out.add(id);
|
||||
}
|
||||
return [...out].sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a stored setting (any junk → []): `{ v, notOwned }` or a bare array from an older build.
|
||||
* @param {any} raw
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export function parseStoredOwnership(raw) {
|
||||
if (Array.isArray(raw)) return cleanIds(raw);
|
||||
return isObj(raw) ? cleanIds(raw.notOwned) : [];
|
||||
}
|
||||
|
||||
/** Serialised form for localStorage. */
|
||||
export const toStoredOwnership = (notOwned) => ({ v: OWNERSHIP_VERSION, notOwned: cleanIds(notOwned) });
|
||||
|
||||
/**
|
||||
* The ids to keep / send against the loaded data: droppable chess only (checkNotOwned), sorted.
|
||||
* @param {any} notOwned @param {(id: string) => any} getChess
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export function sanitizeNotOwned(notOwned, getChess) {
|
||||
const res = checkNotOwned(cleanIds(notOwned), getChess);
|
||||
return res && res.ok ? res.notOwned : [];
|
||||
}
|
||||
|
||||
/** Whether a chess is owned under a not-owned list (its base id is absent). */
|
||||
export function isOwned(notOwned, chessId) {
|
||||
return !(Array.isArray(notOwned) && notOwned.includes(chessId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle one chess: owned ↔ not owned. Returns a new sorted list (the input is not changed).
|
||||
* @param {string[]} notOwned @param {string} chessId base chess id
|
||||
* @param {boolean} [owned] the wanted state (default: the other one)
|
||||
*/
|
||||
export function setOwned(notOwned, chessId, owned = !isOwned(notOwned, chessId)) {
|
||||
const set = new Set(cleanIds(notOwned));
|
||||
if (owned) set.delete(chessId); else set.add(chessId);
|
||||
return [...set].sort();
|
||||
}
|
||||
|
||||
// ---- roster -------------------------------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The chess the screen lists: droppable chess in this season's shop (visible), in shop order (tier, shopSortId).
|
||||
* @param {any[]} list data.list('chess')
|
||||
*/
|
||||
export function ownershipRoster(list) {
|
||||
return (Array.isArray(list) ? list : [])
|
||||
.filter((c) => isDroppableChess(c) && c.visible !== false && !c.isHidden)
|
||||
.sort((a, b) => (a.tier ?? 0) - (b.tier ?? 0) || (a.shopSortId ?? 0) - (b.shopSortId ?? 0) || String(a.chessId).localeCompare(String(b.chessId)));
|
||||
}
|
||||
|
||||
/** The roster grouped by tier: [{ tier, list }] in tier order. */
|
||||
export function rosterByTier(roster) {
|
||||
const by = new Map();
|
||||
for (const c of roster) {
|
||||
if (!by.has(c.tier)) by.set(c.tier, []);
|
||||
by.get(c.tier).push(c);
|
||||
}
|
||||
return [...by.entries()].sort((a, b) => a[0] - b[0]).map(([tier, list]) => ({ tier, list }));
|
||||
}
|
||||
|
||||
/**
|
||||
* What the screen shows of a chess's stand-in: the composed record (shared/standIn.js — the stand-in at the chess's
|
||||
* normal status with its backup skill) reduced to the card's fields; null without the data.
|
||||
* @param {any} chess normal chess record @param {any} backups data/backups.json
|
||||
* @returns {{ charId: string, name: string, appellation: string, profession: string, subProfessionName: string,
|
||||
* rarity: number, skill: any, record: any } | null}
|
||||
*/
|
||||
export function standInSummary(chess, backups) {
|
||||
let rec;
|
||||
try { rec = standInRecord(chess, backups); } catch { rec = null; }
|
||||
if (!rec) return null;
|
||||
return {
|
||||
charId: rec.charId, name: rec.name, appellation: rec.appellation || '', profession: rec.profession,
|
||||
subProfessionName: rec.subProfessionName || '', rarity: rec.rarity, skill: rec.skill, record: rec,
|
||||
};
|
||||
}
|
||||
|
||||
/** Number of listed chess marked as not owned (ids of retired / unknown chess do not count). */
|
||||
export function notOwnedCount(notOwned, roster) {
|
||||
const ids = new Set(Array.isArray(notOwned) ? notOwned : []);
|
||||
let n = 0;
|
||||
for (const c of roster || []) if (ids.has(c.chessId)) n++;
|
||||
return n;
|
||||
}
|
||||
|
||||
// ---- export / import -------------------------------------------------------------------------------------------------
|
||||
|
||||
/** Portable payload of a not-owned list, as downloaded / copied by 导出. */
|
||||
export function exportOwnership(notOwned, { now = Date.now() } = {}) {
|
||||
const list = cleanIds(notOwned);
|
||||
return {
|
||||
kind: OWNERSHIP_EXPORT_KIND,
|
||||
v: OWNERSHIP_VERSION,
|
||||
exportedAt: new Date(Number.isFinite(now) ? now : Date.now()).toISOString(),
|
||||
count: list.length,
|
||||
notOwned: list,
|
||||
};
|
||||
}
|
||||
|
||||
/** Pretty JSON of `exportOwnership`. */
|
||||
export const serializeOwnership = (notOwned, opts) => JSON.stringify(exportOwnership(notOwned, opts), null, 2);
|
||||
|
||||
/**
|
||||
* Parse an imported list: the envelope, the stored `{ v, notOwned }` form, a bare array of ids, or the text of any of
|
||||
* them. STRUCTURAL only — the caller sanitises against the loaded data (sanitizeNotOwned). An envelope may hold an empty
|
||||
* list (= every operator owned).
|
||||
* @param {any} input payload or text
|
||||
* @returns {{ ok: true, notOwned: string[] } | { ok: false, error: string }}
|
||||
*/
|
||||
export function parseOwnershipImport(input) {
|
||||
let raw = input;
|
||||
if (typeof raw === 'string') {
|
||||
if (raw.length > OWNERSHIP_IMPORT_MAX_BYTES) return { ok: false, error: '内容过长,无法导入' };
|
||||
const text = raw.trim();
|
||||
if (!text) return { ok: false, error: '没有可导入的内容' };
|
||||
try { raw = JSON.parse(text); } catch { return { ok: false, error: '无法识别的内容' }; }
|
||||
}
|
||||
if (Array.isArray(raw)) return { ok: true, notOwned: cleanIds(raw) };
|
||||
if (!isObj(raw)) return { ok: false, error: '无法识别的格式' };
|
||||
const v = Number.isInteger(raw.v) ? raw.v : null;
|
||||
if (v != null && v > OWNERSHIP_VERSION) return { ok: false, error: `这份数据来自更新的版本(v${v}),请先更新游戏` };
|
||||
const kind = typeof raw.kind === 'string' ? raw.kind : null;
|
||||
if (kind && kind !== OWNERSHIP_EXPORT_KIND) return { ok: false, error: '这不是干员持有的数据' };
|
||||
if (!Array.isArray(raw.notOwned)) return { ok: false, error: '里面没有干员持有的数据' };
|
||||
return { ok: true, notOwned: cleanIds(raw.notOwned) };
|
||||
}
|
||||
+12
-7
@@ -17,12 +17,14 @@
|
||||
// item slot drawn as an item card (player report #6 after 0.1.0).
|
||||
// Every operator card shows the skill it will fight with — the player's 干员调配 loadout (m.private.loadout, DESIGN
|
||||
// §16): the skill icon above the name, mint-framed with 已调配 in its title when it is not the default skill (and the
|
||||
// module type of an elite card, with its official type icon when the local-client art has it).
|
||||
// module type of an elite card, with its official type icon when the local-client art has it). A chess the player does
|
||||
// not own (0.2.0 补位, m.private.standIns) keeps its card — name, portrait, bonds, price — with an ice 「替补:X」 badge,
|
||||
// and the skill icon is the stand-in's (its backup skill: the one that fights).
|
||||
|
||||
import { useEffect, useState } from '../../vendor/hooks.module.js';
|
||||
import { html, Icon, HexBadge, TierChip, Tooltip, MicroLabel } from './components.js';
|
||||
import { Img, BondGlyph, CoinGlyph, GIcon, RichText } from './gameComponents.js';
|
||||
import { priceTone, mergeProgress, mergeTarget, shopBlockReason, chessLoadout, offerHeader, briefingBondTip } from './gameLogic.js';
|
||||
import { priceTone, mergeProgress, mergeTarget, shopBlockReason, chessLoadout, offerHeader, briefingBondTip, fieldsStandIn, standInOf, standInLoadout, standInLabel } from './gameLogic.js';
|
||||
import { chessPortraitUrl, itemIconUrl, profIconUrl, uiUrl, skillIconUrl, skillRecordIconUrl, moduleTypeIconUrl } from './assetUrls.js';
|
||||
import { data } from '../data.js';
|
||||
|
||||
@@ -70,7 +72,9 @@ export function ChessCard({ slot, idx, priv, frozen = false, reason = null, free
|
||||
const willMerge = !!hint;
|
||||
const bonds = Array.isArray(c?.bonds) ? c.bonds : [];
|
||||
const disabled = !!reason;
|
||||
const lo = c ? chessLoadout(c, priv?.loadout, LOOKUPS.getChess) : null;
|
||||
// 0.2.0 补位: the player's not-owned chess is deployed as its stand-in — badge + the stand-in's skill
|
||||
const si = c && fieldsStandIn(priv, c) ? standInOf(c, data.get('backups')) : null;
|
||||
const lo = si ? standInLoadout(si, LOOKUPS.getChess, data.get('backups')) : c ? chessLoadout(c, priv?.loadout, LOOKUPS.getChess) : null;
|
||||
const tap = () => { if (onTap) onTap(idx); else if (!disabled) onBuy(idx); else onDetail(slot.id, 'chess', hint); };
|
||||
const card = html`<button type="button" class=${cx('scard', `scard--t${tier}`, frozen && 'is-frozen', disabled && 'is-disabled', willMerge && 'is-merge', armed && 'is-armed')}
|
||||
onClick=${tap} onContextMenu=${(e) => { e.preventDefault(); onDetail(slot.id, 'chess', hint); }}
|
||||
@@ -85,8 +89,9 @@ export function ChessCard({ slot, idx, priv, frozen = false, reason = null, free
|
||||
${Array.from({ length: prog.need }, (_, i) => html`<i key=${i} class=${i < prog.copies ? 'on' : ''}></i>`)}
|
||||
</span>` : null}
|
||||
</span>
|
||||
${si ? html`<span class="scard__standin" data-standin=${si.charId} title=${`未持有:由替补干员 ${si.name} 上场(盟约、阶级与价格不变)`}>${standInLabel(si)}</span>` : null}
|
||||
<span class="scard__body">
|
||||
${lo?.skill ? html`<${SkillBadge} chess=${c} lo=${lo} />` : null}
|
||||
${lo?.skill ? html`<${SkillBadge} chess=${si || c} lo=${lo} standIn=${!!si} />` : null}
|
||||
<span class="scard__name">${c?.name || slot.id}</span>
|
||||
<span class="scard__bonds">
|
||||
${bonds.slice(0, 3).map((b) => {
|
||||
@@ -108,14 +113,14 @@ export function ChessCard({ slot, idx, priv, frozen = false, reason = null, free
|
||||
}
|
||||
|
||||
/** The loadout's skill (icon; name + 已调配 in the tooltip) and an elite's module type, on an operator card. */
|
||||
function SkillBadge({ chess, lo }) {
|
||||
function SkillBadge({ chess, lo, standIn = false }) {
|
||||
const m = data.get('assets');
|
||||
const custom = !lo.defaultSkill;
|
||||
const custom = !lo.defaultSkill && !standIn;
|
||||
// a chosen skill without an icon in the manifest: its slot letter (S1–S3) instead of the blank skill sprite
|
||||
const src = custom ? skillRecordIconUrl(m, lo.skill, { empty: false }) : skillIconUrl(m, chess);
|
||||
const slot = Number.isInteger(lo.skill.index) ? `S${lo.skill.index + 1}` : null;
|
||||
const mod = lo.module && !lo.module.none ? lo.module : null;
|
||||
const tip = `技能${slot ? ` ${slot}` : ''}:${lo.skill.name || ''}${custom ? '(已调配)' : ''}${mod ? ` · 模组:${mod.name}` : lo.module?.none ? ' · 未装备模组' : ''}`;
|
||||
const tip = `${standIn ? `替补 ${chess.name} · ` : ''}技能${slot ? ` ${slot}` : ''}:${lo.skill.name || ''}${custom ? '(已调配)' : ''}${mod ? ` · 模组:${mod.name}` : lo.module?.none ? ' · 未装备模组' : ''}`;
|
||||
return html`<span class=${cx('scard__skill', custom && 'is-custom')} title=${tip} aria-label=${tip} data-skill=${lo.skill.skillId || ''}>
|
||||
<${Img} src=${src} fallback=${slot ? html`<span class="scard__sglyph num">${slot}</span>` : html`<${GIcon} name="bolt" />`} />
|
||||
${mod && mod.typeName ? html`<span class="scard__mod" data-type=${mod.typeName}><${Img} src=${moduleTypeIconUrl(data.get('local'), mod.typeName)} class="scard__modicon" />${mod.typeName}</span>` : null}
|
||||
|
||||
+34
-4
@@ -64,6 +64,13 @@
|
||||
// (or outside a room) it simply replaces the stored one; while the room's match runs it is also handed to
|
||||
// match.setLoadout(playerId, loadout), which accepts it only during INFO_CHECK (the 干员调配 entry of the briefing)
|
||||
// and refuses it afterwards (WRONG_PHASE: the match's loadout is locked, the stored one applies to the next match).
|
||||
// * Operator ownership (干员持有, 0.2.0 补位, owner's decision 2026-10-05): room.ownership { notOwned } — the base chess
|
||||
// ids the player marked as not owned — is checked leniently (shared/protocol.js checkNotOwned: anything that is not
|
||||
// a droppable NORMAL chess is dropped, never the whole list; only a malformed list is BAD_MSG) and stored on the
|
||||
// session and the seat like the loadout. The match receives seats[].notOwned when it starts (bots: none — they own
|
||||
// every operator) and keeps it for its whole length: the setting is out of match ("局外设置,下一局生效"), so while
|
||||
// the room's match runs a new list is only stored for the next match (ROOM_STARTED 'stored for the next match',
|
||||
// never handed to the match). A spectator's list stays on its session.
|
||||
// * Spectator seats (community report #26, owner's decision 2026-10-04 — a remake feature, the official room has none):
|
||||
// room.spectate { code } takes one of a co-op room's MAX_SPECTATORS (2) spectator seats, in its lobby or while its
|
||||
// match runs (▸ solo rooms: ROOM_FULL). A spectator is not a player: never in `seats`, never counted for the 1–4 players
|
||||
@@ -71,15 +78,15 @@
|
||||
// {empty} for its spectators). It receives room.state (`spectators: [{ playerId, name, connected }]`) and every match
|
||||
// broadcast (m.public, m.ticker, m.emote, b.pool — public data); the match registers it (opts.spectators /
|
||||
// addSpectator) and shows it fields like an eliminated player (b.start watch / m.field), never an m.private. It may
|
||||
// only g.watch (the heavy bucket, like every watcher), g.leave / room.leave, and room.loadout (stored for its session,
|
||||
// never handed to the match); anything else → SPECTATOR (▸ emotes too). Host: room.removeSpectator { playerId } any
|
||||
// only g.watch (the heavy bucket, like every watcher), g.leave / room.leave, and room.loadout / room.ownership (stored
|
||||
// for its session, never handed to the match); anything else → SPECTATOR (▸ emotes too). Host: room.removeSpectator { playerId } any
|
||||
// time → room.closed {kicked} to it. A spectator in a LOBBY room may take a free player seat with room.join of the same
|
||||
// code; a player never switches to spectating in place (ALREADY). Disconnect / grace / reconnect / expiry work as for
|
||||
// a player seat (the seat is kept and given back on resume).
|
||||
|
||||
import { randomBytes, randomInt } from 'node:crypto';
|
||||
import { ERR, MAX_SEATS, MAX_SPECTATORS, ROOM_CODE_LEN, modeIdFor } from '../shared/constants.js';
|
||||
import { checkLoadout } from '../shared/protocol.js';
|
||||
import { checkLoadout, checkNotOwned } from '../shared/protocol.js';
|
||||
import { encode, isDroppable, isErrCode, sendRaw, sendSession } from './net.js';
|
||||
import { getData as defaultGetData, lookup } from './data.js';
|
||||
import { Match as DefaultMatch } from './match/Match.js';
|
||||
@@ -109,7 +116,8 @@ const noopLog = { info() {}, warn() {}, error() {}, debug() {} };
|
||||
|
||||
/**
|
||||
* @typedef {{ seat: number, playerId: string, name: string, isBot: boolean, ready: boolean,
|
||||
* connected: boolean, left: boolean, loadout?: Record<string, { skill: number, module: string|null }> | null }} Seat
|
||||
* connected: boolean, left: boolean, loadout?: Record<string, { skill: number, module: string|null }> | null,
|
||||
* notOwned?: readonly string[] | null }} Seat
|
||||
*/
|
||||
|
||||
/** Deep-frozen copy of a checked loadout (shared by the session, the seat and the match's PlayerState). */
|
||||
@@ -289,6 +297,7 @@ export class Lobby {
|
||||
case 'room.kick': return this.kick(session, msg);
|
||||
case 'room.start': return this.start(session);
|
||||
case 'room.loadout': return this.loadout(session, msg);
|
||||
case 'room.ownership': return this.ownership(session, msg);
|
||||
case 'room.spectate': return this.spectate(session, msg);
|
||||
case 'room.removeSpectator': return this.removeSpectator(session, msg);
|
||||
default:
|
||||
@@ -575,6 +584,24 @@ export class Lobby {
|
||||
return OK;
|
||||
}
|
||||
|
||||
/**
|
||||
* room.ownership (0.2.0 补位): keep the droppable chess of the not-owned list, store it on the session and the seat
|
||||
* (see the header). A running match never takes it: it keeps the list its seat had at its start.
|
||||
*/
|
||||
ownership(session, { notOwned }) {
|
||||
const data = this.safeData();
|
||||
const res = checkNotOwned(notOwned, (id) => lookup('chess', id, data));
|
||||
if (!res || res.error) return fail(ERR.BAD_MSG, res && res.detail);
|
||||
const list = Object.freeze(res.notOwned.slice());
|
||||
session.notOwned = list;
|
||||
const room = this.roomOf(session);
|
||||
if (!room) return OK;
|
||||
const seat = room.seatOf(session.playerId);
|
||||
if (seat) seat.notOwned = list;
|
||||
if (room.match && seat) return fail(ERR.ROOM_STARTED, 'stored for the next match');
|
||||
return OK;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------
|
||||
// Match wiring
|
||||
// ---------------------------------------------------------------------------------------------------
|
||||
@@ -587,6 +614,8 @@ export class Lobby {
|
||||
seat: s.seat, playerId: s.playerId, name: s.name, isBot: s.isBot, connected: s.connected,
|
||||
// DESIGN §16: the human's checked operator loadout (bots fight with the defaults)
|
||||
loadout: s.isBot ? null : s.loadout || null,
|
||||
// 0.2.0 补位: the chess the human marked as not owned (bots own every operator)
|
||||
notOwned: s.isBot ? null : s.notOwned || null,
|
||||
}));
|
||||
// lastPublic / results: the latest m.public broadcast and the m.result frames (encoded), kept for the replay.
|
||||
const ctx = { live: true, ended: false, disposed: false, match: null, lastPublic: null, sharedResult: null, results: new Map() };
|
||||
@@ -845,6 +874,7 @@ export class Lobby {
|
||||
return {
|
||||
seat: idx, playerId: session.playerId, name: session.name, isBot: false, ready: false, connected: session.connected, left: false,
|
||||
loadout: session.loadout || null,
|
||||
notOwned: session.notOwned || null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -13,11 +13,14 @@
|
||||
// opts.difficulty 'FUNNY'|'NORMAL'|'HARD'|'ABYSS'
|
||||
// opts.modeId string modeIdFor(mode, difficulty), e.g. 'mode_multi_hard'
|
||||
// opts.seats Array<{ seat: 0..3, playerId: string, name: string, isBot: boolean, connected: boolean,
|
||||
// loadout?: { [baseChessId]: { skill: index, module: uniEquipId|'none'|null } } | null }>
|
||||
// loadout?: { [baseChessId]: { skill: index, module: uniEquipId|'none'|null } } | null,
|
||||
// notOwned?: string[] | null }>
|
||||
// sorted by seat, 1–4 entries, ≥ 1 human; solo ⇒ exactly 1 human and no bots.
|
||||
// Bot playerIds start with 'ai_'. Seat indexes may have gaps (e.g. seats 0 and 2).
|
||||
// `loadout` (DESIGN §16, optional): the human's operator loadout, already checked by the lobby
|
||||
// (shared/protocol.js checkLoadout); PlayerState re-checks it against opts.data and ignores it for bots.
|
||||
// `notOwned` (0.2.0 补位, optional): the base chess ids the human marked as not owned (干员持有) — fixed
|
||||
// for the match; those chess fight as their stand-ins (PlayerState setNotOwned; bots own everything).
|
||||
// opts.spectators string[] (optional) the room's spectator seats (remake feature, community report #26;
|
||||
// server/lobby.js): never players — see addSpectator below
|
||||
// opts.seed uint32 master seed for all match randomness
|
||||
|
||||
@@ -72,6 +72,15 @@
|
||||
// mismatch falls back to the defaults) and kept frozen; bots always use the defaults. Match.setLoadout may replace
|
||||
// it during INFO_CHECK only. battleInput() resolves every chess unit to `skillIndex` + `moduleId` (resolveLoadout:
|
||||
// normal chess → moduleId null, elite → uniEquipId | 'none'); m.private exposes `loadout`.
|
||||
// * Operator ownership (0.2.0 补位, the approved plan — owner's decision 2026-10-05): the human's `seat.notOwned` (base
|
||||
// chess ids marked 未持有 on the 干员持有 screen) is re-checked against this match's data (shared/protocol.js
|
||||
// checkNotOwned + a stand-in record in data/backups.json) and fixed for the match (`standIns`; bots own every
|
||||
// operator). A piece of such a chess — normal or elite, wherever it is — keeps the chess's identity (name, bonds,
|
||||
// 特质, tier, price, sell price, merge: every rule that reads gd.chess) and fights as its official stand-in
|
||||
// (`fieldRecord` = gd.standIn: body, stats, range, position, skills / talents / module — the backup selection, the
|
||||
// loadout never applies to it, "对于补位干员其技能不可更改"): battleInput marks it `standIn: true`, placeClass /
|
||||
// summon range / the bots' range read the stand-in record, it makes no summons (none of the 17 stand-ins has one),
|
||||
// and the scouting views draw it with the stand-in's art on the board. m.private exposes `standIns`.
|
||||
//
|
||||
// Code layout: this file keeps the constructor (the per-player state fields); the methods live in
|
||||
// server/match/player/, one module per concern, and are installed on PlayerState.prototype below in a fixed order,
|
||||
@@ -131,6 +140,14 @@ export class PlayerState {
|
||||
/** operator loadout (DESIGN §16): frozen { [baseChessId]: { skill, module } }, {} = every chess on its defaults */
|
||||
this.loadout = Object.freeze({});
|
||||
if (!this.isBot && seat.loadout) this.setLoadout(seat.loadout);
|
||||
/**
|
||||
* 补位 (0.2.0): the base chess ids this player fields as their stand-ins — the seat's not-owned list when the match
|
||||
* started, re-checked against this match's data; frozen, sorted; [] = every operator owned (bots always)
|
||||
*/
|
||||
this.standIns = Object.freeze([]);
|
||||
/** @type {Set<string>} lookup set of `standIns` */
|
||||
this._standInSet = new Set();
|
||||
if (!this.isBot && seat.notOwned) this.setNotOwned(seat.notOwned);
|
||||
this.shop = { level: 1, upgradePrice: this.gd.upgradeBase(1) ?? 0, slots: [], frozen: false, freeRefreshes: 0 };
|
||||
/** reward offers queue (merge rewards, special refreshes): { tier, source, label, slots: [{ kind, id, price, sold }] } */
|
||||
this.offers = [];
|
||||
|
||||
@@ -147,19 +147,23 @@ export function positionClass(rec, moduleId = null) {
|
||||
|
||||
/**
|
||||
* Placement class of a record under a player's loadout (the module `ps.loadoutFor` resolves, defaults included).
|
||||
* Tokens and records without a chess id ignore the loadout.
|
||||
* @param {{ loadoutFor?: (rec: object) => { moduleId?: string|null }|null }|null} ps
|
||||
* Tokens and records without a chess id ignore the loadout. A chess the player fields as its stand-in (0.2.0 补位,
|
||||
* `ps.fieldRecord`) is placed by the stand-in's position — the body that is deployed (圣约送葬人's 预备干员-先锋 stands on
|
||||
* the ground, 塞雷娅's Touch anywhere) [ASSUMED: the official deploy check reads the fielded character].
|
||||
* @param {{ loadoutFor?: (rec: object) => { moduleId?: string|null }|null, fieldRecord?: (rec: object) => object }|null} ps
|
||||
*/
|
||||
export function placeClass(ps, rec) {
|
||||
if (!rec || !rec.chessId || !ps || typeof ps.loadoutFor !== 'function') return positionClass(rec);
|
||||
let moduleId = null;
|
||||
let body = rec;
|
||||
try {
|
||||
const lo = ps.loadoutFor(rec);
|
||||
moduleId = lo && typeof lo.moduleId === 'string' ? lo.moduleId : null;
|
||||
if (typeof ps.fieldRecord === 'function') body = ps.fieldRecord(rec) || rec;
|
||||
} catch {
|
||||
moduleId = null;
|
||||
}
|
||||
return positionClass(rec, moduleId);
|
||||
return positionClass(body, moduleId);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+3
-1
@@ -818,7 +818,9 @@ export function rangeRec(ps, rec) {
|
||||
if (cache.has(key)) return cache.get(key);
|
||||
let out = rec;
|
||||
try {
|
||||
const g = attackRangeGrid(loadoutRecord(rec, resolveRecordLoadout(rec, lo)));
|
||||
// 0.2.0 补位: a human's not-owned chess (AI 托管) is deployed as its stand-in — that body's range
|
||||
const body = typeof ps.fieldRecord === 'function' ? ps.fieldRecord(rec) || rec : rec;
|
||||
const g = attackRangeGrid(loadoutRecord(body, resolveRecordLoadout(body, lo)));
|
||||
if (Array.isArray(g) && g.length && JSON.stringify(g) !== JSON.stringify(rec.rangeGrid)) out = Object.freeze({ ...rec, rangeGrid: g });
|
||||
} catch { out = rec; }
|
||||
cache.set(key, out);
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
|
||||
import { getConfig, getMode } from '../data.js';
|
||||
import { isShopItem } from '../sim/simdata.js';
|
||||
import { standInRecord } from '../../shared/standIn.js';
|
||||
|
||||
const own = (map, id) => (map && typeof map === 'object' && typeof id === 'string' && Object.hasOwn(map, id) && map[id] && typeof map[id] === 'object' ? map[id] : null);
|
||||
const numOr = (v, d) => (typeof v === 'number' && Number.isFinite(v) ? v : d);
|
||||
@@ -95,6 +96,8 @@ export class GameData {
|
||||
this.inactiveEnemies = new Set(Array.isArray(this.mode.inactiveEnemyKeys) ? this.mode.inactiveEnemyKeys : []);
|
||||
/** data/tuning.json (titles only, see the header) */
|
||||
this.tuning = this.raw.tuning && typeof this.raw.tuning === 'object' ? this.raw.tuning : {};
|
||||
/** standIn memo: chess id → composed 补位 record | null */
|
||||
this._standIns = new Map();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -175,6 +178,26 @@ export class GameData {
|
||||
get choices() { return this.raw.choices && typeof this.raw.choices === 'object' ? this.raw.choices : {}; }
|
||||
get factions() { return this.raw.factions && typeof this.raw.factions === 'object' ? this.raw.factions : {}; }
|
||||
|
||||
/**
|
||||
* The 补位 record of chess `id` (normal or elite; DATA.md §18): shared/standIn.js standInRecord over data/backups.json
|
||||
* — the chess's identity (ids, tier, bonds, 特质, price, merge) with its official stand-in's body (stats, range, skills
|
||||
* with the backup skill as the default, talents, module, art, `standInFor`). Null for a PRESET / 自选 chess, an unknown
|
||||
* id or data without backups.json. Memoized (frozen records).
|
||||
* @param {string} id
|
||||
* @returns {object|null}
|
||||
*/
|
||||
standIn(id) {
|
||||
if (typeof id !== 'string') return null;
|
||||
if (this._standIns.has(id)) return this._standIns.get(id);
|
||||
const c = this.chess(id);
|
||||
const backups = this.raw.backups && typeof this.raw.backups === 'object' ? this.raw.backups : null;
|
||||
let rec;
|
||||
try { rec = c && backups ? standInRecord(c, backups) : null; } catch { rec = null; }
|
||||
if (rec) Object.freeze(rec);
|
||||
this._standIns.set(id, rec);
|
||||
return rec;
|
||||
}
|
||||
|
||||
/** Normal (base) chess id of a chess id (golden → base). */
|
||||
baseIdOf(id) {
|
||||
const c = this.chess(id);
|
||||
|
||||
@@ -187,11 +187,14 @@ export class MatchViews {
|
||||
prepFieldMeta(ps) {
|
||||
const units = [];
|
||||
for (const { r, c, piece } of boardOrder(ps.board)) {
|
||||
const rec = piece.kind === 'token' ? this.gd.token(piece.id) : this.gd.chess(piece.id);
|
||||
const chess = piece.kind === 'token' ? null : this.gd.chess(piece.id);
|
||||
// 0.2.0 补位: a chess this player fields as its stand-in is deployed with the stand-in's body — name, art, max HP,
|
||||
// skill, like the sim's UnitInfo (`standInFor` = the replaced operator's charId)
|
||||
const rec = piece.kind === 'token' ? this.gd.token(piece.id) : ps.fieldRecord(chess);
|
||||
const assets = (rec && rec.assets) || {};
|
||||
// DESIGN §16: the skill / module THIS player's operator fights with (the scout's detail card shows it, like the
|
||||
// sim's UnitInfo in a shared field); moduleId only for an elite
|
||||
const lo = piece.kind === 'chess' && rec ? ps.loadoutFor(rec) : null;
|
||||
const lo = piece.kind === 'chess' && chess ? ps.loadoutFor(chess) : null;
|
||||
units.push({
|
||||
id: piece.uid, uid: piece.uid, kind: piece.kind === 'token' ? 'token' : 'op', side: 'ally', ownerId: ps.playerId, defId: piece.id,
|
||||
name: rec ? rec.name : piece.id, tier: rec && Number.isInteger(rec.tier) ? rec.tier : 1, golden: !!(rec && rec.isGolden),
|
||||
@@ -201,6 +204,7 @@ export class MatchViews {
|
||||
moduleId: lo && typeof lo.moduleId === 'string' ? lo.moduleId : undefined,
|
||||
// the equipped items (like the sim's UnitInfo): a 变形同构体 wearer shows as a member of the bond it grants
|
||||
items: piece.kind === 'chess' && Array.isArray(piece.items) && piece.items.length ? piece.items.map((it) => it.id) : undefined,
|
||||
standInFor: rec && rec.standInFor ? rec.standInFor : undefined,
|
||||
});
|
||||
}
|
||||
// the hand (整备区) and the 临时整备区 scout exactly like the own prep bench renders them: pieces as units on
|
||||
@@ -208,10 +212,13 @@ export class MatchViews {
|
||||
// items included (the client draws their floating plates). PRTS 帮助 counts the temp area with the hand (review of
|
||||
// PR #129). Part of the meta for every watcher alike — the spectator seat's copy equals a teammate's
|
||||
// (test/match/spectator.test.js). User playtest #2 item 1 (GitHub #44).
|
||||
// (a held chess the player fields as its stand-in keeps the chess's own art — the hand shows the original operator,
|
||||
// the approved 0.2.0 补位 plan — and carries `standInFor`: its card shows the stand-in it deploys as)
|
||||
const benchUnit = (piece, i, y) => {
|
||||
const rec = piece.kind === 'item' ? this.gd.item(piece.id) : piece.kind === 'token' ? this.gd.token(piece.id) : this.gd.chess(piece.id);
|
||||
const assets = (rec && rec.assets) || {};
|
||||
const lo = piece.kind === 'chess' && rec ? ps.loadoutFor(rec) : null;
|
||||
const standIn = piece.kind === 'chess' && rec && ps.fieldsStandIn(rec) ? this.gd.standIn(rec.chessId) : null;
|
||||
units.push({
|
||||
id: piece.uid, uid: piece.uid, kind: piece.kind === 'token' ? 'token' : piece.kind === 'item' ? 'item' : 'op',
|
||||
side: 'ally', ownerId: ps.playerId, defId: piece.id,
|
||||
@@ -221,6 +228,7 @@ export class MatchViews {
|
||||
skillIndex: lo && Number.isInteger(lo.skillIndex) ? lo.skillIndex : undefined,
|
||||
moduleId: lo && typeof lo.moduleId === 'string' ? lo.moduleId : undefined,
|
||||
items: piece.kind === 'chess' && Array.isArray(piece.items) && piece.items.length ? piece.items.map((it) => it.id) : undefined,
|
||||
standInFor: standIn && standIn.standInFor ? standIn.standInFor : undefined,
|
||||
});
|
||||
};
|
||||
for (let i = 0; i < ps.hand.length; i++) {
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
// server/match/player/basics.js — PlayerState methods: the basics — seat flags and counts (isHumanActive,
|
||||
// botControlled, deployCap, deployCount, tempEmpty), the prep that resolves a temp piece (tempDue, _putTemp: every
|
||||
// write into a temp slot), the operator loadout (DESIGN §16: setLoadout, loadoutFor), the deploy map on the field the
|
||||
// write into a temp slot), the operator loadout (DESIGN §16: setLoadout, loadoutFor), the not-owned operators fielded
|
||||
// as their stand-ins (0.2.0 补位: setNotOwned, fieldsStandIn, fieldRecord), the deploy map on the field the
|
||||
// player deploys on (Match.deployFieldOf) and the withdrawal of pieces a terrain / deploy-field change left on tiles
|
||||
// they may no longer occupy (_evictIllegal), dirty.
|
||||
// Installed on PlayerState.prototype by server/match/PlayerState.js (a method container: never instantiated; `this` is
|
||||
// the player state).
|
||||
|
||||
import { PHASE } from '../../../shared/constants.js';
|
||||
import { checkLoadout, resolveLoadout } from '../../../shared/protocol.js';
|
||||
import { checkLoadout, checkNotOwned, resolveLoadout } from '../../../shared/protocol.js';
|
||||
import { tileKey, boardOrder } from '../board.js';
|
||||
|
||||
export class PlayerBasics {
|
||||
@@ -73,11 +74,59 @@ export class PlayerBasics {
|
||||
return true;
|
||||
}
|
||||
|
||||
/** The skill index / module a chess record fights with under this player's loadout (DESIGN §16). */
|
||||
/**
|
||||
* The skill index / module a chess record fights with under this player's loadout (DESIGN §16) — for a chess this
|
||||
* player fields as its stand-in (0.2.0 补位) the stand-in's backup selection, whatever the loadout says.
|
||||
*/
|
||||
loadoutFor(chessRecord) {
|
||||
if (this.fieldsStandIn(chessRecord)) {
|
||||
return resolveLoadout(null, this.gd.standIn(chessRecord.chessId), (id) => this.gd.standIn(id) || this.gd.chess(id));
|
||||
}
|
||||
return resolveLoadout(this.loadout, chessRecord, (id) => this.gd.chess(id));
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the not-owned list (0.2.0 补位; the seat's list at the match start — the setting never changes during a
|
||||
* match): keeps the droppable chess (checkNotOwned) whose stand-in this match's data has. Bots own every operator.
|
||||
* @param {any} list base chess ids
|
||||
* @returns {boolean} false when the list is malformed (nothing changes) or the player is a bot
|
||||
*/
|
||||
setNotOwned(list) {
|
||||
if (this.isBot) return false;
|
||||
const res = checkNotOwned(list, (id) => this.gd.chess(id));
|
||||
if (!res || !res.ok) {
|
||||
this.m.log?.warn?.(`[match ${this.m.roomCode}] not-owned list of ${this.playerId} ignored: ${res && res.detail}`);
|
||||
return false;
|
||||
}
|
||||
const ids = res.notOwned.filter((id) => !!this.gd.standIn(id));
|
||||
this.standIns = Object.freeze(ids);
|
||||
this._standInSet = new Set(ids);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this player's piece of chess record (or id) `rec` fights as its stand-in (0.2.0 补位): its base chess is
|
||||
* in `standIns` and the data has the stand-in. Normal and elite alike (the elite uses the same backup).
|
||||
* @param {object|string|null} rec
|
||||
*/
|
||||
fieldsStandIn(rec) {
|
||||
if (!this._standInSet || this._standInSet.size === 0 || !rec) return false;
|
||||
const r = typeof rec === 'string' ? this.gd.chess(rec) : rec;
|
||||
if (!r || typeof r.chessId !== 'string') return false;
|
||||
return this._standInSet.has(r.baseId || r.chessId) && !!this.gd.standIn(r.chessId);
|
||||
}
|
||||
|
||||
/**
|
||||
* The record this player's piece of chess record `rec` fights with (0.2.0 补位): the stand-in record
|
||||
* (gd.standIn — the chess's identity, the stand-in's body) when the player fields its stand-in, else `rec` itself.
|
||||
* Rules about the unit's body read it (placement class, summon / bot ranges, the scouting art); rules about the chess
|
||||
* (price, bonds, 特质, merges, pools) keep reading gd.chess.
|
||||
* @param {object|null} rec
|
||||
*/
|
||||
fieldRecord(rec) {
|
||||
return rec && this.fieldsStandIn(rec) ? this.gd.standIn(rec.chessId) || rec : rec;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deploy classes of the board tiles (server/match/board.js buildDeployMap) on the field the player deploys on now
|
||||
* (Match.deployFieldOf: the own board, or its half of the boss field in a boss round — user playtest #5 item 7).
|
||||
|
||||
@@ -181,6 +181,8 @@ export class PlayerPieces {
|
||||
grantTokensFor(owner) {
|
||||
const rec = owner && owner.kind === 'chess' ? this.gd.chess(owner.id) : null;
|
||||
if (!rec) return;
|
||||
// 0.2.0 补位: a chess fielded as its stand-in makes the stand-in's summons — none of the 17 has one (DATA.md §18)
|
||||
if (this.fieldsStandIn(rec)) return;
|
||||
for (const { tokenId, count } of this.gd.placeableTokens(owner.id, this.loadoutFor(rec))) {
|
||||
const missing = count - this._tokenCountOf(owner.uid, tokenId);
|
||||
if (missing <= 0) continue;
|
||||
|
||||
@@ -66,9 +66,10 @@ export class PlayerPlacement {
|
||||
if (!piece || piece.kind !== 'token' || this.gd.token(piece.id)?.ownerRange !== true) return null;
|
||||
let at = owner;
|
||||
if (!at) for (const [key, p] of this.board) if (p.uid === piece.ownerUid && p.kind === 'chess') { at = { key, piece: p, dir: pieceDir(p) }; break; }
|
||||
const rec = at && this.gd.chess(at.piece.id);
|
||||
if (!rec) return null;
|
||||
const grid = attackRangeGrid(loadoutRecord(rec, resolveRecordLoadout(rec, this.loadoutFor(rec)))) || rec.rangeGrid;
|
||||
const chess = at && this.gd.chess(at.piece.id);
|
||||
if (!chess) return null;
|
||||
const rec = this.fieldRecord(chess); // 0.2.0 补位: a stand-in's own range (its backup selection)
|
||||
const grid = attackRangeGrid(loadoutRecord(rec, resolveRecordLoadout(rec, this.loadoutFor(chess)))) || rec.rangeGrid;
|
||||
const [r, c] = parseKey(at.key);
|
||||
return ownerRangeKeys(grid, r, c, at.dir);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// server/match/player/round.js — PlayerState methods: the round lifecycle the match calls — startRound (income, pending
|
||||
// coins, the shop, summon stacks topped up), endPrep, eliminate (every copy back to the pool), recompute (legality,
|
||||
// out-of-range summons, temp pieces into free hand slots, bonds), the bond views — and battleInput (the player's
|
||||
// PlayerBattleInput: board units with their loadout, carried 联防 state, the reached layers).
|
||||
// PlayerBattleInput: board units with their loadout or their 补位 mark, carried 联防 state, the reached layers).
|
||||
// Installed on PlayerState.prototype by server/match/PlayerState.js (a method container: never instantiated; `this` is
|
||||
// the player state).
|
||||
|
||||
@@ -102,10 +102,16 @@ export class PlayerRound {
|
||||
for (const { r, c, piece } of boardOrder(this.board)) {
|
||||
if (piece.kind === 'chess') {
|
||||
const u = { uid: piece.uid, kind: 'chess', chessId: piece.id, row: r, col: c, dir: pieceDir(piece), items: (piece.items || []).map((i) => i.id) };
|
||||
// DESIGN §16: the equipped skill / module (elite only) from the loadout (defaults when absent)
|
||||
const lo = this.loadoutFor(this.gd.chess(piece.id));
|
||||
u.skillIndex = lo.skillIndex;
|
||||
u.moduleId = lo.moduleId;
|
||||
if (this.fieldsStandIn(piece.id)) {
|
||||
// 0.2.0 补位: a chess this player does not own fights as its stand-in (sim getChess(id, { standIn: true }): the
|
||||
// backup skill / module — no loadout fields, docs/SIM.md §12)
|
||||
u.standIn = true;
|
||||
} else {
|
||||
// DESIGN §16: the equipped skill / module (elite only) from the loadout (defaults when absent)
|
||||
const lo = this.loadoutFor(this.gd.chess(piece.id));
|
||||
u.skillIndex = lo.skillIndex;
|
||||
u.moduleId = lo.moduleId;
|
||||
}
|
||||
if (carry && carry.has(piece.uid)) u.carryState = carry.get(piece.uid);
|
||||
units.push(u);
|
||||
} else if (piece.kind === 'token') {
|
||||
|
||||
@@ -88,6 +88,9 @@ export class PlayerViews {
|
||||
nextEnemies: this.m.nextEnemiesFor(this),
|
||||
// DESIGN §16: the effective operator loadout ({ [baseChessId]: { skill, module } }; chess not listed use defaults)
|
||||
loadout: this.loadout,
|
||||
// 0.2.0 补位: the base chess ids this player fields as their stand-ins in this match (the not-owned list the seat had
|
||||
// at the match start; [] = every operator owned) — the client's 「替补:X」 badges, the stand-in's art and card
|
||||
standIns: this.standIns,
|
||||
stats: {
|
||||
dmgDealt: Math.round(this.stats.dmgDealt), kills: this.stats.kills, leaks: this.stats.leaks, gold: this.stats.gold,
|
||||
refreshes: this.stats.refreshes, merges: this.stats.merges,
|
||||
|
||||
+5
-2
@@ -60,10 +60,11 @@ export const NET_DEFAULTS = Object.freeze({
|
||||
|
||||
/**
|
||||
* Intents that also draw from the per-connection heavy bucket: g.watch (its reply is a large state resend, m.field),
|
||||
* room.loadout (a ≤ 160-entry map validated against the game data; the client debounces its edits) and room.spectate
|
||||
* room.loadout (a ≤ 160-entry map validated against the game data; the client debounces its edits), room.ownership
|
||||
* (a ≤ 160-id list, the same way) and room.spectate
|
||||
* (taking a spectator seat in a running match resends its state like a watcher's g.watch — server/lobby.js spectate).
|
||||
*/
|
||||
export const HEAVY_TYPES = new Set(['g.watch', 'room.loadout', 'room.spectate']);
|
||||
export const HEAVY_TYPES = new Set(['g.watch', 'room.loadout', 'room.ownership', 'room.spectate']);
|
||||
|
||||
/** Close codes (see header). */
|
||||
export const CLOSE = Object.freeze({ REPLACED: 4001, HELLO_TIMEOUT: 4002, POLICY: 1008, SHUTDOWN: 1001 });
|
||||
@@ -106,6 +107,8 @@ export class Session {
|
||||
this.resyncAt = -Infinity;
|
||||
/** @type {Record<string, { skill: number, module: string|null }> | null} checked operator loadout (lobby-owned, DESIGN §16) */
|
||||
this.loadout = null;
|
||||
/** @type {readonly string[] | null} checked not-owned chess ids (干员持有, lobby-owned, 0.2.0 补位) */
|
||||
this.notOwned = null;
|
||||
/** @type {string} client address of the latest connection (logging) */
|
||||
this.addr = '?';
|
||||
/** @type {string | null} per-network limit key of the latest connection (null = not limited), see clientAddress */
|
||||
|
||||
@@ -104,8 +104,11 @@ export default {
|
||||
A NORMAL chess whose operator the player does not own fights as its official stand-in (原型干员, data/backups.json,
|
||||
docs/DATA.md §18): the chess's ids, bonds, 特质, tier and price, the stand-in's body — stats, range, trait, talents, the
|
||||
skill `backup.skillIndex` and the module `backup.uniEquipId` the chess names. The sim gets that def from
|
||||
`battle.data.getChess(chessId, { standIn: true })` (a PlayerBattleInput entry with `standIn: true`); `def.charId` is the
|
||||
stand-in's, `def.standInFor` the replaced operator's charId.
|
||||
`battle.data.getChess(chessId, { standIn: true })` (a PlayerBattleInput entry with `standIn: true` — in a match, the
|
||||
player's chess marked 未持有 on the 干员持有 tab, `PlayerState.battleInput`); `def.charId` is the stand-in's,
|
||||
`def.standInFor` the replaced operator's charId. The golden `standins` family (tools/golden.mjs) fields every NORMAL
|
||||
chess record as its stand-in, so a kit change that alters a battle shows there (test/golden-standins.test.js checks that
|
||||
every active backup skill is cast).
|
||||
|
||||
- **File and key**: `ops/standin-<codename>.js`, the code name being the stand-in's charId without `char_<n>_`
|
||||
(`standin-acguad.js` = Sharp, `char_609_acguad`); the default export has exactly one key, that charId:
|
||||
@@ -161,8 +164,9 @@ stand-in's, `def.standInFor` the replaced operator's charId.
|
||||
- Assert that the unit is the stand-in (`u.def.charId`, `u.def.standInFor`, `u.skill.id`) and that its kit is yours, not
|
||||
the generic one (`!u.kit.generic`); then the checklist below as for any operator. Examples:
|
||||
`test/content/standin.test.js` (the plumbing and the eight 预备干员).
|
||||
- A stand-in kit is new gameplay for stand-ins only: the golden corpus fields no stand-in, so its digests stay as they
|
||||
are.
|
||||
- A stand-in kit is gameplay for stand-ins only: of the golden corpus only the `standins` family (and the 补位 match
|
||||
`coop2-NORMAL-14-standins`) fields stand-ins, so a kit change moves those digests and no other — `npm run
|
||||
golden:update`, then review that the change is confined to the battles that field the stand-in.
|
||||
|
||||
## The fidelity rule
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// Every client→server message is `{ t, rid?, ...fields }`. Unknown `t` or invalid fields ⇒ ERR.BAD_MSG.
|
||||
|
||||
import { DIFFICULTIES, NAME_MAX_LEN, ROOM_CODE_LEN, MAX_SEATS, EMOTES, GEO } from './constants.js';
|
||||
import { isDroppableChess } from './standIn.js';
|
||||
|
||||
// ---- tiny validators -------------------------------------------------------
|
||||
const isInt = (v, lo = -Infinity, hi = Infinity) => Number.isInteger(v) && v >= lo && v <= hi;
|
||||
@@ -171,6 +172,38 @@ export function resolveLoadout(loadout, chess, getChess) {
|
||||
return { skillIndex, moduleId };
|
||||
}
|
||||
|
||||
// ---- operator ownership (干员持有, 0.2.0 补位): room.ownership { notOwned } -------------------------------------------
|
||||
|
||||
/**
|
||||
* `room.ownership { notOwned }`: the base chess ids the player marked as not owned on the 干员持有 screen (the
|
||||
* per-browser setting next to 干员调配; default: every operator owned ⇒ []). Such a chess keeps its identity (name,
|
||||
* bonds, 特质, tier, price, merge) and fights as its official stand-in (shared/standIn.js standInRecord). Structural
|
||||
* limit below; the semantic check (`checkNotOwned`) is LENIENT, unlike checkLoadout: an id that is not a droppable chess
|
||||
* (unknown, elite, PRESET / 自选, a stale id of another build) is dropped, never the whole list.
|
||||
*/
|
||||
export const OWNERSHIP_LIMITS = Object.freeze({ notOwned: 160 });
|
||||
/** Structural check of `room.ownership.notOwned`: an array of ≤ 160 ids. */
|
||||
export const isNotOwnedList = (v) => isList(v, OWNERSHIP_LIMITS.notOwned, isId);
|
||||
|
||||
/**
|
||||
* Semantic check + normalisation of a not-owned list against the game data: keeps the ids of droppable chess
|
||||
* (shared/standIn.js isDroppableChess — NORMAL base chess with a stand-in), deduplicated and sorted; drops the rest.
|
||||
* Used by the server (lobby, match) and by the client before it sends or imports a list.
|
||||
* @param {any} list `room.ownership.notOwned`
|
||||
* @param {(id: string) => any} getChess chess record lookup
|
||||
* @returns {{ ok: true, notOwned: string[], dropped: number } | { error: 'BAD_MSG', detail: string }}
|
||||
*/
|
||||
export function checkNotOwned(list, getChess) {
|
||||
if (!isNotOwnedList(list)) return { error: 'BAD_MSG', detail: 'bad notOwned list' };
|
||||
const keep = new Set();
|
||||
for (const id of list) {
|
||||
const c = typeof getChess === 'function' ? getChess(id) : null;
|
||||
if (c && c.chessId === id && isDroppableChess(c)) keep.add(id);
|
||||
}
|
||||
const notOwned = [...keep].sort();
|
||||
return { ok: true, notOwned, dropped: list.length - notOwned.length };
|
||||
}
|
||||
|
||||
// ---- unit stats (user playtest #4 item 7): m.unitStats units and the browser battle's live stats ---------------------
|
||||
|
||||
const fin = (v, d = 0) => (typeof v === 'number' && Number.isFinite(v) ? v : d);
|
||||
@@ -253,6 +286,9 @@ export const C2S = {
|
||||
'room.start': {},
|
||||
// operator loadout (DESIGN §16): stored per session/seat; accepted until the match leaves INFO_CHECK
|
||||
'room.loadout': { entries: isLoadoutEntries },
|
||||
// operator ownership (干员持有, 0.2.0 补位): stored per session / seat; a match takes the list its seat had when it
|
||||
// started (an out-of-match setting — during a match it is stored for the next one: ROOM_STARTED)
|
||||
'room.ownership': { notOwned: isNotOwnedList },
|
||||
// spectator seats (remake feature, community report #26; MAX_SPECTATORS): take one of a co-op room's spectator seats —
|
||||
// in its lobby or while its match runs — never a player seat; the host frees one by playerId (the spectator gets
|
||||
// room.closed { reason: 'kicked' }). room.leave / g.leave leave a spectator seat like a player seat.
|
||||
|
||||
@@ -101,6 +101,20 @@ export function composeUnitRecord(identity, unit, form, { skillIndex, moduleId =
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a player may mark chess record `chess` as not owned (干员持有 → 下掉, the approved 0.2.0 补位 plan, owner's
|
||||
* decision 2026-10-05): a NORMAL base chess (not the elite, not a 自选 slot) whose official stand-in is another
|
||||
* character — the 55 chess of DATA.md §18 (53 of them in this season's shop; the hidden 百炼嘉维尔 t5 / 妮芙 t6 forms are
|
||||
* retired). A PRESET (特许) chess always fields its own operator ("预设棋子…无论是否持有都由本人上场").
|
||||
* @param {any} chess data/chess.json record
|
||||
*/
|
||||
export function isDroppableChess(chess) {
|
||||
const b = chess && typeof chess === 'object' ? chess.backup : null;
|
||||
return !!(b && !chess.isGolden && !chess.isDiy && chess.chessType === 'NORMAL'
|
||||
&& (!chess.baseId || chess.baseId === chess.chessId)
|
||||
&& typeof b.charId === 'string' && b.charId && b.charId !== chess.charId);
|
||||
}
|
||||
|
||||
/**
|
||||
* The 补位 record of a chess: a NORMAL chess (normal or elite) fielded as its official stand-in — `backup.charId` at
|
||||
* the chess's own status, skill `backup.skillIndex`, module `backup.uniEquipId` (none when null), potential 0 — with
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
// The 补位 golden corpus covers what it claims (test/golden/README.md, tools/golden.mjs — 0.2.0): reads the stored
|
||||
// digests only (test/golden.test.js recomputes them). The `standins` family fields every NORMAL chess record (normal and
|
||||
// elite) as its stand-in — the battle unit's def is the chess id, its loadout the backup selection — so all 17 stand-ins
|
||||
// run; every active skill a chess names for its stand-in is cast in some battle (the ON_DEPLOY passives of 预备干员-特种
|
||||
// and Misery count no cast: their units deploy); no battle reports a content error. The 补位 match fields stand-ins.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
const read = (p) => JSON.parse(readFileSync(new URL(p, import.meta.url), 'utf8'));
|
||||
const CHESS = read('../data/chess.json');
|
||||
const BACKUPS = read('../data/backups.json');
|
||||
const STANDINS = read('./golden/standins.json');
|
||||
const MATCHES = read('./golden/matches.json');
|
||||
const COL = Object.fromEntries(STANDINS.columns.units.map((k, i) => [k, i]));
|
||||
|
||||
const normal = Object.values(CHESS).filter((c) => c.chessType === 'NORMAL' && c.backup && c.backup.charId !== c.charId);
|
||||
|
||||
test('standins: every NORMAL chess record (normal + elite) is fielded once, with its backup skill, without content errors', () => {
|
||||
assert.equal(normal.length, 110);
|
||||
const seen = new Map();
|
||||
for (const [id, sc] of Object.entries(STANDINS.scenarios)) {
|
||||
assert.equal(sc.errors.count, 0, `${id}: ${sc.errors.kinds.join('; ')}`);
|
||||
for (const u of sc.units) if (u[COL.kind] === 'op') seen.set(u[COL.def], u);
|
||||
}
|
||||
assert.deepEqual([...seen.keys()].sort(), normal.map((c) => c.chessId).sort());
|
||||
for (const c of normal) {
|
||||
const u = seen.get(c.chessId);
|
||||
assert.equal(u[COL.skill], c.backup.skillIndex, `${c.chessId}: the backup skill`);
|
||||
assert.ok(u[COL.deploys] >= 1, `${c.chessId}: deployed`);
|
||||
}
|
||||
assert.equal(new Set(normal.map((c) => c.backup.charId)).size, 17, 'all 17 stand-ins');
|
||||
});
|
||||
|
||||
test('standins: every active skill a chess names for its stand-in is cast; passive ones deploy', () => {
|
||||
const cast = new Map();
|
||||
for (const sc of Object.values(STANDINS.scenarios)) {
|
||||
for (const u of sc.units) {
|
||||
if (u[COL.kind] !== 'op') continue;
|
||||
const c = CHESS[u[COL.def]];
|
||||
const k = `${c.backup.charId}|${u[COL.skill]}`;
|
||||
cast.set(k, (cast.get(k) || 0) + (u[COL.casts] || 0));
|
||||
}
|
||||
}
|
||||
for (const c of normal) {
|
||||
const k = `${c.backup.charId}|${c.backup.skillIndex}`;
|
||||
const form = Object.values(BACKUPS.units[c.backup.charId].forms)[0];
|
||||
const sk = form.skills.find((s) => s.index === c.backup.skillIndex);
|
||||
if (sk.skillType === 'PASSIVE' || sk.spType === 'ON_DEPLOY') continue;
|
||||
assert.ok(cast.get(k) > 0, `${c.backup.charId} S${c.backup.skillIndex + 1} (${sk.name}) never cast`);
|
||||
}
|
||||
});
|
||||
|
||||
test('matches: the 补位 match\'s human seat fields stand-ins (listed per round) and the match ends without errors', () => {
|
||||
const dg = MATCHES.scenarios['coop2-NORMAL-14-standins'];
|
||||
assert.ok(dg, 'scenario present');
|
||||
assert.deepEqual(dg.errors, { engine: 0, logged: 0, sim: 0, dispatcher: 0 });
|
||||
const fielded = dg.standIns.rounds.filter(([, s]) => s).flatMap(([, s]) => s.split(' '));
|
||||
assert.ok(new Set(fielded).size >= 3, `stand-ins fielded: ${[...new Set(fielded)].join(', ')}`);
|
||||
for (const f of fielded) {
|
||||
const [id, charId] = f.split('→');
|
||||
const c = CHESS[`chess_char_${id}`];
|
||||
assert.ok(dg.standIns.notOwned.includes(c.baseId || c.chessId), `${f}: not owned`);
|
||||
assert.equal(charId, c.backup.charId);
|
||||
}
|
||||
});
|
||||
@@ -7,7 +7,7 @@ through.
|
||||
|
||||
- `tools/golden.mjs` builds the corpus from `data/*.json` (fixed order, fixed seeds) and reduces every scenario to a
|
||||
digest. Its header documents the families and the digest fields.
|
||||
- `test/golden/<family>.json` hold the digests (`roster`, `bonds`, `fields`, `matches`).
|
||||
- `test/golden/<family>.json` hold the digests (`roster`, `bonds`, `fields`, `matches`, `standins`).
|
||||
- `test/golden.test.js` recomputes them and compares. On a mismatch it prints the scenario, the field and old → new.
|
||||
|
||||
## The corpus
|
||||
@@ -17,7 +17,8 @@ through.
|
||||
| `roster` | 49 battles: every visible chess record (normal and elite) with every selectable skill and module (DESIGN §16), 12 operators per battle on a real stage (all 11 in turn) against the round's real wave three times over, every non-leader enemy kind of `data/enemies.json` as extra spawns (half of them bounties), placeable summons on the board, every equipment item, band, battle-side 机变 card and stage map card in turn, bonds from the board |
|
||||
| `bonds` | 46 battles: every bond at its activation threshold (1 layer) and at its top tier (999 layers) |
|
||||
| `fields` | 22 battles: every Final Assault / Hidden Core leader on a pair and a solo template (shared pool, 200 s cap) and the 联防 field with 1 and 2 helpers on its own map (carried HP / SP, a knocked-out operator, two leakers' enemies) |
|
||||
| `matches` | 16 bot-only matches to the end in virtual time: solo 标准 / 险境 / 绝境 / 终极 × 2 seeds, co-op 2 / 3 / 4, one server-run combat match, two runs boosted to the Hidden Core |
|
||||
| `matches` | 17 matches to the end in virtual time: 16 bot-only (solo 标准 / 险境 / 绝境 / 终极 × 2 seeds, co-op 2 / 3 / 4, one server-run combat match, two runs boosted to the Hidden Core) and one co-op match whose human seat (AI 托管, offline) does not own 7 NORMAL chess — they fight as their 补位 stand-ins (its digest lists them per round, `standIns`) |
|
||||
| `standins` | 10 battles: every NORMAL chess record (normal and elite, 110) fielded as its 补位 stand-in (`standIn: true`: the stand-in's body, backup skill / module and kit — all 17 stand-ins, every skill a chess names for them), 12 per battle by strength band, laid out by the stand-in's position on a real stage, against the round's real wave three times over plus 8 ground enemy kinds (melee stand-ins always meet an enemy), an item each, bonds from the board |
|
||||
|
||||
Battles go through the production BattleSpec path (`server/sim/spec.js`, as browsers and the server's headless fields
|
||||
run them); matches construct `Match` directly with a `VirtualScheduler`. Every option is explicit, so a change to a
|
||||
@@ -29,7 +30,8 @@ within a tick (they are counted, not hashed) and wall-clock time. Damage / heali
|
||||
to 2 decimals.
|
||||
|
||||
`npm test` runs the fast subset (the scenarios marked `"fast": true`: every chess record with its default loadout,
|
||||
every stage and every non-leader enemy kind, every bond at its top tier, six fields, five matches — 53 of the 133). `GOLDEN_FULL=1` checks everything.
|
||||
every stage and every non-leader enemy kind, every bond at its top tier, six fields, six matches, the five stand-in
|
||||
battles of the normal records — 59 of the 144). `GOLDEN_FULL=1` checks everything.
|
||||
|
||||
## Workflow
|
||||
|
||||
|
||||
+130
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"family": "matches",
|
||||
"about": "bot-only matches run to the end in virtual time (solo ×4 difficulties ×2 seeds, co-op 2/3/4, one server-run, two boosted to the Hidden Core)",
|
||||
"about": "matches run to the end in virtual time: bot-only (solo ×4 difficulties ×2 seeds, co-op 2/3/4, one server-run, two boosted to the Hidden Core) and one co-op match whose human seat fields 补位 stand-ins",
|
||||
"note": "Generated by `npm run golden:update` (tools/golden.mjs). A refactor must never change this file; see test/golden/README.md.",
|
||||
"columns": {
|
||||
"rounds": ["alive","lp","prepFunds","funds","pending","level","deployed","board","layers","bonds","killed","total","leaks","gold"]
|
||||
@@ -1482,6 +1482,135 @@
|
||||
"bonds": ["arcaneShip 0/302","deputShip 1/328","egirShip 0/300","emptyShip 0/300","indomShip 1/326","investShip 0/300","kazimierzShip 0/300","kjeragShip 0/300","lateranoShip 0/300","maniShip 0/300","miraShip 0/300","preciShip 0/309","raidShip 0/300","sargonShip 0/300","siracusaShip 0/300","skillfulShip 1/314","soloShip 1/318","steadShip 2/384","suntShip 1/310","swiftShip 0/300","victoriaShip 1/342","visiShip 0/306","yanShip 0/310"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"coop2-NORMAL-14-standins": {
|
||||
"about": "coop NORMAL, 1 bot seat(s) + 1 human seat (AI 托管) without 7 operators (补位 stand-ins), seed 14",
|
||||
"fast": true,
|
||||
"mode": "coop",
|
||||
"difficulty": "NORMAL",
|
||||
"seats": 1,
|
||||
"seed": 14,
|
||||
"combat": "client",
|
||||
"boost": null,
|
||||
"setup": {"stageId":"act1autochess_m01","bossId":"boss_5","hiddenBossId":"boss_10","factions":["DOT","INVISIBLE","REFLECTION"]},
|
||||
"end": {"victory":true,"roundsPassed":14,"hiddenReached":false,"hiddenCleared":false,"reason":"victory","teamLp":2,"bossPoolLeft":0,"virtualMs":116550},
|
||||
"rng": {"setup":25,"shop":495,"waves":0,"draft":34,"bots":17791,"meta":11},
|
||||
"errors": {"engine":0,"logged":0,"sim":0,"dispatcher":0},
|
||||
"rounds": [
|
||||
{
|
||||
"round": 1,
|
||||
"h_0": [1,28,4,0,0,1,2,"58414d69",0,"arcaneShip 0/2, preciShip 0/2",5,5,0,4],
|
||||
"ai_0": [1,23,4,0,0,1,2,"40f57285",0,"kazimierzShip 0/2, swiftShip 0/2",5,5,0,4]
|
||||
},
|
||||
{
|
||||
"round": 2,
|
||||
"h_0": [1,28,5,0,0,1,4,"f52e5aa9",0,"arcaneShip 0/4, preciShip 0/4",6,6,0,9],
|
||||
"ai_0": [1,23,5,0,0,1,4,"1c3bb4d9",4,"kazimierzShip 0/4, swiftShip 1/4",6,6,0,9]
|
||||
},
|
||||
{
|
||||
"round": 3,
|
||||
"h_0": [1,28,6,0,0,1,7,"77be43d5",0,"arcaneShip 0/4, kazimierzShip 0/4, preciShip 0/4, swiftShip 0/4, yanShip 0/1",6,6,0,15],
|
||||
"ai_0": [1,23,6,0,0,1,7,"751ce61a",4,"kazimierzShip 0/4, sargonShip 0/2, swiftShip 1/4",6,6,0,15]
|
||||
},
|
||||
{
|
||||
"round": 4,
|
||||
"h_0": [1,28,7,0,0,2,8,"7cb948b2",8,"arcaneShip 0/4, kazimierzShip 0/8, preciShip 0/4, raidShip 1/8, soloShip 1/0, swiftShip 0/8, yanShip 0/1",9,9,0,22],
|
||||
"ai_0": [1,23,7,0,0,1,8,"b679c297",10,"arcaneShip 0/2, deputShip 1/2, kazimierzShip 0/4, preciShip 0/2, sargonShip 0/2, swiftShip 1/8",9,9,0,22]
|
||||
},
|
||||
{
|
||||
"round": 5,
|
||||
"h_0": [1,28,8,0,0,3,8,"d543ab44",16,"arcaneShip 0/4, kazimierzShip 0/8, preciShip 0/4, raidShip 1/16, soloShip 1/0, swiftShip 0/8, yanShip 0/1",21,21,0,30],
|
||||
"ai_0": [1,23,8,0,0,2,8,"b5d78234",34,"arcaneShip 0/2, deputShip 1/4, kazimierzShip 0/8, lateranoShip 1/5, preciShip 1/7, sargonShip 0/2, suntShip 1/2, swiftShip 1/16",21,21,0,30]
|
||||
},
|
||||
{
|
||||
"round": 6,
|
||||
"h_0": [1,28,9,0,2,3,8,"9b4b0e34",36,"arcaneShip 0/4, kazimierzShip 0/8, preciShip 0/4, raidShip 1/28, soloShip 1/0, swiftShip 1/8, yanShip 0/1",31,31,0,39],
|
||||
"ai_0": [1,23,9,0,1,3,8,"c6b88ba7",52,"arcaneShip 0/2, deputShip 2/8, kazimierzShip 0/8, lateranoShip 1/15, preciShip 0/10, sargonShip 0/2, suntShip 1/6, swiftShip 1/23",31,31,0,39]
|
||||
},
|
||||
{
|
||||
"round": 7,
|
||||
"h_0": [1,28,12,0,2,4,8,"f09ca8b6",44,"arcaneShip 0/4, kazimierzShip 0/8, preciShip 0/4, raidShip 1/36, soloShip 1/0, swiftShip 1/8, yanShip 0/1",34,34,0,50],
|
||||
"ai_0": [1,23,11,0,1,4,8,"f6f38045",77,"arcaneShip 0/2, deputShip 2/12, kazimierzShip 0/8, lateranoShip 1/25, preciShip 0/10, sargonShip 0/2, suntShip 1/10, swiftShip 1/30",34,34,0,50]
|
||||
},
|
||||
{
|
||||
"round": 8,
|
||||
"h_0": [1,25,13,0,2,4,8,"1070819b",63,"arcaneShip 1/7, kazimierzShip 0/8, preciShip 0/4, raidShip 1/48, skillfulShip 1/0, swiftShip 1/8, yanShip 0/1",12,15,3,62],
|
||||
"ai_0": [1,23,12,0,0,4,8,"87633ca6",106,"arcaneShip 0/2, deputShip 2/16, kazimierzShip 0/8, lateranoShip 1/35, preciShip 0/10, sargonShip 0/2, skillfulShip 1/4, suntShip 1/14, swiftShip 1/37",15,15,0,62]
|
||||
},
|
||||
{
|
||||
"round": 9,
|
||||
"h_0": [1,23,14,0,0,4,8,"70a00d2f",50,"arcaneShip 1/10, kazimierzShip 1/12, preciShip 1/9, raidShip 0/48, skillfulShip 1/3, soloShip 1/0, swiftShip 1/16, yanShip 0/1",21,23,2,76],
|
||||
"ai_0": [1,21,12,0,0,4,8,"8dc0d798",161,"arcaneShip 0/2, deputShip 1/18, kazimierzShip 0/8, lateranoShip 1/47, preciShip 1/20, sargonShip 0/2, skillfulShip 1/11, suntShip 1/16, swiftShip 1/49",21,23,2,75]
|
||||
},
|
||||
{
|
||||
"round": 10,
|
||||
"h_0": [1,23,12,0,0,5,8,"59b986b7",73,"arcaneShip 1/10, kazimierzShip 1/19, preciShip 1/14, raidShip 0/48, skillfulShip 1/6, soloShip 1/3, swiftShip 1/21, yanShip 0/1",31,31,0,87],
|
||||
"ai_0": [1,21,12,0,0,5,8,"fa71d132",188,"arcaneShip 0/2, deputShip 0/18, kazimierzShip 0/8, lateranoShip 1/71, preciShip 1/25, sargonShip 0/2, skillfulShip 1/16, suntShip 1/16, swiftShip 1/60",31,31,0,88]
|
||||
},
|
||||
{
|
||||
"round": 11,
|
||||
"h_0": [1,19,12,0,0,5,8,"b7278144",146,"arcaneShip 0/10, kazimierzShip 1/34, preciShip 2/19, raidShip 1/61, skillfulShip 0/9, soloShip 1/6, suntShip 1/0, swiftShip 1/26, yanShip 0/1",21,25,4,99],
|
||||
"ai_0": [1,19,12,0,0,5,8,"e240abda",226,"arcaneShip 0/2, deputShip 0/18, kazimierzShip 0/8, lateranoShip 1/79, preciShip 1/33, raidShip 1/5, sargonShip 0/2, skillfulShip 1/21, suntShip 1/16, swiftShip 1/67, visiShip 1/5",23,25,2,100]
|
||||
},
|
||||
{
|
||||
"round": 12,
|
||||
"h_0": [1,12,12,0,0,6,8,"955db8af",172,"arcaneShip 0/10, kazimierzShip 1/42, preciShip 2/24, raidShip 1/69, skillfulShip 0/9, soloShip 1/6, suntShip 1/0, swiftShip 1/31, yanShip 0/1",24,31,7,111],
|
||||
"ai_0": [1,9,12,0,2,6,8,"3c27819a",264,"arcaneShip 0/2, deputShip 0/18, kazimierzShip 0/8, lateranoShip 1/87, preciShip 1/41, raidShip 1/10, sargonShip 0/2, skillfulShip 1/26, suntShip 1/16, swiftShip 1/74, visiShip 1/10",17,31,14,113]
|
||||
},
|
||||
{
|
||||
"round": 13,
|
||||
"h_0": [1,2,12,0,0,6,8,"1bd1e6d0",244,"arcaneShip 1/10, kazimierzShip 1/54, preciShip 2/34, raidShip 1/105, skillfulShip 0/9, soloShip 0/6, suntShip 1/0, swiftShip 1/41, yanShip 0/1",20,57,37,122],
|
||||
"ai_0": [0,0,14,0,0,6,0,"811c9dc5",0,"arcaneShip 0/2, deputShip 0/18, kazimierzShip 0/8, lateranoShip 0/95, preciShip 0/49, raidShip 0/15, sargonShip 0/2, skillfulShip 0/31, suntShip 0/16, swiftShip 0/81, visiShip 0/15",15,57,42,126]
|
||||
}
|
||||
],
|
||||
"players": {
|
||||
"h_0": {
|
||||
"alive": true,
|
||||
"victory": true,
|
||||
"roundsPassed": 14,
|
||||
"eliminatedRound": null,
|
||||
"lp": 2,
|
||||
"bandId": "band_amedic",
|
||||
"title": {"id":"comment_1","name":"卫戍之星","picId":"comment_icon_1","text":"对敌方领袖造成伤害最高(仅胜利时)"},
|
||||
"trophies": 4,
|
||||
"reward": 400,
|
||||
"stats": {"activatedLayers":276,"bossDamage":390000,"dmgDealt":2811982,"gold":135,"itemsEquipped":23,"kills":254,"leaks":53,"lpLost":26,"merges":5,"perfectRounds":8,"refreshes":21},
|
||||
"lineup": ["6_05_a@12,3[3_08_e_a+3_05_e_a]","4_14_b@11,3[3_11_e_b+6_05_e_a]","1_17_b@10,3","6_19_a@10,4","6_17_a@10,7[4_08_e_a+1_05_e_b]","6_06_a@10,8[1_01_e_a+4_04_e_a]","3_12_a@9,4[4_04_e_b+1_02_e_b]","3_13_b@9,8[2_01_e_a+4_03_e_a]"],
|
||||
"bonds": ["arcaneShip 0/14","kazimierzShip 1/62","kjeragShip 0/8","preciShip 2/48","raidShip 1/105","sargonShip 1/2","skillfulShip 0/9","soloShip 0/6","suntShip 1/0","swiftShip 1/59","yanShip 0/1"]
|
||||
},
|
||||
"ai_0": {
|
||||
"alive": false,
|
||||
"victory": false,
|
||||
"roundsPassed": 12,
|
||||
"eliminatedRound": 13,
|
||||
"lp": 0,
|
||||
"bandId": "band_chiave",
|
||||
"title": {"id":"comment_6","name":"挥金如土","picId":"comment_icon_6","text":"消耗资金最多"},
|
||||
"trophies": 3,
|
||||
"reward": 280,
|
||||
"stats": {"activatedLayers":0,"bossDamage":0,"dmgDealt":2138966,"gold":126,"itemsEquipped":15,"kills":234,"leaks":60,"lpLost":24,"merges":3,"perfectRounds":9,"refreshes":23},
|
||||
"lineup": [],
|
||||
"bonds": ["arcaneShip 0/2","deputShip 0/18","kazimierzShip 0/8","lateranoShip 0/95","preciShip 0/49","raidShip 0/15","sargonShip 0/2","skillfulShip 0/31","suntShip 0/16","swiftShip 0/81","visiShip 0/15"]
|
||||
}
|
||||
},
|
||||
"standIns": {
|
||||
"notOwned": ["chess_char_3_12_a","chess_char_3_18_a","chess_char_5_20_a","chess_char_6_05_a","chess_char_6_06_a","chess_char_6_17_a","chess_char_6_19_a"],
|
||||
"rounds": [
|
||||
[1,""],
|
||||
[2,""],
|
||||
[3,""],
|
||||
[4,"3_12_a→char_608_acpion"],
|
||||
[5,"3_12_a→char_608_acpion"],
|
||||
[6,"3_12_a→char_608_acpion"],
|
||||
[7,"3_12_a→char_608_acpion"],
|
||||
[8,"3_12_a→char_608_acpion"],
|
||||
[9,"3_12_a→char_608_acpion"],
|
||||
[10,"3_12_a→char_608_acpion"],
|
||||
[11,"3_12_a→char_608_acpion 6_05_a→char_611_acnipe"],
|
||||
[12,"3_12_a→char_608_acpion 6_05_a→char_611_acnipe"],
|
||||
[13,"3_12_a→char_608_acpion 6_05_a→char_611_acnipe 6_17_a→char_608_acpion"]
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,578 @@
|
||||
{
|
||||
"family": "standins",
|
||||
"about": "every NORMAL chess record (normal + elite) fielded as its 补位 stand-in (all 17 stand-ins and the skills the chess name for them), 12 per battle on real stages and waves",
|
||||
"note": "Generated by `npm run golden:update` (tools/golden.mjs). A refactor must never change this file; see test/golden/README.md.",
|
||||
"columns": {
|
||||
"units": ["player","ref","kind","def","skill","module","hp","alive","deploys","kills","dmg","taken","heal","attacks","casts"],
|
||||
"enemies": ["spawned","killed","leaked","dmg","taken"],
|
||||
"hooks": ["battleStart","deploy","tick","beforeAttack","attack","hit","damaged","heal","kill","death","skillStart","skillEnd","ammoUsed","spGain","statusApplied","blocked","enemySpawn","enemyLeak","battleEnd","fatal","layerGain","elementBurst","dodge"]
|
||||
},
|
||||
"scenarios": {
|
||||
"standin-01": {
|
||||
"about": "normal band 4 as stand-ins: chess_char_3_12_a→char_608_acpion chess_char_4_09_a→char_617_sharp2 chess_char_3_05_a→char_601_cguard chess_char_3_18_a→char_609_acguad chess_char_4_04_a→char_607_cspec chess_char_4_07_a→char_601_cguard chess_char_4_16_a→char_615_acspec chess_char_3_01_a→char_603_csnipe chess_char_3_04_a→char_605_cmedic chess_char_3_21_a→char_611_acnipe chess_char_4_02_a→char_611_acnipe chess_char_4_13_a→char_614_acsupo",
|
||||
"fast": true,
|
||||
"kind": "normal",
|
||||
"stageId": "act1autochess_m02",
|
||||
"modeId": "mode_single_normal",
|
||||
"round": 8,
|
||||
"seed": 2069558672,
|
||||
"waveId": "act1autochess_h01",
|
||||
"time": 170,
|
||||
"ticks": 5100,
|
||||
"reason": "timeout",
|
||||
"killed": 21,
|
||||
"total": 50,
|
||||
"unspawned": 0,
|
||||
"bossHpLeft": null,
|
||||
"rngDraws": 402,
|
||||
"errors": {"count":0,"kinds":[]},
|
||||
"players": {
|
||||
"p1": {
|
||||
"killed": 21,
|
||||
"total": 50,
|
||||
"perfect": false,
|
||||
"leaks": 29,
|
||||
"leaksUncounted": 0,
|
||||
"coins": 4,
|
||||
"dmg": 284159,
|
||||
"bossDmg": 0,
|
||||
"heal": 26726,
|
||||
"deaths": 22,
|
||||
"layerGains": {"raidShip":14,"visiShip":2}
|
||||
}
|
||||
},
|
||||
"units": [
|
||||
["p1",1,"op","chess_char_3_12_a",2,null,0,0,67,4,56082,3680,0,37,3],
|
||||
["p1",2,"op","chess_char_4_09_a",0,null,0,0,77,3,56268,5968,0,34,2],
|
||||
["p1",3,"op","chess_char_3_05_a",2,null,2109.99,1,74,4,33622,8823,0,39,2],
|
||||
["p1",4,"op","chess_char_3_18_a",2,null,0,0,7,3,33048,9522,0,77,2],
|
||||
["p1",5,"op","chess_char_4_04_a",1,null,435.7,1,80,0,2375,13673,0,113,0],
|
||||
["p1",6,"op","chess_char_4_07_a",1,null,0,0,71,1,16766,11343,0,37,1],
|
||||
["p1",7,"op","chess_char_4_16_a",1,null,807.8,1,85,3,27738,6036,0,83,0],
|
||||
["p1",8,"op","chess_char_3_01_a",2,null,1271,1,5,1,16680,0,0,152,3],
|
||||
["p1",9,"op","chess_char_3_04_a",2,null,1218,1,8,0,0,587,26726,59,3],
|
||||
["p1",10,"op","chess_char_3_21_a",2,null,1272,1,84,1,21722,1272,0,62,1],
|
||||
["p1",11,"op","chess_char_4_02_a",1,null,1272,1,82,1,9400,1724,0,71,1],
|
||||
["p1",12,"op","chess_char_4_13_a",1,null,0,0,78,0,10458,1624,0,28,1]
|
||||
],
|
||||
"enemies": {
|
||||
"enemy_10001_trslim": [1,1,0,8,4860],
|
||||
"enemy_1000_gopro_2": [1,1,0,22,1836],
|
||||
"enemy_10018_sgrobh": [1,1,0,2668,8100],
|
||||
"enemy_1001_bigbo": [1,0,1,0,2592],
|
||||
"enemy_10027_vtsk": [1,0,1,11845,916],
|
||||
"enemy_10031_cnvsld": [1,0,1,0,0],
|
||||
"enemy_10034_cnvsax": [1,0,1,0,0],
|
||||
"enemy_10042_prtrop": [1,0,1,808,3807],
|
||||
"enemy_1116_liprr_2": [21,12,9,5388,124143],
|
||||
"enemy_1119_vofsd": [12,5,6,21861,99812],
|
||||
"enemy_1121_lifbos_2": [9,1,7,13749,38094]
|
||||
},
|
||||
"hooks": [1,85,5100,943,943,1330,1321,3766,43,76,19,28,0,21170,28,44,50,27,1,43,6,0,9],
|
||||
"events": {"atk":1028,"bounty":2,"deploy":35,"die":49,"dmg":1321,"fx":68,"heal":59,"layer":6,"leak":27,"skill":35,"spawn":62,"status":134},
|
||||
"fx": {"blink":6,"dodge":9,"dp":7,"garrison":6,"liberate":15,"slash":24,"telegraph":1},
|
||||
"snaps": "e6528b42 398001f2 644c5ee8 df7b48f9 a8a51847 554e1092 13c60701 49f04e5d fb95fb75 38fa86a2 687082e0 9c39cb98 7dd1bdd1 a74eebb6 b9c1e236 8fc6086d 944683e9"
|
||||
},
|
||||
"standin-02": {
|
||||
"about": "normal band 5 as stand-ins: chess_char_4_17_a→char_610_acfend chess_char_4_18_a→char_602_cdfend chess_char_5_05_a→char_602_cdfend chess_char_4_19_a→char_608_acpion chess_char_5_01_a→char_600_cpione chess_char_4_22_a→char_609_acguad chess_char_4_23_a→char_609_acguad chess_char_5_06_a→char_609_acguad chess_char_4_20_a→char_603_csnipe chess_char_4_24_a→char_604_ccast chess_char_5_02_a→char_604_ccast chess_char_5_03_a→char_612_accast",
|
||||
"fast": true,
|
||||
"kind": "normal",
|
||||
"stageId": "act1autochess_m05",
|
||||
"modeId": "mode_multi_abyss",
|
||||
"round": 10,
|
||||
"seed": 3710344357,
|
||||
"waveId": "act1autochess_h03",
|
||||
"time": 190,
|
||||
"ticks": 5700,
|
||||
"reason": "timeout",
|
||||
"killed": 9,
|
||||
"total": 68,
|
||||
"unspawned": 0,
|
||||
"bossHpLeft": null,
|
||||
"rngDraws": 113,
|
||||
"errors": {"count":0,"kinds":[]},
|
||||
"players": {
|
||||
"p1": {"killed":9,"total":68,"perfect":false,"leaks":59,"leaksUncounted":0,"coins":0,"dmg":98109,"bossDmg":0,"heal":0,"deaths":23,"layerGains":{"preciShip":12}}
|
||||
},
|
||||
"units": [
|
||||
["p1",1,"op","chess_char_4_17_a",1,null,0,0,3,0,57,2604,0,4,1],
|
||||
["p1",2,"op","chess_char_4_18_a",1,null,0,0,78,0,2350,6810,0,6,1],
|
||||
["p1",3,"op","chess_char_5_05_a",2,null,0,0,70,0,14,4256,0,1,0],
|
||||
["p1",4,"op","chess_char_4_19_a",1,null,0,0,66,0,316,3058,0,13,1],
|
||||
["p1",5,"op","chess_char_5_01_a",2,null,0,0,91,0,35,3675,0,2,0],
|
||||
["p1",6,"op","chess_char_4_22_a",1,null,0,0,71,0,5165,5276,0,9,0],
|
||||
["p1",7,"op","chess_char_4_23_a",1,null,0,0,64,0,5497,5276,0,9,0],
|
||||
["p1",8,"op","chess_char_5_06_a",2,null,0,0,1,0,0,2638,0,0,0],
|
||||
["p1",9,"op","chess_char_4_20_a",1,null,1271,1,92,1,11656,2542,0,25,0],
|
||||
["p1",10,"op","chess_char_4_24_a",1,null,0,0,90,0,3736,3399,0,8,0],
|
||||
["p1",11,"op","chess_char_5_02_a",2,null,0,0,84,3,13253,2266,0,25,1],
|
||||
["p1",12,"op","chess_char_5_03_a",2,null,0,0,4,5,56032,1372,0,10,1]
|
||||
],
|
||||
"enemies": {
|
||||
"enemy_10042_prtrop_2": [1,0,1,4567,14393],
|
||||
"enemy_10043_sailor": [1,1,0,1137,23649],
|
||||
"enemy_10044_wintun": [1,0,1,4374,12094],
|
||||
"enemy_10045_parrot": [1,0,1,0,4099],
|
||||
"enemy_10054_cjhot": [1,0,1,1676,1665],
|
||||
"enemy_1005_yokai": [27,8,19,0,29182],
|
||||
"enemy_1005_yokai_2": [27,0,24,11401,2816],
|
||||
"enemy_1005_yokai_3": [6,0,6,20017,14371],
|
||||
"enemy_10065_ftzlc": [1,0,1,0,560],
|
||||
"enemy_10067_ftsjc": [1,0,1,0,680],
|
||||
"enemy_1006_shield": [1,0,1,0,760]
|
||||
},
|
||||
"hooks": [1,92,5700,337,337,558,523,0,32,88,5,5,0,12532,1,13,68,56,1,32,3,0,41],
|
||||
"events": {"atk":345,"deploy":24,"die":32,"dmg":523,"fx":81,"layer":3,"leak":56,"skill":10,"spawn":80,"status":2},
|
||||
"fx": {"aoe":4,"dodge":41,"dp":4,"explode":27,"garrison":3,"garrisonGrant":1,"zone":1},
|
||||
"snaps": "9d2468c6 9a32f006 99b2aa33 a01ecc39 97bb00b0 2fcede4b fe594f15 377f7691 e87b0fd3 af44759d 7b13ea89 61cd9b40 4f9d1182 5905fb57 d697ca06 c37e9513 3102b854 296cdd49 4d5efa28"
|
||||
},
|
||||
"standin-03": {
|
||||
"about": "normal band 5 as stand-ins: chess_char_5_08_a→char_610_acfend chess_char_5_14_a→char_608_acpion chess_char_5_19_a→char_617_sharp2 chess_char_5_07_a→char_601_cguard chess_char_5_13_a→char_609_acguad chess_char_5_17_a→char_615_acspec chess_char_5_10_a→char_606_csuppo chess_char_5_11_a→char_613_acmedc chess_char_5_12_a→char_614_acsupo chess_char_5_15_a→char_603_csnipe chess_char_5_18_a→char_611_acnipe chess_char_5_20_a→char_614_acsupo",
|
||||
"fast": true,
|
||||
"kind": "normal",
|
||||
"stageId": "act2autochess_m01",
|
||||
"modeId": "mode_multi_funny",
|
||||
"round": 10,
|
||||
"seed": 3083705594,
|
||||
"waveId": "act1autochess_h03",
|
||||
"time": 190,
|
||||
"ticks": 5700,
|
||||
"reason": "timeout",
|
||||
"killed": 37,
|
||||
"total": 74,
|
||||
"unspawned": 0,
|
||||
"bossHpLeft": null,
|
||||
"rngDraws": 374,
|
||||
"errors": {"count":0,"kinds":[]},
|
||||
"players": {"p1":{"killed":37,"total":74,"perfect":false,"leaks":37,"leaksUncounted":0,"coins":8,"dmg":331499,"bossDmg":0,"heal":49226,"deaths":12,"layerGains":{}}},
|
||||
"units": [
|
||||
["p1",1,"op","chess_char_5_08_a",2,null,0,0,6,0,4409,3444,0,59,1],
|
||||
["p1",2,"op","chess_char_5_14_a",2,null,0,0,86,16,131680,8403,0,53,6],
|
||||
["p1",3,"op","chess_char_5_19_a",0,null,0,0,90,8,38187,15644,0,91,2],
|
||||
["p1",4,"op","chess_char_5_07_a",2,null,0,0,7,2,18936,10110,0,69,2],
|
||||
["p1",5,"op","chess_char_5_13_a",2,null,1872.24,1,11,5,68168,12979,0,127,4],
|
||||
["p1",6,"op","chess_char_5_17_a",2,null,0,0,81,2,7848,5571,0,85,0],
|
||||
["p1",7,"op","chess_char_5_10_a",2,null,932,1,2,1,8617,0,0,74,2],
|
||||
["p1",8,"op","chess_char_5_11_a",2,null,1224,1,3,0,0,0,49226,72,2],
|
||||
["p1",9,"op","chess_char_5_12_a",2,null,1160,1,4,0,22812,0,0,88,2],
|
||||
["p1",10,"op","chess_char_5_15_a",2,null,0,0,85,2,8089,12138,0,87,2],
|
||||
["p1",11,"op","chess_char_5_18_a",2,null,0,0,1,1,13952,1272,0,34,1],
|
||||
["p1",12,"op","chess_char_5_20_a",2,null,742,1,91,0,8800,3202,0,43,1]
|
||||
],
|
||||
"enemies": {
|
||||
"enemy_1006_shield_2": [1,1,0,679,8000],
|
||||
"enemy_1006_shield_3": [1,0,1,11277,13035],
|
||||
"enemy_10073_mpcar": [1,1,0,12,1600],
|
||||
"enemy_1007_slime": [1,1,0,0,440],
|
||||
"enemy_10081_mpplai": [1,1,0,0,2000],
|
||||
"enemy_10087_hlchgr": [1,1,0,58,4800],
|
||||
"enemy_10094_crstf": [1,1,0,28,3200],
|
||||
"enemy_10097_crshd": [1,0,1,936,5046],
|
||||
"enemy_1166_dusbr_2": [42,27,15,3496,107410],
|
||||
"enemy_1170_dushld_2": [12,2,8,27941,44857],
|
||||
"enemy_9011_acrefr": [12,2,4,28337,141112]
|
||||
},
|
||||
"hooks": [1,91,5700,1118,1118,1648,1663,132,49,78,25,27,0,27793,3468,54,74,29,1,49,0,1,9],
|
||||
"events": {"atk":1244,"bounty":4,"deploy":17,"die":49,"dmg":1661,"fx":130,"heal":126,"leak":29,"skill":50,"spawn":86,"status":661},
|
||||
"fx": {"aoe":30,"burst":1,"displace":1,"dodge":16,"dp":22,"phase":1,"slash":48,"sp":1,"spGain":10},
|
||||
"snaps": "dbcb5cec 56a8a8f6 e8d97a5d d2594a66 b6f753a4 6b5239d5 0d94f1a8 53bcdab2 582202c7 d547ef42 438122c0 f5345cd2 dbac677c 38aa1103 537a6dbf c9e9e9aa 7c7ed647 9cfd6d5b 94abfb24"
|
||||
},
|
||||
"standin-04": {
|
||||
"about": "normal band 6 as stand-ins: chess_char_6_03_a→char_610_acfend chess_char_6_02_a→char_608_acpion chess_char_6_07_a→char_617_sharp2 chess_char_6_10_a→char_617_sharp2 chess_char_6_11_a→char_608_acpion chess_char_6_06_a→char_609_acguad chess_char_5_22_a→char_611_acnipe chess_char_6_01_a→char_611_acnipe chess_char_6_04_a→char_614_acsupo chess_char_6_05_a→char_611_acnipe chess_char_6_08_a→char_612_accast chess_char_6_09_a→char_613_acmedc",
|
||||
"fast": true,
|
||||
"kind": "normal",
|
||||
"stageId": "act2autochess_m04",
|
||||
"modeId": "mode_single_hard",
|
||||
"round": 12,
|
||||
"seed": 428893583,
|
||||
"waveId": "act1autochess_h05",
|
||||
"time": 201.7,
|
||||
"ticks": 6051,
|
||||
"reason": "cleared",
|
||||
"killed": 136,
|
||||
"total": 158,
|
||||
"unspawned": 0,
|
||||
"bossHpLeft": null,
|
||||
"rngDraws": 644,
|
||||
"errors": {"count":0,"kinds":[]},
|
||||
"players": {
|
||||
"p1": {
|
||||
"killed": 136,
|
||||
"total": 158,
|
||||
"perfect": false,
|
||||
"leaks": 22,
|
||||
"leaksUncounted": 0,
|
||||
"coins": 8,
|
||||
"dmg": 1309822,
|
||||
"bossDmg": 0,
|
||||
"heal": 23527,
|
||||
"deaths": 12,
|
||||
"layerGains": {"sargonShip":5}
|
||||
}
|
||||
},
|
||||
"units": [
|
||||
["p1",1,"op","chess_char_6_03_a",2,null,0,0,174,0,698,7812,0,6,0],
|
||||
["p1",2,"op","chess_char_6_02_a",2,null,1529,1,179,1,2975,3058,0,7,2],
|
||||
["p1",3,"op","chess_char_6_07_a",0,null,0,0,172,9,135322,3730,0,74,1],
|
||||
["p1",4,"op","chess_char_6_10_a",0,null,0,0,166,5,94409,3730,0,75,2],
|
||||
["p1",5,"op","chess_char_6_11_a",2,null,1026.4,1,178,11,100320,8547,0,40,4],
|
||||
["p1",6,"op","chess_char_6_06_a",2,null,2080.4,1,156,9,111400,19283,0,111,4],
|
||||
["p1",7,"op","chess_char_5_22_a",2,null,1272,1,3,2,25528,0,0,19,2],
|
||||
["p1",8,"op","chess_char_6_01_a",2,null,1272,1,4,34,325813,0,0,164,2],
|
||||
["p1",9,"op","chess_char_6_04_a",2,null,1160,1,13,7,65845,0,0,121,2],
|
||||
["p1",10,"op","chess_char_6_05_a",2,null,1272,1,14,18,234561,2453,0,122,2],
|
||||
["p1",11,"op","chess_char_6_08_a",2,null,1372,1,5,39,212952,0,0,57,2],
|
||||
["p1",12,"op","chess_char_6_09_a",2,null,1224,1,11,0,0,0,23527,37,2],
|
||||
["-","~1","device","trap_1105_accrate",null,null,100,1,1,0,0,0,0,0,null],
|
||||
["-","~2","device","trap_1105_accrate",null,null,100,1,2,0,0,0,0,0,null]
|
||||
],
|
||||
"enemies": {
|
||||
"enemy_10098_crhro": [1,1,0,3450,41472],
|
||||
"enemy_10099_crvln": [1,1,0,2453,20736],
|
||||
"enemy_1009_lurker": [1,1,0,16,3041],
|
||||
"enemy_1010_demon": [1,1,0,0,10368],
|
||||
"enemy_1010_demon_2": [28,22,6,15447,543909],
|
||||
"enemy_10112_ymgds": [1,1,0,0,6912],
|
||||
"enemy_10116_ymgtop": [1,1,0,2924,13824],
|
||||
"enemy_10118_ymgprc": [1,0,1,3965,18459],
|
||||
"enemy_1071_dftman": [63,63,0,447,243855],
|
||||
"enemy_1072_dlancer": [60,45,15,19911,429319]
|
||||
},
|
||||
"hooks": [1,181,6051,918,918,1945,1944,62,148,170,25,24,0,31170,4160,44,158,22,1,149,2,0,13],
|
||||
"events": {"atk":1080,"bounty":4,"deploy":21,"die":148,"dmg":1944,"fx":87,"heal":45,"layer":2,"leak":22,"skill":49,"spawn":172,"status":440},
|
||||
"fx": {"aoe":7,"dodge":13,"dp":18,"garrison":2,"revive":1,"slash":29,"sp":12,"spGain":3,"telegraph":2},
|
||||
"snaps": "29a29463 6fc96b2d 184b448a fce3c126 45d67357 2b2b80de f966fc58 66e1b5c9 ce0eff29 79805bf9 289d6433 dbecb111 d3142708 bec781dd 26e4cc8e 0c071920 829264cd 362b2f26 16b5df6a 9fda7720 7730ec59"
|
||||
},
|
||||
"standin-05": {
|
||||
"about": "normal band 6 as stand-ins: chess_char_6_17_a→char_608_acpion chess_char_6_13_a→char_615_acspec chess_char_6_19_a→char_615_acspec chess_char_6_12_a→char_612_accast chess_char_6_15_a→char_614_acsupo chess_char_6_18_a→char_612_accast chess_char_6_20_a→char_613_acmedc",
|
||||
"fast": true,
|
||||
"kind": "normal",
|
||||
"stageId": "act1autochess_m03",
|
||||
"modeId": "mode_multi_normal",
|
||||
"round": 12,
|
||||
"seed": 4096833445,
|
||||
"waveId": "act1autochess_h05",
|
||||
"time": 214.33,
|
||||
"ticks": 6430,
|
||||
"reason": "cleared",
|
||||
"killed": 7,
|
||||
"total": 104,
|
||||
"unspawned": 0,
|
||||
"bossHpLeft": null,
|
||||
"rngDraws": 246,
|
||||
"errors": {"count":0,"kinds":[]},
|
||||
"players": {
|
||||
"p1": {"killed":7,"total":104,"perfect":false,"leaks":97,"leaksUncounted":0,"coins":0,"dmg":426178,"bossDmg":0,"heal":1353,"deaths":22,"layerGains":{"raidShip":3}}
|
||||
},
|
||||
"units": [
|
||||
["p1",1,"op","chess_char_6_17_a",2,null,0,0,128,1,103267,9556,0,43,1],
|
||||
["p1",2,"op","chess_char_6_13_a",2,null,0,0,132,1,25092,9232,0,87,0],
|
||||
["p1",3,"op","chess_char_6_19_a",2,null,0,0,134,2,29265,9232,0,141,0],
|
||||
["p1",4,"op","chess_char_6_12_a",2,null,671.78,1,7,1,143681,975,0,85,2],
|
||||
["p1",5,"op","chess_char_6_15_a",2,null,0,0,133,2,54479,4965,0,113,2],
|
||||
["p1",6,"op","chess_char_6_18_a",2,null,1372,1,85,0,70393,1372,0,42,2],
|
||||
["p1",7,"op","chess_char_6_20_a",2,null,1224,1,4,0,0,1353,1353,3,1],
|
||||
["-","~1","device","trap_1105_accrate",null,null,100,1,1,0,0,0,0,0,null],
|
||||
["-","~2","device","trap_1105_accrate",null,null,100,1,2,0,0,0,0,0,null],
|
||||
["-","~3","device","trap_1105_accrate",null,null,100,1,3,0,0,0,0,0,null]
|
||||
],
|
||||
"enemies": {
|
||||
"enemy_10122_uacann_2": [1,0,1,6380,12182],
|
||||
"enemy_10124_uashld_2": [1,0,1,0,5764],
|
||||
"enemy_10127_rkmbst_2": [1,0,1,834,0],
|
||||
"enemy_10138_xdsnow": [1,0,1,0,1276],
|
||||
"enemy_10141_xdpeng_2": [1,0,1,4211,14094],
|
||||
"enemy_10144_xdelk_2": [1,0,1,0,42],
|
||||
"enemy_10156_mncrer": [1,0,1,0,738],
|
||||
"enemy_10159_mntrjn": [1,0,1,0,0],
|
||||
"enemy_1381_winman": [27,5,22,3499,98764],
|
||||
"enemy_1381_winman_2": [39,1,38,5799,153206],
|
||||
"enemy_1387_winshd": [30,1,29,15960,140112]
|
||||
},
|
||||
"hooks": [1,134,6430,620,620,988,988,6,29,128,8,24,0,14868,2437,28,104,97,1,29,1,0,0],
|
||||
"events": {"atk":651,"deploy":27,"die":31,"dmg":901,"fx":58,"heal":3,"layer":1,"leak":97,"skill":16,"spawn":114,"status":514},
|
||||
"fx": {"aoe":5,"blink":2,"camouflage":1,"displace":22,"dodge":3,"dp":2,"explode":6,"garrison":1,"garrisonGrant":2,"slash":8,"zone":6},
|
||||
"snaps": "8d7b3223 b6dafa64 3940bc8e fd79b9e2 e5e85618 6fcfc479 90958489 71bfbb91 d12c224c cd2c9c7f 4a9fff33 68e48a86 4b97e027 5f8e8dad da62239a 8601c0dc 73717b6d 9e4f784f 818e7ed1 c1f2a855 33118eab 9bafc0bc"
|
||||
},
|
||||
"standin-06": {
|
||||
"about": "elite band 5 as stand-ins: chess_char_3_12_b→char_608_acpion chess_char_4_09_b→char_617_sharp2 chess_char_3_05_b→char_601_cguard chess_char_3_18_b→char_609_acguad chess_char_4_04_b→char_607_cspec chess_char_4_07_b→char_601_cguard chess_char_4_16_b→char_615_acspec chess_char_3_01_b→char_603_csnipe chess_char_3_04_b→char_605_cmedic chess_char_3_21_b→char_611_acnipe chess_char_4_02_b→char_611_acnipe chess_char_4_13_b→char_614_acsupo",
|
||||
"fast": false,
|
||||
"kind": "normal",
|
||||
"stageId": "act1autochess_m06",
|
||||
"modeId": "mode_multi_hard",
|
||||
"round": 10,
|
||||
"seed": 1442657168,
|
||||
"waveId": "act1autochess_h03",
|
||||
"time": 185.83,
|
||||
"ticks": 5575,
|
||||
"reason": "cleared",
|
||||
"killed": 40,
|
||||
"total": 98,
|
||||
"unspawned": 0,
|
||||
"bossHpLeft": null,
|
||||
"rngDraws": 293,
|
||||
"errors": {"count":0,"kinds":[]},
|
||||
"players": {
|
||||
"p1": {"killed":40,"total":98,"perfect":false,"leaks":58,"leaksUncounted":0,"coins":8,"dmg":553335,"bossDmg":0,"heal":19916,"deaths":32,"layerGains":{"raidShip":24}}
|
||||
},
|
||||
"units": [
|
||||
["p1",1,"op","chess_char_3_12_b",2,"none",1904,1,135,10,142984,5468,880,20,3],
|
||||
["p1",2,"op","chess_char_4_09_b",0,"none",0,0,6,7,58726,3510,0,13,1],
|
||||
["p1",3,"op","chess_char_3_05_b",2,"none",0,0,90,1,27607,9128,0,18,2],
|
||||
["p1",4,"op","chess_char_3_18_b",2,"none",0,0,89,3,34118,9190,2808,27,2],
|
||||
["p1",5,"op","chess_char_4_04_b",1,"none",0,0,134,1,8258,8846,0,21,0],
|
||||
["p1",6,"op","chess_char_4_07_b",1,"none",0,0,93,0,19765,10676,0,20,1],
|
||||
["p1",7,"op","chess_char_4_16_b",1,"none",0,0,133,2,17401,11631,0,26,0],
|
||||
["p1",8,"op","chess_char_3_01_b",2,"none",0,0,128,1,21787,3020,0,27,1],
|
||||
["p1",9,"op","chess_char_3_04_b",2,"none",0,0,4,0,0,6998,16229,17,2],
|
||||
["p1",10,"op","chess_char_3_21_b",2,"none",0,0,114,11,155605,7911,0,47,1],
|
||||
["p1",11,"op","chess_char_4_02_b",1,"none",0,0,2,3,47003,1483,0,29,1],
|
||||
["p1",12,"op","chess_char_4_13_b",1,"none",0,0,96,1,20081,3262,0,25,2]
|
||||
],
|
||||
"enemies": {
|
||||
"enemy_10162_mnctpt": [1,1,0,7990,39813],
|
||||
"enemy_1019_jshoot": [1,1,0,423,4479],
|
||||
"enemy_1019_jshoot_2": [1,1,0,1526,6718],
|
||||
"enemy_1021_bslime": [1,1,0,3434,6121],
|
||||
"enemy_1023_jmage": [1,1,0,2086,5474],
|
||||
"enemy_1025_reveng": [1,1,0,0,12442],
|
||||
"enemy_1026_aghost": [1,1,0,0,9456],
|
||||
"enemy_1043_zomsbr": [1,0,1,0,0],
|
||||
"enemy_1251_lysyta_2": [57,25,32,15811,231796],
|
||||
"enemy_1252_lysytb_2": [21,7,14,21523,186411],
|
||||
"enemy_1329_cbshld_2": [12,1,11,28332,50624]
|
||||
},
|
||||
"hooks": [1,135,5575,471,471,727,756,61,72,134,16,33,0,6314,36,49,98,58,1,81,3,0,6],
|
||||
"events": {"atk":553,"bounty":4,"deploy":37,"die":76,"dmg":756,"fx":57,"heal":58,"layer":3,"leak":58,"skill":32,"spawn":110,"status":119},
|
||||
"fx": {"blink":4,"camouflage":1,"dodge":11,"dp":13,"explode":1,"garrison":3,"slash":24},
|
||||
"snaps": "2e7a8cb5 d50cd79b d7949cae 1b9749a6 6e3d202d f87f73b6 45e24574 d9fad5af 5495e86c 3357c483 195f9b7f 05cf2852 258f0a18 7db86d77 fcd4dcb8 b89c90a0 d44a9d5e 0de542a5 f00c7481"
|
||||
},
|
||||
"standin-07": {
|
||||
"about": "elite band 6 as stand-ins: chess_char_4_17_b→char_610_acfend chess_char_4_18_b→char_602_cdfend chess_char_5_05_b→char_602_cdfend chess_char_4_19_b→char_608_acpion chess_char_5_01_b→char_600_cpione chess_char_4_22_b→char_609_acguad chess_char_4_23_b→char_609_acguad chess_char_5_06_b→char_609_acguad chess_char_4_20_b→char_603_csnipe chess_char_4_24_b→char_604_ccast chess_char_5_02_b→char_604_ccast chess_char_5_03_b→char_612_accast",
|
||||
"fast": false,
|
||||
"kind": "normal",
|
||||
"stageId": "act2autochess_m02",
|
||||
"modeId": "mode_single_normal",
|
||||
"round": 12,
|
||||
"seed": 816286799,
|
||||
"waveId": "act1autochess_h05",
|
||||
"time": 222.97,
|
||||
"ticks": 6689,
|
||||
"reason": "cleared",
|
||||
"killed": 8,
|
||||
"total": 80,
|
||||
"unspawned": 0,
|
||||
"bossHpLeft": null,
|
||||
"rngDraws": 80,
|
||||
"errors": {"count":0,"kinds":[]},
|
||||
"players": {
|
||||
"p1": {
|
||||
"killed": 8,
|
||||
"total": 80,
|
||||
"perfect": false,
|
||||
"leaks": 72,
|
||||
"leaksUncounted": 0,
|
||||
"coins": 4,
|
||||
"dmg": 126420,
|
||||
"bossDmg": 0,
|
||||
"heal": 0,
|
||||
"deaths": 22,
|
||||
"layerGains": {"preciShip":8,"sargonShip":18}
|
||||
}
|
||||
},
|
||||
"units": [
|
||||
["p1",1,"op","chess_char_4_17_b",1,"none",0,0,3,0,42,3211,0,2,1],
|
||||
["p1",2,"op","chess_char_4_18_b",1,"none",0,0,83,0,30,9306,0,1,1],
|
||||
["p1",3,"op","chess_char_5_05_b",2,"none",0,0,97,0,572,5816,0,1,1],
|
||||
["p1",4,"op","chess_char_4_19_b",1,"none",1847.63,1,107,1,367,3864,0,5,1],
|
||||
["p1",5,"op","chess_char_5_01_b",2,"none",1036.53,1,105,0,2197,3985,0,5,3],
|
||||
["p1",6,"op","chess_char_4_22_b",1,"none",3191,1,109,0,1178,14884,0,5,1],
|
||||
["p1",7,"op","chess_char_4_23_b",1,"none",0,0,67,0,3484,6610,0,8,1],
|
||||
["p1",8,"op","chess_char_5_06_b",2,"uniequip_002_acguad",0,0,10,0,0,3820,0,0,0],
|
||||
["p1",9,"op","chess_char_4_20_b",1,"none",1255.06,1,106,2,16976,4326,0,31,1],
|
||||
["p1",10,"op","chess_char_4_24_b",1,"none",1007.59,1,108,0,5460,3072,0,18,1],
|
||||
["p1",11,"op","chess_char_5_02_b",2,"none",0,0,102,1,8032,4080,0,19,1],
|
||||
["p1",12,"op","chess_char_5_03_b",2,"uniequip_002_accast",0,0,4,4,88082,1749,0,10,1],
|
||||
["-","~1","device","trap_1105_accrate",null,null,100,1,1,0,0,0,0,0,null],
|
||||
["-","~2","device","trap_1105_accrate",null,null,100,1,2,0,0,0,0,0,null]
|
||||
],
|
||||
"enemies": {
|
||||
"enemy_1005_yokai_2": [18,2,16,3894,7075],
|
||||
"enemy_1041_lazerd_2": [24,3,21,41810,33530],
|
||||
"enemy_1042_frostd": [30,1,29,0,24206],
|
||||
"enemy_1045_hammer": [1,1,0,1914,15552],
|
||||
"enemy_1045_hammer_2": [1,0,1,6132,25057],
|
||||
"enemy_1046_agent": [1,1,0,57,3888],
|
||||
"enemy_1050_lslime": [1,0,1,9425,1320],
|
||||
"enemy_1061_zomshd": [1,0,1,31,9241],
|
||||
"enemy_1062_rager_2": [1,0,1,1459,14551],
|
||||
"enemy_1067_snslime": [1,0,1,0,0],
|
||||
"enemy_1071_dftman": [1,0,1,0,0]
|
||||
},
|
||||
"hooks": [1,109,6689,304,304,581,573,378,30,102,13,13,0,8920,9,11,80,72,1,33,2,0,8],
|
||||
"events": {"atk":330,"bounty":2,"deploy":27,"die":30,"dmg":573,"fx":22,"layer":2,"leak":72,"skill":26,"spawn":94,"status":103},
|
||||
"fx": {"aoe":1,"dodge":8,"dp":6,"garrison":2,"garrisonGrant":1,"palsy":1,"revive":3},
|
||||
"snaps": "ef9ff66a 4f4ac6fc 133f7f1c a187a8c8 46d89701 4bb496a8 0dfccf57 81173658 8c42876d 4718c77c d3b80c8e df3c692d 0576a74b 7684de92 e59b0c43 849795d5 e99812de 3f883cd6 6bb9cdf6 b246f597 289cd790 2f34e6ff 997f51f3"
|
||||
},
|
||||
"standin-08": {
|
||||
"about": "elite band 6 as stand-ins: chess_char_5_08_b→char_610_acfend chess_char_5_14_b→char_608_acpion chess_char_5_19_b→char_617_sharp2 chess_char_5_07_b→char_601_cguard chess_char_5_13_b→char_609_acguad chess_char_5_17_b→char_615_acspec chess_char_5_10_b→char_606_csuppo chess_char_5_11_b→char_613_acmedc chess_char_5_12_b→char_614_acsupo chess_char_5_15_b→char_603_csnipe chess_char_5_18_b→char_611_acnipe chess_char_5_20_b→char_614_acsupo",
|
||||
"fast": false,
|
||||
"kind": "normal",
|
||||
"stageId": "act1autochess_m01",
|
||||
"modeId": "mode_multi_abyss",
|
||||
"round": 12,
|
||||
"seed": 2456427706,
|
||||
"waveId": "act1autochess_h05",
|
||||
"time": 230,
|
||||
"ticks": 6900,
|
||||
"reason": "timeout",
|
||||
"killed": 32,
|
||||
"total": 179,
|
||||
"unspawned": 0,
|
||||
"bossHpLeft": null,
|
||||
"rngDraws": 1052,
|
||||
"errors": {"count":0,"kinds":[]},
|
||||
"players": {"p1":{"killed":32,"total":179,"perfect":false,"leaks":147,"leaksUncounted":0,"coins":2,"dmg":1703220,"bossDmg":0,"heal":106481,"deaths":9,"layerGains":{}}},
|
||||
"units": [
|
||||
["p1",1,"op","chess_char_5_08_b",2,"uniequip_002_acfend",3285.08,1,201,4,80453,15794,0,116,3],
|
||||
["p1",2,"op","chess_char_5_14_b",2,"uniequip_002_acpion",1904,1,200,8,678474,11374,0,31,9],
|
||||
["p1",3,"op","chess_char_5_19_b",0,"uniequip_002_sharp2",4154.5,1,202,4,187598,17962,0,51,2],
|
||||
["p1",4,"op","chess_char_5_07_b",2,"none",3334,1,199,2,46104,18401,0,36,3],
|
||||
["p1",5,"op","chess_char_5_13_b",2,"uniequip_002_acguad",0,0,160,5,101185,25518,0,50,3],
|
||||
["p1",6,"op","chess_char_5_17_b",2,"uniequip_002_acspec",1448,1,8,2,21853,46495,0,206,0],
|
||||
["p1",7,"op","chess_char_5_10_b",2,"none",1131,1,4,1,46614,0,0,155,4],
|
||||
["p1",8,"op","chess_char_5_11_b",2,"uniequip_002_acmedc",2262.4,1,5,0,0,0,106481,74,3],
|
||||
["p1",9,"op","chess_char_5_12_b",2,"uniequip_002_acsupo",1430,1,11,2,97118,0,0,130,3],
|
||||
["p1",10,"op","chess_char_5_15_b",2,"none",1510,1,13,0,84181,0,0,228,4],
|
||||
["p1",11,"op","chess_char_5_18_b",2,"uniequip_002_acnipe",2380.5,1,6,4,265046,0,0,156,3],
|
||||
["p1",12,"op","chess_char_5_20_b",2,"uniequip_002_acsupo",1430,1,14,0,94594,0,0,125,3],
|
||||
["-","~1","device","trap_1105_accrate",null,null,100,1,1,0,0,0,0,0,null],
|
||||
["-","~2","device","trap_1105_accrate",null,null,100,1,2,0,0,0,0,0,null],
|
||||
["-","~3","device","trap_1105_accrate",null,null,100,1,3,0,0,0,0,0,null]
|
||||
],
|
||||
"enemies": {
|
||||
"enemy_1072_dlancer": [1,1,0,3008,37150],
|
||||
"enemy_1081_sotisd": [1,1,0,39568,92876],
|
||||
"enemy_1092_mdgint": [1,0,1,0,67],
|
||||
"enemy_1116_liprr": [1,0,1,0,7702],
|
||||
"enemy_1116_liprr_2": [1,0,1,0,1829],
|
||||
"enemy_1118_lidbox_2": [1,0,1,0,1344],
|
||||
"enemy_1119_vofsd": [1,0,1,0,959],
|
||||
"enemy_1121_lifbos": [1,0,1,0,922],
|
||||
"enemy_1249_lysdb_2": [60,9,49,23770,693077],
|
||||
"enemy_1251_lysyta": [63,20,43,6676,488381],
|
||||
"enemy_1254_lypa_2": [48,1,47,62523,378913]
|
||||
},
|
||||
"hooks": [1,202,6900,1760,1760,3076,3094,145,41,186,40,39,0,26130,16747,54,179,145,1,45,0,0,8],
|
||||
"events": {"atk":1979,"bounty":1,"deploy":20,"die":41,"dmg":3094,"fx":274,"heal":126,"leak":145,"skill":79,"spawn":194,"status":1237},
|
||||
"fx": {"aoe":84,"dodge":29,"dp":17,"shieldBreak":60,"slash":72,"sp":5,"spGain":7},
|
||||
"snaps": "4d4dc5ab 98e9e455 46d76035 722418a9 b67e68a3 866e42e8 786ec7e2 2c2a7307 3657963b 8fad1c0c 513f1b73 84a84fa5 d1698d65 9ae19dde 551dcc8c 8e2b8aef a8f4f525 70f7b5a7 f8b9e9b9 0c6627fe 9c4013d8 fa4c2add 6f3c1f53"
|
||||
},
|
||||
"standin-09": {
|
||||
"about": "elite band 7 as stand-ins: chess_char_6_03_b→char_610_acfend chess_char_6_02_b→char_608_acpion chess_char_6_07_b→char_617_sharp2 chess_char_6_10_b→char_617_sharp2 chess_char_6_11_b→char_608_acpion chess_char_6_06_b→char_609_acguad chess_char_5_22_b→char_611_acnipe chess_char_6_01_b→char_611_acnipe chess_char_6_04_b→char_614_acsupo chess_char_6_05_b→char_611_acnipe chess_char_6_08_b→char_612_accast chess_char_6_09_b→char_613_acmedc",
|
||||
"fast": false,
|
||||
"kind": "normal",
|
||||
"stageId": "act1autochess_m04",
|
||||
"modeId": "mode_multi_funny",
|
||||
"round": 13,
|
||||
"seed": 2308517178,
|
||||
"waveId": "act1autochess_h06",
|
||||
"time": 199.53,
|
||||
"ticks": 5986,
|
||||
"reason": "cleared",
|
||||
"killed": 158,
|
||||
"total": 158,
|
||||
"unspawned": 0,
|
||||
"bossHpLeft": null,
|
||||
"rngDraws": 279,
|
||||
"errors": {"count":0,"kinds":[]},
|
||||
"players": {
|
||||
"p1": {"killed":158,"total":158,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":8,"dmg":924432,"bossDmg":0,"heal":29522,"deaths":0,"layerGains":{"sargonShip":16}}
|
||||
},
|
||||
"units": [
|
||||
["p1",1,"op","chess_char_6_03_b",2,"uniequip_002_acfend",4974.95,1,8,0,0,0,0,0,0],
|
||||
["p1",2,"op","chess_char_6_02_b",2,"uniequip_002_acpion",1904,1,6,0,0,0,0,0,0],
|
||||
["p1",3,"op","chess_char_6_07_b",0,"uniequip_002_sharp2",2494,1,9,0,0,0,0,0,0],
|
||||
["p1",4,"op","chess_char_6_10_b",0,"uniequip_002_sharp2",2494,1,11,24,119442,0,0,173,3],
|
||||
["p1",5,"op","chess_char_6_11_b",2,"uniequip_002_acpion",1624,1,13,38,184185,11513,0,52,20],
|
||||
["p1",6,"op","chess_char_6_06_b",2,"uniequip_002_acguad",3396,1,14,19,66075,855,0,156,4],
|
||||
["p1",7,"op","chess_char_5_22_b",2,"uniequip_002_acnipe",1587,1,4,0,0,0,0,0,0],
|
||||
["p1",8,"op","chess_char_6_01_b",2,"uniequip_002_acnipe",1643,1,5,0,0,0,0,0,0],
|
||||
["p1",9,"op","chess_char_6_04_b",2,"uniequip_002_acsupo",83.4,1,15,11,95944,2382,0,135,2],
|
||||
["p1",10,"op","chess_char_6_05_b",2,"uniequip_002_acnipe",1643,1,7,19,77978,0,0,93,2],
|
||||
["p1",11,"op","chess_char_6_08_b",2,"uniequip_002_accast",1819,1,10,47,380809,0,0,82,2],
|
||||
["p1",12,"op","chess_char_6_09_b",2,"uniequip_002_acmedc",1324,1,12,0,0,17709,29522,83,2],
|
||||
["-","~1","device","trap_1105_accrate",null,null,100,1,1,0,0,0,0,0,null],
|
||||
["-","~2","device","trap_1105_accrate",null,null,100,1,2,0,0,0,0,0,null],
|
||||
["-","~3","device","trap_1105_accrate",null,null,100,1,3,0,0,0,0,0,null]
|
||||
],
|
||||
"enemies": {
|
||||
"enemy_1121_lifbos_2": [1,1,0,0,24000],
|
||||
"enemy_1148_dssbr": [1,1,0,0,2688],
|
||||
"enemy_1148_dssbr_2": [1,1,0,0,3456],
|
||||
"enemy_1158_divman": [1,1,0,195,3168],
|
||||
"enemy_1160_hvyslr": [1,1,0,0,9600],
|
||||
"enemy_1160_hvyslr_2": [1,1,0,0,13440],
|
||||
"enemy_1161_tidmag": [1,1,0,1363,5760],
|
||||
"enemy_1161_tidmag_2": [1,1,0,824,7680],
|
||||
"enemy_1249_lysdb_2": [60,60,0,1374,345600],
|
||||
"enemy_1251_lysyta": [51,51,0,0,134640],
|
||||
"enemy_1329_cbshld": [39,39,0,3643,374400]
|
||||
},
|
||||
"hooks": [1,173,5986,867,867,1620,1744,149,158,158,35,34,0,31554,3298,63,158,0,1,158,2,1,21],
|
||||
"events": {"atk":965,"bounty":4,"deploy":12,"die":158,"dmg":1722,"fx":309,"heal":116,"layer":2,"skill":69,"spawn":173,"status":425},
|
||||
"fx": {"aoe":2,"burst":1,"dodge":27,"dp":58,"garrison":2,"shieldBreak":60,"slash":146,"sp":13},
|
||||
"snaps": "268546d4 ec010f3d d556f759 749d85b5 fd1848fa 9a69b0ed 35a96974 ceeb72dd adfeed08 f497b058 ffade80c ec17b5c7 49c46293 8eebc960 34dab525 36f0055b b3c7d1bd 036e5819 41df2c8f 33def1f4"
|
||||
},
|
||||
"standin-10": {
|
||||
"about": "elite band 7 as stand-ins: chess_char_6_17_b→char_608_acpion chess_char_6_13_b→char_615_acspec chess_char_6_19_b→char_615_acspec chess_char_6_12_b→char_612_accast chess_char_6_15_b→char_614_acsupo chess_char_6_18_b→char_612_accast chess_char_6_20_b→char_613_acmedc",
|
||||
"fast": false,
|
||||
"kind": "normal",
|
||||
"stageId": "act1autochess_m07",
|
||||
"modeId": "mode_single_hard",
|
||||
"round": 13,
|
||||
"seed": 3949114127,
|
||||
"waveId": "act1autochess_h06",
|
||||
"time": 216.2,
|
||||
"ticks": 6486,
|
||||
"reason": "cleared",
|
||||
"killed": 25,
|
||||
"total": 131,
|
||||
"unspawned": 0,
|
||||
"bossHpLeft": null,
|
||||
"rngDraws": 245,
|
||||
"errors": {"count":0,"kinds":[]},
|
||||
"players": {
|
||||
"p1": {"killed":25,"total":131,"perfect":false,"leaks":106,"leaksUncounted":0,"coins":0,"dmg":508719,"bossDmg":0,"heal":10260,"deaths":25,"layerGains":{"raidShip":6}}
|
||||
},
|
||||
"units": [
|
||||
["p1",1,"op","chess_char_6_17_b",2,"uniequip_002_acpion",0,0,164,8,199340,14855,0,64,9],
|
||||
["p1",2,"op","chess_char_6_13_b",2,"uniequip_002_acspec",0,0,163,6,43893,11872,0,99,0],
|
||||
["p1",3,"op","chess_char_6_19_b",2,"uniequip_002_acspec",1484,1,165,3,65411,16324,0,126,0],
|
||||
["p1",4,"op","chess_char_6_12_b",2,"uniequip_002_accast",2265.49,1,4,4,78264,281,0,32,2],
|
||||
["p1",5,"op","chess_char_6_15_b",2,"uniequip_002_acsupo",0,0,147,2,52466,6493,0,38,3],
|
||||
["p1",6,"op","chess_char_6_18_b",2,"uniequip_002_accast",1819,1,8,2,69344,618,0,44,2],
|
||||
["p1",7,"op","chess_char_6_20_b",2,"uniequip_002_acmedc",1464,1,9,0,0,3373,10260,17,1],
|
||||
["-","~1","device","trap_1105_accrate",null,null,100,1,1,0,0,0,0,0,null],
|
||||
["-","~2","device","trap_1105_accrate",null,null,100,1,2,0,0,0,0,0,null]
|
||||
],
|
||||
"enemies": {
|
||||
"enemy_1006_shield_3": [21,1,20,14948,163592],
|
||||
"enemy_1043_zomsbr": [66,20,46,789,124139],
|
||||
"enemy_1162_magmot": [1,0,1,4063,11534],
|
||||
"enemy_1165_duhond": [1,1,0,139,4645],
|
||||
"enemy_1165_duhond_2": [1,0,1,0,0],
|
||||
"enemy_1166_dusbr": [1,0,1,0,0],
|
||||
"enemy_1166_dusbr_2": [1,0,1,0,0],
|
||||
"enemy_1168_dumage": [1,1,0,1476,7465],
|
||||
"enemy_1168_dumage_2": [1,0,1,3178,0],
|
||||
"enemy_1169_duphlx": [1,0,1,0,0],
|
||||
"enemy_1402_tgshd_2": [36,2,34,29224,197345]
|
||||
},
|
||||
"hooks": [1,165,6486,549,549,865,877,6809,50,159,17,36,0,8486,1125,49,131,106,1,50,1,1,0],
|
||||
"events": {"atk":554,"deploy":32,"die":53,"dmg":802,"fx":530,"heal":21,"layer":1,"leak":106,"skill":34,"spawn":140,"status":378},
|
||||
"fx": {"aoe":4,"blink":3,"burst":1,"displace":17,"dodge":3,"dp":17,"expose":414,"garrison":1,"garrisonGrant":3,"slash":67},
|
||||
"snaps": "80709a69 1b180600 2040e410 16970933 0b0b610d 4e3060e6 d2f5fa1d d015c650 26f1c266 9350b3c3 ad710861 7c181d0b d41c00a0 64db1e01 858cfe5f 40faa038 5cf23687 498f5f47 50bc9ad2 848c94df 4806ebb3 9bfdaf83"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
// test/lobby-ownership.test.js — room.ownership end to end over WebSocket (0.2.0 补位, 干员持有): lenient validation
|
||||
// (only a malformed list is refused; non-droppable ids are dropped), storage on the session (follows the player into
|
||||
// rooms, survives a resume) and on the seat, seats[].notOwned handed to the Match when it starts (bots: none), never to a
|
||||
// running match (ROOM_STARTED: stored for the next one — the setting is out of match), a spectator's kept on its session,
|
||||
// and the real match exposing it as m.private.standIns.
|
||||
import { describe, test, before, after, afterEach } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { startServer } from '../server/index.js';
|
||||
import { StubMatch } from '../server/match/StubMatch.js';
|
||||
import { TestClient } from './helpers/wsClient.js';
|
||||
import { ERR, PHASE } from '../shared/constants.js';
|
||||
|
||||
const SILVER = 'chess_char_4_22_a'; // 银灰 (NORMAL)
|
||||
const SARIA = 'chess_char_5_11_a'; // 塞雷娅 (NORMAL)
|
||||
const CATHY = 'chess_char_4_11_a'; // 凯瑟琳 (PRESET)
|
||||
|
||||
class RecordingStub extends StubMatch {
|
||||
static instances = [];
|
||||
constructor(opts) { super(opts); this.opts = opts; RecordingStub.instances.push(this); }
|
||||
}
|
||||
|
||||
function clientPool(getUrl) {
|
||||
const open = new Set();
|
||||
return {
|
||||
async player(name, token) {
|
||||
const c = await TestClient.connect(getUrl());
|
||||
open.add(c);
|
||||
const w = await c.hello(name, token);
|
||||
c.id = w.playerId;
|
||||
c.token = w.token;
|
||||
return c;
|
||||
},
|
||||
async closeAll() {
|
||||
await Promise.all([...open].map((c) => c.terminate().catch(() => {})));
|
||||
open.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
const quietLog = () => {
|
||||
const errors = [];
|
||||
return { errors, log: { info() {}, warn() {}, debug() {}, error: (...a) => errors.push(a.map(String).join(' ')) } };
|
||||
};
|
||||
const ok = async (c, msg) => { const r = await c.request(msg); assert.equal(r.t, 'ok', `${msg.t}: ${JSON.stringify(r)}`); return r; };
|
||||
const err = async (c, msg, code) => { const r = await c.request(msg); assert.equal(r.t, 'error', JSON.stringify(r)); assert.equal(r.code, code, JSON.stringify(r)); return r; };
|
||||
async function createRoom(c, mode = 'coop', difficulty = 'NORMAL') {
|
||||
await ok(c, { t: 'room.create', mode, difficulty });
|
||||
return c.waitFor('room.state', (s) => s.hostId === c.id && s.mode === mode);
|
||||
}
|
||||
|
||||
describe('room.ownership (lobby, stub match)', () => {
|
||||
let srv;
|
||||
let pool;
|
||||
const cap = quietLog();
|
||||
before(async () => {
|
||||
srv = await startServer({ port: 0, host: '127.0.0.1', log: cap.log, MatchClass: RecordingStub, heavyBurst: 12, heavyPerSec: 4 });
|
||||
pool = clientPool(() => `ws://127.0.0.1:${srv.port}/ws`);
|
||||
});
|
||||
afterEach(async () => { RecordingStub.instances = []; await pool.closeAll(); });
|
||||
after(async () => { await srv?.close(); });
|
||||
|
||||
test('lenient: a malformed list is BAD_MSG (nothing stored); other ids are dropped; stored per session and seat; bots none', async () => {
|
||||
const host = await pool.player('Host');
|
||||
await err(host, { t: 'room.ownership', notOwned: { [SILVER]: true } }, ERR.BAD_MSG);
|
||||
await err(host, { t: 'room.ownership', notOwned: ['bad id'] }, ERR.BAD_MSG);
|
||||
// outside a room: accepted (PRESET / elite / unknown ids dropped), kept on the session
|
||||
await ok(host, { t: 'room.ownership', notOwned: [SARIA, CATHY, 'chess_char_9_99_a', `${SILVER.slice(0, -1)}b`, SILVER, SILVER] });
|
||||
const st = await createRoom(host);
|
||||
const guest = await pool.player('Guest');
|
||||
await ok(guest, { t: 'room.join', code: st.code });
|
||||
await guest.waitFor('room.state', (s) => s.seats.some((x) => x && x.playerId === guest.id));
|
||||
await ok(guest, { t: 'room.ownership', notOwned: [SARIA] });
|
||||
await ok(guest, { t: 'room.ownership', notOwned: [SILVER] }); // re-sent: the latest wins
|
||||
await ok(host, { t: 'room.addBot' });
|
||||
// the guest drops and resumes: the session keeps its list
|
||||
await guest.terminate();
|
||||
const back = await TestClient.connect(`ws://127.0.0.1:${srv.port}/ws`);
|
||||
await back.hello('Guest', guest.token);
|
||||
await ok(back, { t: 'room.ready', ready: true });
|
||||
await ok(host, { t: 'room.start' });
|
||||
await host.waitFor('m.public', (p) => p.phase === 'INFO_CHECK');
|
||||
const seats = RecordingStub.instances.at(-1).opts.seats;
|
||||
const by = (id) => seats.find((s) => s.playerId === id);
|
||||
assert.deepEqual(by(host.id).notOwned, [SILVER, SARIA].sort());
|
||||
assert.ok(Object.isFrozen(by(host.id).notOwned));
|
||||
assert.deepEqual(by(guest.id).notOwned, [SILVER]);
|
||||
assert.equal(seats.find((s) => s.isBot).notOwned, null, 'bots own every operator');
|
||||
// during the match: refused for this match (ROOM_STARTED) but stored for the next one
|
||||
const r = await err(host, { t: 'room.ownership', notOwned: [] }, ERR.ROOM_STARTED);
|
||||
assert.match(r.detail || '', /next match/);
|
||||
await back.terminate();
|
||||
assert.deepEqual(cap.errors, []);
|
||||
});
|
||||
|
||||
test('a spectator\'s list stays on its session (never on a seat, never handed to the match)', async () => {
|
||||
const host = await pool.player('Host2');
|
||||
const st = await createRoom(host);
|
||||
const spec = await pool.player('Watcher');
|
||||
await ok(spec, { t: 'room.spectate', code: st.code });
|
||||
await spec.waitFor('room.state', (s) => (s.spectators || []).some((x) => x.playerId === spec.id));
|
||||
await ok(spec, { t: 'room.ownership', notOwned: [SILVER] });
|
||||
await ok(host, { t: 'room.start' });
|
||||
await host.waitFor('m.public', (p) => p.phase === 'INFO_CHECK');
|
||||
const seats = RecordingStub.instances.at(-1).opts.seats;
|
||||
assert.ok(!seats.some((s) => s.playerId === spec.id));
|
||||
assert.equal(seats[0].notOwned, null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('room.ownership (lobby + real match)', () => {
|
||||
let srv;
|
||||
let pool;
|
||||
const cap = quietLog();
|
||||
before(async () => {
|
||||
srv = await startServer({ port: 0, host: '127.0.0.1', log: cap.log, seedFn: () => 4242 });
|
||||
pool = clientPool(() => `ws://127.0.0.1:${srv.port}/ws`);
|
||||
});
|
||||
afterEach(async () => { await pool.closeAll(); });
|
||||
after(async () => { await srv?.close(); });
|
||||
|
||||
test('the list the seat had at the start is the match\'s m.private.standIns; a change during the briefing waits for the next match', async () => {
|
||||
const a = await pool.player('Solo');
|
||||
await ok(a, { t: 'room.ownership', notOwned: [SILVER] });
|
||||
await createRoom(a, 'solo');
|
||||
await ok(a, { t: 'room.start' });
|
||||
await a.waitFor('m.public', (p) => p.phase === PHASE.INFO_CHECK, 5000);
|
||||
const priv = await a.waitFor('m.private', (p) => Array.isArray(p.standIns), 3000);
|
||||
assert.deepEqual(priv.standIns, [SILVER]);
|
||||
// stored for the next match only: this match keeps [SILVER] (PlayerState.standIns is fixed; test/match/standin-ownership)
|
||||
await err(a, { t: 'room.ownership', notOwned: [SARIA] }, ERR.ROOM_STARTED);
|
||||
await ok(a, { t: 'g.infoReady' });
|
||||
await a.waitFor('m.public', (p) => p.phase === PHASE.BAND_DRAFT, 3000);
|
||||
assert.deepEqual(cap.errors, []);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,230 @@
|
||||
// test/match/standin-ownership.test.js — 0.2.0 补位 end to end on the server (the approved plan, owner's decision
|
||||
// 2026-10-05): the not-owned list (room.ownership → seats[].notOwned) is checked leniently, fixed for the match and
|
||||
// applied to that player's own pieces only — the chess keeps its identity (name, bonds, price, merge) and its battle
|
||||
// unit is the official stand-in (PlayerBattleInput `standIn: true`: the stand-in's body, skill, talents, module, range,
|
||||
// position), in the player's own field, the 联防 field and the boss fields alike, client-run and server-run identical.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { C2S, validateC2S, checkNotOwned, OWNERSHIP_LIMITS, checkLoadout } from '../../shared/protocol.js';
|
||||
import { isDroppableChess, standInRecord } from '../../shared/standIn.js';
|
||||
import { buildBattleSpec, createBattleFromSpec } from '../../server/sim/spec.js';
|
||||
import { DataSource, hasGeneratedData } from '../../server/sim/simdata.js';
|
||||
import { uniteBattleOpts } from '../../server/match/unite.js';
|
||||
import { placeClass } from '../../server/match/board.js';
|
||||
import { PHASE, GEO } from '../../shared/constants.js';
|
||||
import { DATA, makeMatch, give, legalTileFor } from './harness.js';
|
||||
|
||||
const REAL = { skip: !hasGeneratedData() };
|
||||
const chess = (id) => (Object.hasOwn(DATA.chess, id) ? DATA.chess[id] : null);
|
||||
const wire = (msg) => JSON.parse(JSON.stringify(msg));
|
||||
|
||||
const SILVER = 'chess_char_4_22_a'; // 银灰 (NORMAL) → Sharp S2, melee
|
||||
const MLYSS = 'chess_char_6_11_a'; // 缪尔赛思 (NORMAL, RANGED, summons 流形) → 郁金香 S3 + SOL-X, melee
|
||||
const SARIA = 'chess_char_5_11_a'; // 塞雷娅 (NORMAL, MELEE) → Touch S3 + PHY-X, ranged
|
||||
const CATHY = 'chess_char_4_11_a'; // 凯瑟琳 (PRESET): never droppable
|
||||
const INSIDE = 'chess_char_1_01_a'; // 隐现 (PRESET) with a loadout choice
|
||||
|
||||
test('droppable chess = the 55 NORMAL base chess; checkNotOwned keeps those, drops the rest; the wire validator is structural', () => {
|
||||
const all = Object.values(DATA.chess);
|
||||
const drop = all.filter(isDroppableChess);
|
||||
assert.equal(drop.length, 55);
|
||||
assert.ok(drop.every((c) => c.chessType === 'NORMAL' && !c.isGolden && c.backup.charId !== c.charId));
|
||||
assert.equal(all.filter((c) => c.chessType === 'PRESET' && isDroppableChess(c)).length, 0, 'PRESET always fields its own operator');
|
||||
assert.equal(drop.filter((c) => c.visible).length, 53, '53 in this season\'s shop (2 retired forms are hidden)');
|
||||
const res = checkNotOwned([SARIA, SILVER, SILVER, CATHY, chess(SILVER).goldenId, 'chess_char_9_99_a', 'chess_diy_5_1_a', MLYSS], chess);
|
||||
assert.deepEqual(res, { ok: true, notOwned: [SILVER, SARIA, MLYSS].sort(), dropped: 5 });
|
||||
assert.deepEqual(checkNotOwned([], chess), { ok: true, notOwned: [], dropped: 0 });
|
||||
for (const bad of [null, {}, 'x', [1], ['a b'], new Array(OWNERSHIP_LIMITS.notOwned + 1).fill(SILVER)]) {
|
||||
assert.equal(checkNotOwned(bad, chess).error, 'BAD_MSG', JSON.stringify(bad)?.slice(0, 40));
|
||||
}
|
||||
assert.ok(C2S['room.ownership']);
|
||||
assert.equal(validateC2S({ t: 'room.ownership', notOwned: [SILVER, 'whatever_id'] }), null);
|
||||
assert.match(validateC2S({ t: 'room.ownership', notOwned: { a: 1 } }), /bad field notOwned/);
|
||||
assert.match(validateC2S({ t: 'room.ownership' }), /bad field notOwned/);
|
||||
});
|
||||
|
||||
/** A co-op match: p_0 does not own 银灰 / 缪尔赛思 / 塞雷娅 (+ junk), p_1 owns everything, the bot never drops. */
|
||||
function ownershipMatch(extra = {}) {
|
||||
const loadout = checkLoadout({ [INSIDE]: { skill: 0 } }, chess).loadout;
|
||||
const seats = [
|
||||
{ seat: 0, playerId: 'p_0', name: 'P0', isBot: false, connected: true, loadout, notOwned: [SILVER, MLYSS, SARIA, CATHY, 'junk_id'] },
|
||||
{ seat: 1, playerId: 'p_1', name: 'P1', isBot: false, connected: true },
|
||||
{ seat: 2, playerId: 'ai_0', name: 'AI0', isBot: true, connected: true, notOwned: [SILVER] },
|
||||
];
|
||||
return makeMatch({ mode: 'coop', seats, seed: 11, ...extra });
|
||||
}
|
||||
function clearBoard(ps) {
|
||||
for (const p of [...ps.board.values()]) ps.returnCopies(p);
|
||||
ps.board.clear();
|
||||
for (let i = 0; i < ps.hand.length; i++) if (ps.hand[i]) { ps.returnCopies(ps.hand[i]); ps.hand[i] = null; }
|
||||
ps.recompute();
|
||||
}
|
||||
|
||||
test('seats[].notOwned → PlayerState.standIns (re-checked, frozen; bots own everything); m.private.standIns', REAL, () => {
|
||||
const h = ownershipMatch().start();
|
||||
const p0 = h.ps('p_0');
|
||||
assert.deepEqual([...p0.standIns], [SILVER, SARIA, MLYSS].sort());
|
||||
assert.ok(Object.isFrozen(p0.standIns));
|
||||
assert.deepEqual([...h.ps('p_1').standIns], []);
|
||||
assert.deepEqual([...h.ps('ai_0').standIns], [], 'bots own every operator');
|
||||
h.m.flush(true);
|
||||
assert.deepEqual(h.lastTo('p_0', 'm.private').standIns, [SILVER, SARIA, MLYSS].sort());
|
||||
assert.deepEqual(h.lastTo('p_1', 'm.private').standIns, []);
|
||||
// a malformed list (stale client) changes nothing; a later call cannot happen through the protocol during a match
|
||||
assert.equal(p0.setNotOwned({ bad: true }), false);
|
||||
assert.equal(p0.standIns.length, 3);
|
||||
h.m.dispose();
|
||||
});
|
||||
|
||||
test('a not-owned chess keeps its identity and fights as the stand-in: backup selection over the loadout, the stand-in\'s position, no summons', REAL, () => {
|
||||
const h = ownershipMatch().start();
|
||||
const m = h.m;
|
||||
h.toPrep(1);
|
||||
const p0 = h.ps('p_0');
|
||||
const p1 = h.ps('p_1');
|
||||
for (const [id, body, skill] of [[SILVER, 'char_609_acguad', 1], [chess(SILVER).goldenId, 'char_609_acguad', 1], [MLYSS, 'char_608_acpion', 2]]) {
|
||||
const rec = chess(id);
|
||||
assert.ok(p0.fieldsStandIn(rec) && p0.fieldsStandIn(id), id);
|
||||
assert.ok(!p1.fieldsStandIn(rec), 'only the player who dropped it');
|
||||
const f = p0.fieldRecord(rec);
|
||||
assert.equal(f.charId, body);
|
||||
assert.equal(f.standInFor, rec.charId);
|
||||
assert.deepEqual([f.chessId, f.tier, f.price, f.bonds], [rec.chessId, rec.tier, rec.price, rec.bonds], 'identity kept');
|
||||
assert.equal(p0.loadoutFor(rec).skillIndex, skill, 'the backup skill');
|
||||
assert.equal(p1.fieldRecord(rec), rec);
|
||||
}
|
||||
// the elite's module is the backup's (郁金香 SOL-X on 缪尔赛思's elite), never the player's loadout
|
||||
assert.equal(p0.loadoutFor(chess(chess(MLYSS).goldenId)).moduleId, chess(chess(MLYSS).goldenId).backup.uniEquipId);
|
||||
assert.equal(p0.loadoutFor(chess(INSIDE)).skillIndex, 0, 'an owned chess keeps its loadout');
|
||||
// placement class = the deployed body's position (缪尔赛思 RANGED → 郁金香 melee; 塞雷娅 MELEE → Touch ranged)
|
||||
assert.equal(placeClass(p1, chess(MLYSS)), 'ranged');
|
||||
assert.equal(placeClass(p0, chess(MLYSS)), 'melee');
|
||||
assert.equal(placeClass(p1, chess(SARIA)), 'melee');
|
||||
assert.equal(placeClass(p0, chess(SARIA)), 'ranged');
|
||||
// summons: an owned 缪尔赛思 on the board sends her 流形 to the hand; a stand-in makes none (none of the 17 has one)
|
||||
clearBoard(p0);
|
||||
clearBoard(p1);
|
||||
const t0 = legalTileFor(m, p0, MLYSS);
|
||||
const t1 = legalTileFor(m, p1, MLYSS);
|
||||
give(m, p0, MLYSS, 'board', t0);
|
||||
give(m, p1, MLYSS, 'board', t1);
|
||||
const tokens = (ps) => [...ps.hand, ...ps.temp].filter((p) => p && p.kind === 'token').map((p) => p.id);
|
||||
assert.deepEqual(tokens(p1), ['token_10030_mlyss_wtrman']);
|
||||
assert.deepEqual(tokens(p0), []);
|
||||
// identity rules still read the chess: sell price, bonds
|
||||
assert.equal(p0.gd.sellPrice(MLYSS), chess(MLYSS).sellPrice);
|
||||
for (const b of chess(MLYSS).bonds) assert.ok(p0.bonds[b] && p0.bonds[b].count >= 1, `bond ${b} counts the stand-in`);
|
||||
h.invariants();
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
/** The browser's DataSource (battle/runner.js loadBrowserSim): own JSON copies of the data files, backups included. */
|
||||
function browserSource() {
|
||||
const copy = (v) => JSON.parse(JSON.stringify(v));
|
||||
return new DataSource({ chess: copy(DATA.chess), enemies: copy(DATA.enemies), tokens: copy(DATA.tokens), stages: copy(DATA.stages), waves: copy(DATA.waves), backups: copy(DATA.backups) }, null);
|
||||
}
|
||||
|
||||
test('battleInput: the player\'s not-owned chess carries standIn: true (no loadout fields); client-run and server-run battles are identical', REAL, () => {
|
||||
const h = ownershipMatch().start();
|
||||
const m = h.m;
|
||||
h.toPrep(1);
|
||||
const p0 = h.ps('p_0');
|
||||
clearBoard(p0);
|
||||
const sg = chess(SILVER).goldenId;
|
||||
const a = give(m, p0, sg, 'board', legalTileFor(m, p0, sg));
|
||||
const b = give(m, p0, INSIDE, 'board', legalTileFor(m, p0, INSIDE));
|
||||
const c = give(m, p0, SARIA, 'board', legalTileFor(m, p0, SARIA));
|
||||
const input = p0.battleInput();
|
||||
const by = new Map(input.units.map((u) => [u.uid, u]));
|
||||
assert.equal(by.get(a.uid).standIn, true);
|
||||
assert.ok(!('skillIndex' in by.get(a.uid)) && !('moduleId' in by.get(a.uid)), 'a stand-in carries no loadout fields');
|
||||
assert.equal(by.get(c.uid).standIn, true);
|
||||
assert.ok(!('standIn' in by.get(b.uid)));
|
||||
assert.equal(by.get(b.uid).skillIndex, 0, 'the owned chess keeps its loadout');
|
||||
// the production spec path keeps the mark; both data sources field the same stand-in and fight the same battle
|
||||
const wave = m.wave;
|
||||
const spec = wire(buildBattleSpec({
|
||||
battleId: 't.1', fieldId: 'n:p_0', kind: 'normal', seed: 77, modeId: m.modeId, round: 1, stageId: m.stageId,
|
||||
rect: { ...GEO.NORMAL_RECT }, timeLimit: 40, players: [input], spawns: wave.spawns.map((s) => ({ ...s, ownerPlayerId: 'p_0' })),
|
||||
routes: wave.routes, flags: { layerGainsEnabled: true, ...m.gd.dp }, enemyOverrides: wave.overrides ?? {}, content: 'full',
|
||||
}));
|
||||
assert.equal(spec.players[0].units.find((u) => u.uid === a.uid).standIn, true);
|
||||
const run = (ds) => {
|
||||
const bt = createBattleFromSpec(wire(spec), ds, { quiet: true, recordEvents: false });
|
||||
const u = bt.allyUnits.find((x) => x.uid === a.uid);
|
||||
const def = { charId: u.def.charId, standInFor: u.def.standInFor, skill: u.def.skill?.id, bonds: u.def.bonds, name: u.name };
|
||||
let n = 0;
|
||||
while (!bt.finished && n++ < 30 * 45) bt.step();
|
||||
const res = bt.result();
|
||||
return { def, time: res.time, killed: res.killed, units: bt.allyUnits.map((x) => [x.uid, x.defId, Math.round(x.hp * 100), x.stats?.dmg | 0, x.skill?.activations ?? null]), rng: bt.rng.state() };
|
||||
};
|
||||
const server = run(m.ds);
|
||||
const client = run(browserSource());
|
||||
assert.equal(server.def.charId, 'char_609_acguad');
|
||||
assert.equal(server.def.standInFor, chess(sg).charId);
|
||||
assert.equal(server.def.skill, standInRecord(chess(sg), DATA.backups).skill.skillId);
|
||||
assert.deepEqual(server.def.bonds, chess(sg).bonds);
|
||||
assert.deepEqual(client, server, 'the browser\'s battle equals the server\'s');
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
test('联防 and the Final Assault field carry each player\'s own marks (p_0 stand-in, p_1 the real operator)', REAL, () => {
|
||||
const h = ownershipMatch({ fake: true, clientCombat: true, clients: false }).start();
|
||||
const m = h.m;
|
||||
// 联防: the helpers' inputs (unite.js uniteBattleOpts → PlayerState.battleInput with carry / reached)
|
||||
h.toPrep(1);
|
||||
const p0 = h.ps('p_0');
|
||||
const p1 = h.ps('p_1');
|
||||
clearBoard(p0);
|
||||
clearBoard(p1);
|
||||
const a0 = give(m, p0, SILVER, 'board', legalTileFor(m, p0, SILVER));
|
||||
const a1 = give(m, p1, SILVER, 'board', legalTileFor(m, p1, SILVER));
|
||||
const { players } = uniteBattleOpts(m, { helpers: [p0, p1], leaked: [] }, 60);
|
||||
const u0 = players.find((p) => p.playerId === 'p_0').units.find((u) => u.uid === a0.uid);
|
||||
const u1 = players.find((p) => p.playerId === 'p_1').units.find((u) => u.uid === a1.uid);
|
||||
assert.equal(u0.standIn, true);
|
||||
assert.ok(!('standIn' in u1) && Number.isInteger(u1.skillIndex));
|
||||
m.dispose();
|
||||
|
||||
// the boss field: jump to the Final Assault prep, deploy, and read the field's BattleSpec
|
||||
const hb = ownershipMatch({ fake: true, clientCombat: true, clients: false });
|
||||
const mb = hb.m;
|
||||
const orig = mb.startRound.bind(mb);
|
||||
let jumped = false;
|
||||
mb.startRound = (r) => { if (!jumped) { jumped = true; return orig(mb.gd.bossRound); } return orig(r); };
|
||||
hb.start();
|
||||
hb.toPrep(mb.gd.bossRound);
|
||||
const b0 = hb.ps('p_0');
|
||||
const b1 = hb.ps('p_1');
|
||||
clearBoard(b0);
|
||||
clearBoard(b1);
|
||||
const s0 = give(mb, b0, SARIA, 'board', legalTileFor(mb, b0, SARIA));
|
||||
const s1 = give(mb, b1, SARIA, 'board', legalTileFor(mb, b1, SARIA));
|
||||
hb.drive(() => mb.phase === PHASE.FINAL_ASSAULT);
|
||||
assert.equal(mb.phase, PHASE.FINAL_ASSAULT);
|
||||
const units = mb.fields.flatMap((f) => (f.spec ? f.spec.players : [])).flatMap((p) => p.units.map((u) => ({ ...u, owner: p.playerId })));
|
||||
const f0 = units.find((u) => u.uid === s0.uid);
|
||||
const f1 = units.find((u) => u.uid === s1.uid);
|
||||
assert.ok(f0 && f1, 'both deployed on the boss field');
|
||||
assert.equal(f0.standIn, true);
|
||||
assert.ok(!('standIn' in f1));
|
||||
mb.dispose();
|
||||
});
|
||||
|
||||
test('prep scouting (m.field): a teammate\'s stand-in shows the stand-in\'s art on the board, the original in the hand (standInFor on both)', REAL, () => {
|
||||
const h = ownershipMatch().start();
|
||||
const m = h.m;
|
||||
h.toPrep(1);
|
||||
const p0 = h.ps('p_0');
|
||||
clearBoard(p0);
|
||||
const onBoard = give(m, p0, SILVER, 'board', legalTileFor(m, p0, SILVER));
|
||||
const held = give(m, p0, MLYSS, 'hand');
|
||||
assert.deepEqual(m.handle('p_1', { t: 'g.watch', fieldId: 'n:p_0' }), { ok: true });
|
||||
const meta = wire(h.lastTo('p_1', 'm.field'));
|
||||
const by = new Map(meta.units.map((u) => [u.uid, u]));
|
||||
const bu = by.get(onBoard.uid);
|
||||
assert.deepEqual([bu.defId, bu.name, bu.spine, bu.avatar, bu.standInFor, bu.skillIndex], [SILVER, 'Sharp', 'char_609_acguad', 'char_609_acguad', chess(SILVER).charId, 1]);
|
||||
const hu = by.get(held.uid);
|
||||
assert.deepEqual([hu.defId, hu.name, hu.spine, hu.standInFor], [MLYSS, chess(MLYSS).name, chess(MLYSS).assets.spine, chess(MLYSS).charId]);
|
||||
m.dispose();
|
||||
});
|
||||
@@ -0,0 +1,265 @@
|
||||
// 0.2.0 补位 — client side (the approved plan, owner's decision 2026-10-05): the 干员持有 tab's model (storage, toggles,
|
||||
// the roster by tier, export / import) and its server sync (room.ownership), and the match UI of a not-owned chess —
|
||||
// the shop / reward card keeps the chess (name, bonds, price) with a 「替补:X」 badge and the stand-in's skill, the own
|
||||
// pieces' tags, the board / deploy rules of the stand-in's body (position, range), and the detail card's body (portrait,
|
||||
// class, skill, talents) from the stand-in for the player's own pieces (m.private.standIns) and any unit carrying
|
||||
// UnitInfo `standInFor`.
|
||||
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)), '../..');
|
||||
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 M = await import('../../public/js/ui/ownershipModel.js');
|
||||
const { installOwnershipSync, SYNC_DEBOUNCE_MS } = await import('../../public/js/ui/loadoutSync.js');
|
||||
const { createStore } = await import('../../public/js/store.js');
|
||||
const G = await import('../../public/js/ui/gameLogic.js');
|
||||
const { ChessCard } = await import('../../public/js/ui/shopBar.js');
|
||||
const { ChessDetail, resolveDetail } = await import('../../public/js/ui/detailPanel.js');
|
||||
const { standInPieces } = await import('../../public/js/screens/game/standInTags.js');
|
||||
const { OwnCard } = await import('../../public/js/screens/ownership.js');
|
||||
const { data } = await import('../../public/js/data.js');
|
||||
const { DATA } = await import('../match/harness.js');
|
||||
|
||||
await data.loadAll('chess', 'bonds', 'assets', 'garrisons', 'items', 'backups');
|
||||
|
||||
const getChess = (id) => (Object.hasOwn(DATA.chess, id) ? DATA.chess[id] : null);
|
||||
const BACKUPS = DATA.backups;
|
||||
const SILVER = 'chess_char_4_22_a'; // 银灰 → Sharp (S2 亮剑)
|
||||
const MLYSS = 'chess_char_6_11_a'; // 缪尔赛思 (RANGED) → 郁金香 (MELEE)
|
||||
const SARIA = 'chess_char_5_11_a'; // 塞雷娅 (MELEE) → Touch (RANGED)
|
||||
const CATHY = 'chess_char_4_11_a'; // 凯瑟琳 (PRESET)
|
||||
|
||||
function* walk(v) {
|
||||
if (Array.isArray(v)) { for (const x of v) yield* walk(x); return; }
|
||||
if (!v || typeof v !== 'object') return;
|
||||
yield v;
|
||||
yield* walk(v.props?.children);
|
||||
}
|
||||
const hasClass = (v, c) => typeof v?.props?.class === 'string' && v.props.class.split(/\s+/).includes(c);
|
||||
const textOf = (v) => [...walk(v)].flatMap((n) => (Array.isArray(n.props?.children) ? n.props.children : [n.props?.children])).filter((x) => typeof x === 'string' || typeof x === 'number').join('');
|
||||
|
||||
describe('干员持有 model (ui/ownershipModel.js)', () => {
|
||||
test('storage: tolerant parse, clean sorted ids, round trip; hostile keys dropped', () => {
|
||||
assert.deepEqual(M.parseStoredOwnership(null), []);
|
||||
assert.deepEqual(M.parseStoredOwnership({ v: 1, notOwned: [SILVER, 'bad id', SILVER, '__proto__', 7, SARIA] }), [SILVER, SARIA].sort());
|
||||
assert.deepEqual(M.parseStoredOwnership([MLYSS]), [MLYSS], 'a bare array of an older build');
|
||||
assert.deepEqual(M.toStoredOwnership([SARIA, SILVER]), { v: M.OWNERSHIP_VERSION, notOwned: [SILVER, SARIA].sort() });
|
||||
assert.deepEqual(M.sanitizeNotOwned([SILVER, CATHY, 'chess_char_9_99_a', getChess(SILVER).goldenId], getChess), [SILVER], 'droppable chess only');
|
||||
});
|
||||
test('toggles: setOwned / isOwned return new sorted lists', () => {
|
||||
let list = [];
|
||||
list = M.setOwned(list, SARIA, false);
|
||||
list = M.setOwned(list, SILVER);
|
||||
assert.deepEqual(list, [SILVER, SARIA].sort());
|
||||
assert.equal(M.isOwned(list, SILVER), false);
|
||||
assert.equal(M.isOwned(list, MLYSS), true);
|
||||
assert.deepEqual(M.setOwned(list, SILVER, true), [SARIA]);
|
||||
assert.deepEqual(list, [SILVER, SARIA].sort(), 'input unchanged');
|
||||
});
|
||||
test('roster: the 53 NORMAL chess of the shop by tier (6 / 13 / 17 / 17); each has its stand-in summary', () => {
|
||||
const roster = M.ownershipRoster(data.list('chess'));
|
||||
assert.equal(roster.length, 53);
|
||||
assert.ok(!roster.some((c) => c.chessType !== 'NORMAL' || c.isGolden || !c.visible));
|
||||
assert.deepEqual(M.rosterByTier(roster).map((g) => [g.tier, g.list.length]), [[3, 6], [4, 13], [5, 17], [6, 17]]);
|
||||
for (const c of roster) {
|
||||
const si = M.standInSummary(c, BACKUPS);
|
||||
assert.ok(si && si.charId === c.backup.charId && si.skill.index === c.backup.skillIndex, c.chessId);
|
||||
}
|
||||
assert.equal(M.standInSummary(getChess(SILVER), BACKUPS).name, 'Sharp');
|
||||
assert.equal(M.notOwnedCount([SILVER, CATHY, 'chess_char_6_10_a'], roster), 1, 'PRESET / retired ids do not count');
|
||||
});
|
||||
test('export / import: versioned envelope, bare arrays and text; junk, newer versions and other kinds refused', () => {
|
||||
const text = M.serializeOwnership([SARIA, SILVER], { now: 0 });
|
||||
const env = JSON.parse(text);
|
||||
assert.deepEqual([env.kind, env.v, env.count, env.notOwned], [M.OWNERSHIP_EXPORT_KIND, 1, 2, [SILVER, SARIA].sort()]);
|
||||
assert.deepEqual(M.parseOwnershipImport(text), { ok: true, notOwned: [SILVER, SARIA].sort() });
|
||||
assert.deepEqual(M.parseOwnershipImport([MLYSS]), { ok: true, notOwned: [MLYSS] });
|
||||
assert.deepEqual(M.parseOwnershipImport({ v: 1, notOwned: [] }), { ok: true, notOwned: [] }, 'an empty list = everything owned');
|
||||
assert.equal(M.parseOwnershipImport('').ok, false);
|
||||
assert.equal(M.parseOwnershipImport('{nope').ok, false);
|
||||
assert.equal(M.parseOwnershipImport({ v: 9, notOwned: [] }).ok, false);
|
||||
assert.equal(M.parseOwnershipImport({ kind: 'stronghold.loadout', v: 1, entries: {} }).ok, false);
|
||||
assert.equal(M.parseOwnershipImport('x'.repeat(M.OWNERSHIP_IMPORT_MAX_BYTES + 1)).ok, false);
|
||||
});
|
||||
test('the 干员持有 card is a switch: operator → stand-in; a tap asks for the other state', () => {
|
||||
const calls = [];
|
||||
const card = OwnCard({ m: data.get('assets'), chess: getChess(SILVER), backups: BACKUPS, owned: true, onToggle: (id, owned) => calls.push([id, owned]) });
|
||||
assert.equal(card.props.role, 'switch');
|
||||
assert.equal(card.props['aria-checked'], 'true');
|
||||
assert.ok(textOf(card).includes('银灰') && textOf(card).includes('Sharp') && textOf(card).includes('S2'));
|
||||
card.props.onClick();
|
||||
assert.deepEqual(calls, [[SILVER, false]]);
|
||||
const off = OwnCard({ m: null, chess: getChess(SILVER), backups: BACKUPS, owned: false, onToggle() {} });
|
||||
assert.ok(hasClass(off, 'is-off'));
|
||||
assert.equal(off.props['aria-checked'], 'false');
|
||||
});
|
||||
});
|
||||
|
||||
// ---- sync -------------------------------------------------------------------------------------------------------------
|
||||
|
||||
function fakeNet() {
|
||||
const listeners = new Map();
|
||||
const net = {
|
||||
status: 'online', sent: [], replies: [],
|
||||
on(t, fn) { if (!listeners.has(t)) listeners.set(t, new Set()); listeners.get(t).add(fn); return () => listeners.get(t).delete(fn); },
|
||||
emit(t, msg) { for (const fn of listeners.get(t) || []) fn(msg); },
|
||||
request(t, fields) {
|
||||
net.sent.push({ t, ...fields });
|
||||
const r = net.replies.shift();
|
||||
return r && r.error ? Promise.reject(Object.assign(new Error(r.error), { code: r.error })) : Promise.resolve({ t: 'ok' });
|
||||
},
|
||||
};
|
||||
return net;
|
||||
}
|
||||
function fakeTimers() {
|
||||
let now = 0;
|
||||
let seq = 0;
|
||||
const q = new Map();
|
||||
return {
|
||||
setTimeout: (fn, ms) => { const id = ++seq; q.set(id, { at: now + ms, fn }); return id; },
|
||||
clearTimeout: (id) => q.delete(id),
|
||||
async advance(ms) {
|
||||
const end = now + ms;
|
||||
for (;;) {
|
||||
const next = [...q.entries()].filter(([, t]) => t.at <= end).sort((a, b) => a[1].at - b[1].at)[0];
|
||||
if (!next) break;
|
||||
q.delete(next[0]);
|
||||
now = next[1].at;
|
||||
next[1].fn();
|
||||
for (let i = 0; i < 5; i++) await Promise.resolve();
|
||||
}
|
||||
now = end;
|
||||
for (let i = 0; i < 5; i++) await Promise.resolve();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('ownership sync: room.ownership after welcome and after edits (debounced); in a match stored for the next one (told once)', async () => {
|
||||
const net = fakeNet();
|
||||
const T = fakeTimers();
|
||||
const told = [];
|
||||
const target = createStore({ notOwned: [SILVER], open: false, ownSync: 'idle' });
|
||||
const s = installOwnershipSync({ net, timers: T, target, notify: (t) => told.push(t) });
|
||||
net.emit('welcome', {});
|
||||
await T.advance(100);
|
||||
assert.deepEqual(net.sent, [{ t: 'room.ownership', notOwned: [SILVER] }]);
|
||||
assert.equal(target.get().ownSync, 'synced');
|
||||
target.set({ notOwned: [SILVER, SARIA].sort() });
|
||||
await T.advance(SYNC_DEBOUNCE_MS + 10);
|
||||
assert.deepEqual(net.sent[1], { t: 'room.ownership', notOwned: [SILVER, SARIA].sort() });
|
||||
// a running match: ROOM_STARTED = stored for the next match, not an error; the edit is told once
|
||||
net.replies.push({ error: 'ROOM_STARTED' });
|
||||
target.set({ notOwned: [] });
|
||||
await T.advance(SYNC_DEBOUNCE_MS + 10);
|
||||
assert.equal(target.get().ownSync, 'locked');
|
||||
assert.equal(told.length, 1);
|
||||
assert.match(told[0], /下一局生效/);
|
||||
// back in the lobby: sent again (accepted)
|
||||
net.emit('room.state', { inMatch: false });
|
||||
await T.advance(SYNC_DEBOUNCE_MS + 10);
|
||||
assert.deepEqual(net.sent.at(-1), { t: 'room.ownership', notOwned: [] });
|
||||
assert.equal(target.get().ownSync, 'synced');
|
||||
s.dispose();
|
||||
});
|
||||
|
||||
// ---- match UI -------------------------------------------------------------------------------------------------------------
|
||||
|
||||
describe('match UI of a not-owned chess', () => {
|
||||
const priv = { standIns: [SILVER, MLYSS, SARIA], loadout: {}, board: [], hand: [], temp: [] };
|
||||
test('gameLogic: own pieces by m.private.standIns (normal + elite); the composed record keeps the identity', () => {
|
||||
assert.deepEqual(G.standInIds(priv), [SILVER, MLYSS, SARIA]);
|
||||
assert.equal(G.fieldsStandIn(priv, getChess(SILVER)), true);
|
||||
assert.equal(G.fieldsStandIn(priv, getChess(getChess(SILVER).goldenId)), true, 'the elite too');
|
||||
assert.equal(G.fieldsStandIn({ standIns: [] }, getChess(SILVER)), false);
|
||||
assert.equal(G.fieldsStandIn(null, getChess(SILVER)), false);
|
||||
const si = G.standInOf(getChess(SILVER), BACKUPS);
|
||||
assert.deepEqual([si.charId, si.name, si.standInFor, si.chessId, si.price, si.bonds], ['char_609_acguad', 'Sharp', getChess(SILVER).charId, SILVER, getChess(SILVER).price, getChess(SILVER).bonds]);
|
||||
assert.equal(G.standInOf(getChess(SILVER), BACKUPS), si, 'cached');
|
||||
assert.equal(G.standInOf(getChess(CATHY), BACKUPS), null, 'PRESET');
|
||||
assert.equal(G.standInLabel(si), '替补:Sharp');
|
||||
const lo = G.standInLoadout(si, getChess, BACKUPS);
|
||||
assert.equal(lo.skill.skillId, 'skchr_acguad_2', 'its backup skill (S2), never the player\'s loadout');
|
||||
// the deployed record: the stand-in's for a dropped chess, the loadout's otherwise
|
||||
assert.equal(G.deployedRecord(getChess(SILVER), priv, getChess, BACKUPS).charId, 'char_609_acguad');
|
||||
assert.equal(G.deployedRecord(getChess(SILVER), { standIns: [] }, getChess, BACKUPS).charId, getChess(SILVER).charId);
|
||||
const eg = getChess(getChess(MLYSS).goldenId);
|
||||
assert.equal(G.deployedModuleId(eg, priv, getChess, BACKUPS), eg.backup.uniEquipId, 'the backup module (SOL-X)');
|
||||
});
|
||||
|
||||
test('placement: a dropped chess is placed by its stand-in\'s position (缪尔赛思 → 郁金香 melee, 塞雷娅 → Touch ranged)', () => {
|
||||
const stage = null; // (the deploy tiles are not needed for the placement class)
|
||||
const ctx = G.placementContext({ priv, stage, editable: true, getChess, backups: BACKUPS });
|
||||
assert.equal(G.piecePosition(ctx, { kind: 'chess', id: MLYSS }), 'MELEE');
|
||||
assert.equal(G.piecePosition(ctx, { kind: 'chess', id: SARIA }), 'RANGED');
|
||||
const own = G.placementContext({ priv: { standIns: [] }, stage, editable: true, getChess, backups: BACKUPS });
|
||||
assert.equal(G.piecePosition(own, { kind: 'chess', id: MLYSS }), 'RANGED');
|
||||
assert.equal(G.piecePosition(own, { kind: 'chess', id: SARIA }), 'MELEE');
|
||||
});
|
||||
|
||||
test('shop / reward card: the chess\'s name, price and bonds stay, the 「替补:X」 badge and the stand-in\'s skill show', () => {
|
||||
const slot = { kind: 'chess', id: SILVER, price: 3, basePrice: 3 };
|
||||
const card = ChessCard({ slot, idx: 0, priv, onBuy() {}, onDetail() {} });
|
||||
const nodes = [...walk(card)];
|
||||
const badge = nodes.find((v) => hasClass(v, 'scard__standin'));
|
||||
assert.ok(badge, 'badge');
|
||||
assert.equal(badge.props['data-standin'], 'char_609_acguad');
|
||||
assert.equal(badge.props.children, '替补:Sharp');
|
||||
assert.equal(nodes.find((v) => hasClass(v, 'scard__name')).props.children, '银灰', 'the card keeps the chess\'s name');
|
||||
const skill = nodes.find((v) => v?.type && v.props?.lo);
|
||||
assert.equal(skill.props.lo.skill.skillId, 'skchr_acguad_2');
|
||||
const owned = ChessCard({ slot, idx: 0, priv: { standIns: [] }, onBuy() {}, onDetail() {} });
|
||||
assert.equal([...walk(owned)].find((v) => hasClass(v, 'scard__standin')), undefined, 'owned: no badge');
|
||||
});
|
||||
|
||||
test('detail card: own piece / card → the stand-in\'s body under the chess\'s name; a unit with standInFor likewise', () => {
|
||||
const piece = { uid: 41, kind: 'chess', id: SILVER, golden: false, items: [] };
|
||||
const pieces = new Map([[41, { piece, area: 'hand', idx: 0 }]]);
|
||||
const r = resolveDetail({ kind: 'piece', uid: 41 }, pieces, { priv, backups: BACKUPS });
|
||||
assert.equal(r.standIn?.charId, 'char_609_acguad');
|
||||
assert.equal(resolveDetail({ kind: 'piece', uid: 41 }, pieces, { priv: { standIns: [] }, backups: BACKUPS }).standIn, null);
|
||||
assert.equal(resolveDetail({ kind: 'chess', id: SILVER }, new Map(), { priv, backups: BACKUPS }).standIn?.charId, 'char_609_acguad', 'a shop card');
|
||||
const u = resolveDetail({ kind: 'unit', unit: { id: 9, uid: 99, side: 'ally', kind: 'op', defId: SARIA, standInFor: getChess(SARIA).charId } }, new Map(), { priv: null, backups: BACKUPS });
|
||||
assert.equal(u.standIn?.charId, 'char_613_acmedc', 'a teammate\'s unit says it itself');
|
||||
const blocks = ChessDetail({ chess: r.chess, piece, editable: false, bonds: [], loadout: null, standIn: r.standIn });
|
||||
const head = blocks.find((b) => b.key === 'head');
|
||||
const tag = [...walk(head)].find((v) => hasClass(v, 'dtag-standin'));
|
||||
assert.equal(tag?.props.children, '替补:Sharp');
|
||||
assert.equal([...walk(head)].find((v) => hasClass(v, 'dhead__name')).props.children, '银灰');
|
||||
const skill = blocks.find((b) => b.key === 'skill');
|
||||
assert.ok(JSON.stringify(skill.props).includes(r.standIn.skill.name), 'the stand-in\'s skill');
|
||||
const talents = blocks.find((b) => b.key === 'talents');
|
||||
for (const t of r.standIn.talents.filter((x) => x.name && !x.hidden)) assert.ok(JSON.stringify(talents.props).includes(t.name), `talent ${t.name}`);
|
||||
assert.ok(!JSON.stringify(blocks).includes(getChess(SILVER).skill.name), 'not the replaced operator\'s skill');
|
||||
});
|
||||
|
||||
test('own prep pieces with stand-ins get a tag (hand, temp, board); others none', () => {
|
||||
const p = {
|
||||
standIns: [SILVER],
|
||||
hand: [{ uid: 1, kind: 'chess', id: SILVER }, { uid: 2, kind: 'chess', id: CATHY }, null],
|
||||
temp: [{ uid: 3, kind: 'chess', id: getChess(SILVER).goldenId }],
|
||||
board: [{ uid: 4, kind: 'chess', id: SILVER, row: 10, col: 4 }, { uid: 5, kind: 'token', id: 'x', row: 9, col: 4 }],
|
||||
};
|
||||
assert.deepEqual(standInPieces(p, getChess, BACKUPS).map((x) => [x.uid, x.area, x.label]), [[1, 'hand', '替补:Sharp'], [3, 'temp', '替补:Sharp'], [4, 'board', '替补:Sharp']]);
|
||||
assert.deepEqual(standInPieces({ ...p, standIns: [] }, getChess, BACKUPS), []);
|
||||
});
|
||||
|
||||
test('the board model of a dropped chess is the stand-in\'s (render/app.js pieceInfo reads m.private.standIns; the view is rebuilt when its model changes)', () => {
|
||||
const src = readFileSync(path.join(ROOT, 'public/js/render/app.js'), 'utf8');
|
||||
assert.match(src, /area === 'board' && chess && standInList\.includes\(chess\.baseId \|\| chess\.chessId\) \? data\.standIn\(piece\.id\) : null/);
|
||||
assert.match(src, /standInList = Array\.isArray\(src\.standIns\)/);
|
||||
assert.match(src, /const sig = `\$\{info\.kind\}\|\$\{info\.defId\}\|\$\{info\.golden \? 1 : 0\}\|\$\{info\.spine \|\| ''\}`/);
|
||||
assert.match(readFileSync(path.join(ROOT, 'public/js/render/app/info.js'), 'utf8'), /standInFor: typeof u\.standInFor === 'string'/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
// Real-server browser E2E of 0.2.0 补位 end to end (the approved plan, owner's decision 2026-10-05): the 干员持有 tab of
|
||||
// the 干员调配 overlay marks 银灰 as not owned (real clicks; persisted in localStorage; room.ownership → session → seat →
|
||||
// the next match's m.private.standIns); in the match the shop card keeps 银灰 (name, price) with 「替补:Sharp」; bought,
|
||||
// the hand piece carries the tag; deployed, the board model is Sharp's; the detail card shows Sharp's skill under
|
||||
// 银灰's name; and in the browser's own battle (client-side combat) the unit is the stand-in — the b.start spec marks it
|
||||
// `standIn: true`, the local sim fields char_609_acguad with its kit, and the battle view draws Sharp's Spine model.
|
||||
//
|
||||
// SP_E2E=1 node --test test/ui/standin.e2e.test.js
|
||||
//
|
||||
// The server is test/e2e/fastServer.mjs with its starter-kit hooks (no starter operators, 银灰 in the first shop slot,
|
||||
// 20 funds); everything else is the real match engine and the real UI driven by real mouse input.
|
||||
// Screenshots: test/e2e/out/standin-*.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 SILVER = 'chess_char_4_22_a'; // 银灰 (NORMAL) → Sharp (char_609_acguad), S2 亮剑
|
||||
const SHARP = 'char_609_acguad';
|
||||
|
||||
describe('0.2.0 补位 — a not-owned operator fights as its stand-in (real server)', { skip: !ENABLED && 'set SP_E2E=1 (Chrome + public/assets)' }, () => {
|
||||
test('干员持有: drop 银灰 → buy it (badge) → deploy (Sharp model, Sharp card) → the local battle fields Sharp', { timeout: 6 * 60 * 1000 }, async () => {
|
||||
const srv = await startRealServer({ fast: { timerScale: 0.5, combatSpeed: 2, startRound: 1, kit: 0, shop: [SILVER] } });
|
||||
const P = (await import('puppeteer-core')).default;
|
||||
const c = new Client(P, srv.base, 'standin', { prefix: 'standin' });
|
||||
try {
|
||||
await c.open();
|
||||
await c.enter('替补');
|
||||
// 1) 干员调配 → 干员持有: mark 银灰 未持有 (real clicks), synced with the server, persisted in the browser
|
||||
await c.click('.lobby-screen [data-testid="loadout-open"]');
|
||||
await c.page.waitForSelector('.lo .lo-tab[data-tab="ownership"]', { visible: true, timeout: 15000 });
|
||||
await c.click('.lo .lo-tab[data-tab="ownership"]');
|
||||
await c.page.waitForSelector(`.own-card[data-chess="${SILVER}"]`, { visible: true, timeout: 15000 });
|
||||
const cards = await c.page.evaluate(() => [...document.querySelectorAll('.own-card')].length);
|
||||
assert.equal(cards, 53, 'the 53 NORMAL chess of the shop');
|
||||
await c.click(`.own-card[data-chess="${SILVER}"]`);
|
||||
await c.page.waitForFunction((id) => document.querySelector(`.own-card[data-chess="${id}"]`)?.getAttribute('aria-checked') === 'false', { timeout: 3000 }, SILVER);
|
||||
await c.page.waitForFunction(() => /已同步/.test(document.querySelector('[data-testid="ownership-sync"]')?.textContent || ''), { timeout: 8000 });
|
||||
const card = await c.page.evaluate((id) => document.querySelector(`.own-card[data-chess="${id}"]`)?.textContent || '', SILVER);
|
||||
assert.ok(card.includes('银灰') && card.includes('Sharp') && card.includes('未持有'), card);
|
||||
const stored = await c.page.evaluate(() => JSON.parse(localStorage.getItem('sp.pref.ownership') || 'null'));
|
||||
assert.deepEqual(stored, { v: 1, notOwned: [SILVER] });
|
||||
await c.shot('ownership');
|
||||
await c.page.keyboard.press('Escape');
|
||||
await c.page.waitForFunction(() => !document.querySelector('.lo'), { timeout: 3000 });
|
||||
|
||||
// 2) a solo 标准 match: the briefing's m.private carries the list
|
||||
await c.click('.mode-card', '独立模拟');
|
||||
await c.click('.diff-card', '标准模拟');
|
||||
await c.click('.create-box button', '开始独立模拟');
|
||||
await c.waitFor((s) => !!s.room, 'solo room');
|
||||
if (!(await c.st()).phase) await c.click('.room-bar__right button', '开始模拟', { timeout: 20000 });
|
||||
await c.waitFor((s) => s.phase === 'INFO_CHECK', 'briefing', 30000);
|
||||
const standIns = await c.page.evaluate(() => globalThis.__SP__.store.get().match.private?.standIns ?? null);
|
||||
assert.deepEqual(standIns, [SILVER], 'the match received the not-owned list');
|
||||
await c.click('.brief__foot .btn--primary', '准备就绪');
|
||||
await c.waitFor((s) => s.phase === 'BAND_DRAFT', 'band draft', 30000);
|
||||
await c.click('.dband', null, { nth: 1 });
|
||||
await c.click('.draft-detail__btns .btn--primary', '确认选择');
|
||||
await c.waitFor((s) => s.phase === 'PREP' && !s.ready && s.funds >= 3, 'prep with funds', 60000);
|
||||
await sleep(1800); // camera flight
|
||||
|
||||
// 3) the shop card: 银灰 with 「替补:Sharp」; two taps buy it
|
||||
await c.page.waitForSelector('.scard .scard__standin', { visible: true, timeout: 10000 });
|
||||
const shopCard = await c.page.evaluate(() => {
|
||||
const el = document.querySelector('.scard .scard__standin')?.closest('.scard');
|
||||
return el ? { name: el.querySelector('.scard__name')?.textContent, badge: el.querySelector('.scard__standin')?.textContent, standIn: el.querySelector('.scard__standin')?.dataset.standin } : null;
|
||||
});
|
||||
assert.deepEqual(shopCard, { name: '银灰', badge: '替补:Sharp', standIn: SHARP });
|
||||
await c.shot('shop');
|
||||
await c.click('.scard', '银灰');
|
||||
await sleep(300);
|
||||
await c.click('.scard', '银灰');
|
||||
await c.waitFor((s) => s.hand > 0, 'bought', 8000);
|
||||
await sleep(900);
|
||||
const [piece] = (await c.handPieces('chess')).filter((p) => p.id === SILVER);
|
||||
assert.ok(piece, '银灰 is in the hand');
|
||||
// the hand piece keeps 银灰's model, tagged 「替补:Sharp」
|
||||
await c.page.waitForFunction((uid) => document.querySelector(`.sitag[data-uid="${uid}"]`)?.textContent === '替补:Sharp', { timeout: 5000 }, piece.uid);
|
||||
const handModel = await c.page.evaluate((uid) => globalThis.__SP_VIEW__?.raw?.debug?.views?.get(`p:${uid}`)?.info?.spine ?? null, piece.uid);
|
||||
assert.notEqual(handModel, SHARP, 'the hand shows the original operator');
|
||||
await c.shot('hand');
|
||||
|
||||
// 4) deploy it (drag → direction wheel): the board model becomes Sharp's; the card shows Sharp's body
|
||||
const tile = await c.freeTileFor(piece.uid);
|
||||
assert.ok(tile, 'a legal tile');
|
||||
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.waitFor((s) => s.board > 0, 'placed', 8000);
|
||||
await c.page.waitForFunction((uid, sharp) => {
|
||||
const v = globalThis.__SP_VIEW__?.raw?.debug?.views?.get(`p:${uid}`);
|
||||
return v && v.info?.spine === sharp && v.info?.standInFor;
|
||||
}, { timeout: 8000 }, piece.uid, SHARP);
|
||||
await sleep(600);
|
||||
let detail = null;
|
||||
for (const at of [0.72, 0.4, 0.88]) {
|
||||
const p = await c.piecePoint(piece.uid, at);
|
||||
if (!p) continue;
|
||||
await c.page.mouse.click(p.x, p.y, { button: 'right' });
|
||||
detail = await c.page.waitForSelector('.dpanel .dtag-standin', { timeout: 2500 }).then(() => c.page.evaluate(() => ({
|
||||
tag: document.querySelector('.dpanel .dtag-standin')?.textContent || '',
|
||||
name: document.querySelector('.dpanel .dhead__name')?.textContent || '',
|
||||
skill: document.querySelector('.dpanel .dskill')?.dataset.skill || '',
|
||||
})), () => null);
|
||||
if (detail) break;
|
||||
}
|
||||
assert.ok(detail, 'the detail card opens for the deployed piece');
|
||||
assert.deepEqual(detail, { tag: '替补:Sharp', name: '银灰', skill: 'skchr_acguad_2' });
|
||||
await c.shot('board');
|
||||
await c.page.keyboard.press('Escape');
|
||||
|
||||
// 5) ready → the local battle fields Sharp (spec standIn: true; the sim's def; the view's Spine model)
|
||||
await c.click('.readybtn');
|
||||
await c.waitFor((s) => s.phase === 'COMBAT', 'combat', 60000);
|
||||
const got = await c.page.waitForFunction((uid, sharp) => {
|
||||
const r = globalThis.__SP_RUNNER__;
|
||||
const e = r && [...r._entries.values()].find((x) => x.own && x.battle);
|
||||
if (!e) return false;
|
||||
const u = e.battle.allyUnits.find((x) => x.uid === uid);
|
||||
if (!u) return false;
|
||||
const views = globalThis.__SP_VIEW__?.raw?.debug?.views;
|
||||
const v = views ? [...views.values()].find((x) => x.info?.uid === uid && !String(x.id).startsWith('p:')) : null;
|
||||
if (!v || !v.spineReady) return false;
|
||||
const entry = (e.spec.players || []).flatMap((p) => p.units || []).find((x) => x.uid === uid) || null;
|
||||
return {
|
||||
authoritative: e.authoritative, spec: entry && { chessId: entry.chessId, standIn: entry.standIn ?? null, skillIndex: entry.skillIndex ?? null },
|
||||
charId: u.def?.charId, standInFor: u.def?.standInFor, skill: u.skill?.id ?? null, bonds: u.def?.bonds,
|
||||
viewSpine: v.info.spine, viewStandInFor: v.info.standInFor ?? null, model: String(v._actorEntry?.skel || ''),
|
||||
};
|
||||
}, { timeout: 30000, polling: 150 }, piece.uid, SHARP).then((h) => h.jsonValue());
|
||||
assert.equal(got.authoritative, true, 'the own normal battle is simulated by this browser');
|
||||
assert.deepEqual(got.spec, { chessId: SILVER, standIn: true, skillIndex: null }, 'b.start spec marks the stand-in');
|
||||
assert.equal(got.charId, SHARP, 'the local sim fields Sharp');
|
||||
assert.equal(got.skill, 'skchr_acguad_2', 'with the chess\'s backup skill (S2 亮剑)');
|
||||
assert.equal(got.viewSpine, SHARP);
|
||||
assert.ok(got.viewStandInFor, 'the view knows it is a stand-in');
|
||||
assert.match(got.model, /char_609_acguad/, 'the battle view draws Sharp\'s Spine model');
|
||||
await sleep(800);
|
||||
await c.shot('combat');
|
||||
// the battle ends and the round settles normally (the result upload is accepted)
|
||||
await c.waitFor((s) => (s.phase === 'PREP' && s.round >= 2) || !!s.result, 'next prep', 180000);
|
||||
assert.deepEqual(problemsOf([c]), []);
|
||||
} finally {
|
||||
if (c.problems.length) console.log(c.problems.slice(0, 20).join('\n'));
|
||||
await c.close();
|
||||
await srv.stop();
|
||||
}
|
||||
});
|
||||
});
|
||||
+103
-15
@@ -8,7 +8,7 @@
|
||||
// Usage:
|
||||
// node tools/golden.mjs compare the corpus with test/golden/*.json (exit 1 on a difference)
|
||||
// node tools/golden.mjs --update recompute and rewrite test/golden/*.json (npm run golden:update)
|
||||
// options: --family roster,bonds,fields,matches --fast (the test's default subset) --only <id,id>
|
||||
// options: --family roster,bonds,fields,matches,standins --fast (the test's default subset) --only <id,id>
|
||||
// --jobs N (worker threads, default: up to 4) --list --coverage (skills cast, stages, enemies…)
|
||||
// --twice determinism: compute twice in this process (the second pass in reverse order) and compare
|
||||
//
|
||||
@@ -25,9 +25,14 @@
|
||||
// ended at 200 game s) and 联防 fields (1 and 2 helpers on the 联防 map of their count — boards laid out on a
|
||||
// battle stage —, carried HP / SP, a knocked-out operator, two leakers' enemies with a summoned-only kind and
|
||||
// a bounty)
|
||||
// matches 16 bot-only matches (solo 标准 / 险境 / 绝境 / 终极 ×2 seeds, co-op 2 / 3 / 4, one server-run combat match,
|
||||
// two with LP and layers raised at the first prep so they reach the Hidden Core) run to the end in virtual
|
||||
// time with the match's default bot rehearsal
|
||||
// matches 17 matches run to the end in virtual time with the match's default bot rehearsal: 16 bot-only (solo 标准 /
|
||||
// 险境 / 绝境 / 终极 ×2 seeds, co-op 2 / 3 / 4, one server-run combat match, two with LP and layers raised at the
|
||||
// first prep so they reach the Hidden Core) and one co-op match whose human seat (AI 托管, offline: its
|
||||
// battles run on the server) does not own a few NORMAL chess — they fight as their 补位 stand-ins (0.2.0)
|
||||
// standins 10 battles: every NORMAL chess record (normal + elite, 110) fielded as its 补位 stand-in (PlayerBattleInput
|
||||
// standIn: true — DATA.md §18: the stand-in's body, its backup skill / module, its kit by charId; all 17
|
||||
// stand-ins and every skill a chess names for them), 12 per battle by strength band, laid out by the stand-in's
|
||||
// position on a real stage, against the round's real wave three times over, an item each, bonds from the board
|
||||
// Battles run through the production BattleSpec path (server/sim/spec.js buildBattleSpec → createBattleFromSpec, the
|
||||
// path browsers and the server's headless fields use) with every option explicit; matches construct Match directly
|
||||
// with a VirtualScheduler (as tools/botbench.mjs) — test-harness defaults never move a digest.
|
||||
@@ -68,7 +73,7 @@ import { GEO } from '../shared/constants.js';
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
export const GOLDEN_DIR = join(ROOT, 'test', 'golden');
|
||||
export const FAMILY_NAMES = Object.freeze(['roster', 'bonds', 'fields', 'matches']);
|
||||
export const FAMILY_NAMES = Object.freeze(['roster', 'bonds', 'fields', 'matches', 'standins']);
|
||||
|
||||
const QUIET = Object.freeze({ warn() {}, error() {}, info() {}, log() {}, debug() {} });
|
||||
const data = getData({ log: QUIET });
|
||||
@@ -323,11 +328,13 @@ function layout(gd, stageId, wanted, { field = 'normal', colOffset = 0, max = 12
|
||||
const rank = (r, c) => { const d = dist(r, c); return d === 0 ? 1.5 : d; }; // beside a path first, then on it
|
||||
return out.sort((a, b) => rank(a[0], a[1]) - rank(b[0], b[1]));
|
||||
};
|
||||
const isMelee = (w) => positionClass(data.chess[w.chessId], w.moduleId) === 'melee';
|
||||
const order = wanted.slice().sort((a, b) => (isMelee(b) - isMelee(a)) || (isMelee(a) ? (data.chess[b.chessId].stats.blockCnt || 0) - (data.chess[a.chessId].stats.blockCnt || 0) : 0));
|
||||
// a 补位 unit (`standIn`) is laid out by its stand-in's body (position, block count); every other by its chess record
|
||||
const recOf = (w) => (w.standIn ? gd.standIn(w.chessId) || data.chess[w.chessId] : data.chess[w.chessId]);
|
||||
const isMelee = (w) => positionClass(recOf(w), w.moduleId) === 'melee';
|
||||
const order = wanted.slice().sort((a, b) => (isMelee(b) - isMelee(a)) || (isMelee(a) ? (recOf(b).stats.blockCnt || 0) - (recOf(a).stats.blockCnt || 0) : 0));
|
||||
let uid = uid0;
|
||||
for (const w of order) {
|
||||
const rec = data.chess[w.chessId];
|
||||
const rec = recOf(w);
|
||||
const free = units.length < max ? tiles(positionClass(rec, w.moduleId)) : [];
|
||||
if (!free.length) { rest.push(w); continue; }
|
||||
const [r, c] = free[0];
|
||||
@@ -336,12 +343,14 @@ function layout(gd, stageId, wanted, { field = 'normal', colOffset = 0, max = 12
|
||||
const u = { uid: uid++, kind: 'chess', chessId: w.chessId, row: r, col: c, dir, items: w.items ?? [] };
|
||||
if (w.skillIndex != null) u.skillIndex = w.skillIndex;
|
||||
if (w.moduleId != null) u.moduleId = w.moduleId;
|
||||
if (w.standIn) u.standIn = true;
|
||||
if (w.carryState) u.carryState = w.carryState;
|
||||
units.push(u);
|
||||
}
|
||||
// placeable summons of the placed operators
|
||||
// placeable summons of the placed operators (a stand-in makes none: none of the 17 has one)
|
||||
const tokens = [];
|
||||
for (const u of units) {
|
||||
if (u.standIn) continue;
|
||||
for (const { tokenId, count } of gd.placeableTokens(u.chessId, { skillIndex: u.skillIndex ?? null })) {
|
||||
const trec = data.tokens[tokenId];
|
||||
let allowed = null;
|
||||
@@ -625,6 +634,60 @@ export function fieldScenarios() {
|
||||
return scenarios;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
// family: standins (0.2.0 补位)
|
||||
|
||||
/** The NORMAL chess records (normal + elite) that have a stand-in (shared/standIn.js: PRESET / 自选 have none). */
|
||||
const STANDIN_CHESS = CHESS.filter((c) => c.chessType === 'NORMAL' && !c.isDiy && c.backup && c.backup.charId && c.backup.charId !== c.charId);
|
||||
|
||||
export function standInScenarios() {
|
||||
// normal records first, then the elites; within them by strength band (tier + elite) and id; a battle takes up to 12
|
||||
// with distinct chess ids and bands within one of the first waiting record's
|
||||
const queue = STANDIN_CHESS.map((rec) => ({ chessId: rec.chessId, standIn: true, elite: rec.isGolden ? 1 : 0, band: rec.tier + (rec.isGolden ? 1 : 0) }));
|
||||
queue.sort((a, b) => a.elite - b.elite || a.band - b.band || byId(a.chessId, b.chessId));
|
||||
// every battle also gets 8 ground enemy kinds as extra spawns: a 飞行 round wave would leave the melee stand-ins
|
||||
// nobody to block, and their skills (cast with an enemy in range) uncast
|
||||
const ground = cursor(EXTRA_ENEMIES.filter((k) => !data.enemies[k].tokenOnly && !isFlyKey(gdFor('mode_multi_normal'), k)));
|
||||
const items = cursor(EQUIPS);
|
||||
const bands = cursor(BANDS.slice().reverse());
|
||||
const scenarios = [];
|
||||
let n = 0;
|
||||
while (queue.length) {
|
||||
const i = n++;
|
||||
const stageId = STAGES[(i * 3 + 1) % STAGES.length];
|
||||
const modeId = ROSTER_MODES[(i + 2) % ROSTER_MODES.length];
|
||||
const gd = gdFor(modeId);
|
||||
const first = queue[0];
|
||||
const picked = [];
|
||||
for (const w of queue) {
|
||||
if (picked.length >= 12) break;
|
||||
if (picked.some((x) => x.chessId === w.chessId) || Math.abs(w.band - first.band) > 1 || w.elite !== first.elite) continue;
|
||||
picked.push(w);
|
||||
}
|
||||
const chunk = picked.map((w) => ({ ...w, items: [items.next()] }));
|
||||
const { units } = layout(gd, stageId, chunk, { max: 12 });
|
||||
if (!units.length) throw new Error(`standins: nothing fits on ${stageId}`);
|
||||
const placed = new Set(units.map((u) => u.chessId));
|
||||
for (let q = queue.length - 1; q >= 0; q--) if (picked.includes(queue[q]) && placed.has(queue[q].chessId)) queue.splice(q, 1);
|
||||
if (!placed.size) throw new Error('standins: no progress');
|
||||
const band = Math.max(...picked.filter((w) => placed.has(w.chessId)).map((w) => w.band));
|
||||
const round = BAND_ROUND[band];
|
||||
const seed = deriveSeed(20261005, `standins:${i}`);
|
||||
const wave = normalWave(gd, round, seed);
|
||||
const pid = 'p1';
|
||||
scenarios.push({
|
||||
id: `standin-${String(i + 1).padStart(2, '0')}`, family: 'standins', kind: 'normal', modeId, round, stageId, seed, pass: first.elite,
|
||||
rect: { ...GEO.NORMAL_RECT }, timeLimit: wave.timeLimit, routes: wave.routes, waveId: wave.templateId, enemyOverrides: wave.overrides,
|
||||
flags: { layerGainsEnabled: true, ...gd.dp },
|
||||
players: [playerInput(pid, 0, units, { bonds: bondsOf(gd, units, LAYER_STEPS[i % LAYER_STEPS.length]), bandId: bands.next() })],
|
||||
spawns: [0, 1, 2].flatMap((k) => wave.spawns.map((sp) => ({ ...sp, time: sp.time + k * Math.round(wave.timeLimit / 4), ownerPlayerId: pid })))
|
||||
.concat(extraSpawns(gd, wave.routes, Array.from({ length: 8 }, () => ground.next()), round, pid, 6, 6)),
|
||||
about: `${first.elite ? 'elite' : 'normal'} band ${band} as stand-ins: ${units.map((u) => `${u.chessId}→${gd.standIn(u.chessId)?.charId ?? '?'}`).join(' ')}`,
|
||||
});
|
||||
}
|
||||
return scenarios;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
// family: matches
|
||||
|
||||
@@ -641,9 +704,19 @@ export function matchScenarios() {
|
||||
// match reaches the Final Assault and — Σ activated layers over the threshold — the Hidden Core
|
||||
out.push({ id: 'solo-HARD-9-boosted', mode: 'solo', difficulty: 'HARD', bots: 1, seed: 9, boost: { lp: 400, layers: 300 } });
|
||||
out.push({ id: 'coop2-ABYSS-10-boosted', mode: 'coop', difficulty: 'ABYSS', bots: 2, seed: 10, boost: { lp: 400, layers: 300 } });
|
||||
return out.map((m) => ({ ...m, family: 'matches', kind: 'match', about: `${m.mode} ${m.difficulty}, ${m.bots} bot seat(s), seed ${m.seed}${m.clientCombat === false ? ', server-run combat' : ''}${m.boost ? `, LP ${m.boost.lp} and ${m.boost.layers} layers per bond from the first prep` : ''}` }));
|
||||
// 0.2.0 补位: a human seat (AI 托管, offline: its battles run on the server) that does not own a few NORMAL chess
|
||||
out.push({ id: 'coop2-NORMAL-14-standins', mode: 'coop', difficulty: 'NORMAL', bots: 1, seed: 14, human: { notOwned: MATCH_NOT_OWNED } });
|
||||
return out.map((m) => ({ ...m, family: 'matches', kind: 'match', about: `${m.mode} ${m.difficulty}, ${m.bots} bot seat(s)${m.human ? ` + 1 human seat (AI 托管) without ${m.human.notOwned.length} operators (补位 stand-ins)` : ''}, seed ${m.seed}${m.clientCombat === false ? ', server-run combat' : ''}${m.boost ? `, LP ${m.boost.lp} and ${m.boost.layers} layers per bond from the first prep` : ''}` }));
|
||||
}
|
||||
|
||||
/**
|
||||
* The NORMAL chess the 补位 match's human seat does not own: operators its AI fields with seed 14 (瑕光 → 郁金香 from
|
||||
* round 4, 异客 → Stormeye, 耀骑士临光 → 郁金香 later; the digest's `standIns` lists them per round).
|
||||
*/
|
||||
const MATCH_NOT_OWNED = Object.freeze([
|
||||
'chess_char_3_12_a', 'chess_char_3_18_a', 'chess_char_5_20_a', 'chess_char_6_05_a', 'chess_char_6_06_a', 'chess_char_6_17_a', 'chess_char_6_19_a',
|
||||
]);
|
||||
|
||||
const pieceStr = (p, r, c) => `${p.id.replace(/^chess_char_/, '')}@${r},${c}${p.dir && p.dir !== 'RIGHT' ? p.dir[0] : ''}${p.items && p.items.length ? `[${p.items.map((i) => i.id.replace(/^chess_item_/, '')).join('+')}]` : ''}`;
|
||||
function boardStr(ps) {
|
||||
const out = [];
|
||||
@@ -656,7 +729,10 @@ const MATCH_ROW_COLS = Object.freeze(['alive', 'lp', 'prepFunds', 'funds', 'pend
|
||||
export function runMatch(cfg) {
|
||||
const sched = new VirtualScheduler();
|
||||
const seats = [];
|
||||
for (let i = 0; i < cfg.bots; i++) seats.push({ seat: i, playerId: `ai_${i}`, name: `AI-${i + 1}`, isBot: true, connected: true });
|
||||
// a human seat (cfg.human): offline (its battles run on the server) and on AI 托管 from the start; its not-owned chess
|
||||
// fight as their 补位 stand-ins
|
||||
if (cfg.human) seats.push({ seat: 0, playerId: 'h_0', name: 'H-1', isBot: false, connected: false, notOwned: [...cfg.human.notOwned] });
|
||||
for (let i = 0; i < cfg.bots; i++) seats.push({ seat: seats.length, playerId: `ai_${i}`, name: `AI-${i + 1}`, isBot: true, connected: true });
|
||||
let summary = null;
|
||||
const logged = [];
|
||||
const log = { info() {}, debug() {}, warn() {}, error: (...a) => logged.push(a.map(String).join(' ').slice(0, 120)) };
|
||||
@@ -665,7 +741,10 @@ export function runMatch(cfg) {
|
||||
send: () => true, broadcast: () => {}, onEnd: (s) => { summary = s; },
|
||||
clientCombat: cfg.clientCombat !== false, verify: 'off',
|
||||
});
|
||||
if (cfg.human) m.players.get('h_0').autoplay = true;
|
||||
const rounds = [];
|
||||
// 补位: per round at SETTLE, the human's board pieces that fought as stand-ins (chess id → stand-in charId)
|
||||
const standIns = [];
|
||||
let last = '';
|
||||
m.start();
|
||||
const setup = { stageId: m.stageId, bossId: m.bossId, hiddenBossId: m.hiddenBossId, factions: [...(m.factions || [])] };
|
||||
@@ -696,6 +775,11 @@ export function runMatch(cfg) {
|
||||
];
|
||||
}
|
||||
rounds.push(row);
|
||||
if (cfg.human) {
|
||||
const h = m.players.get('h_0');
|
||||
const fielded = [...h.board.values()].filter((p) => p.kind === 'chess' && h.fieldsStandIn(p.id)).map((p) => `${p.id.replace(/^chess_char_/, '')}→${m.gd.standIn(p.id)?.charId}`).sort(byId);
|
||||
standIns.push([m.round, fielded.join(' ')]);
|
||||
}
|
||||
}
|
||||
}
|
||||
return summary != null;
|
||||
@@ -726,6 +810,7 @@ export function runMatch(cfg) {
|
||||
rounds,
|
||||
players,
|
||||
};
|
||||
if (cfg.human) digest.standIns = { notOwned: [...m.players.get('h_0').standIns], rounds: standIns };
|
||||
m.dispose();
|
||||
return digest;
|
||||
}
|
||||
@@ -733,22 +818,25 @@ export function runMatch(cfg) {
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
// families, fast subset, comparison
|
||||
|
||||
const GENERATORS = { roster: rosterScenarios, bonds: bondScenarios, fields: fieldScenarios, matches: matchScenarios };
|
||||
const GENERATORS = { roster: rosterScenarios, bonds: bondScenarios, fields: fieldScenarios, matches: matchScenarios, standins: standInScenarios };
|
||||
const ABOUT = {
|
||||
roster: 'every visible chess record × every selectable skill / module, every stage, every non-leader enemy kind, items, bands, 机变 cards, map cards, placeable summons',
|
||||
bonds: 'every bond at its activation threshold (layers 1) and at its top tier (layers 999)',
|
||||
fields: 'Final Assault / Hidden Core leaders (pair + solo templates, shared pool, 200 s cap) and 联防 fields (1 / 2 helpers)',
|
||||
matches: 'bot-only matches run to the end in virtual time (solo ×4 difficulties ×2 seeds, co-op 2/3/4, one server-run, two boosted to the Hidden Core)',
|
||||
matches: 'matches run to the end in virtual time: bot-only (solo ×4 difficulties ×2 seeds, co-op 2/3/4, one server-run, two boosted to the Hidden Core) and one co-op match whose human seat fields 补位 stand-ins',
|
||||
standins: 'every NORMAL chess record (normal + elite) fielded as its 补位 stand-in (all 17 stand-ins and the skills the chess name for them), 12 per battle on real stages and waves',
|
||||
};
|
||||
/**
|
||||
* The default test subset (GOLDEN_FULL=1 runs everything): the pass-0 roster battles (every chess record with its
|
||||
* default loadout, every stage, every non-leader enemy kind), every bond at its top tier, both 联防 fields, four leader
|
||||
* fields (one of them a Hidden Core) and five matches (solo 标准 / 绝境, co-op 2 / 4, the boosted Hidden Core run).
|
||||
* fields (one of them a Hidden Core), six matches (solo 标准 / 绝境, co-op 2 / 4, the boosted Hidden Core run, the 补位
|
||||
* match) and the normal-record stand-in battles.
|
||||
*/
|
||||
const FAST_MATCHES = new Set(['solo-FUNNY-1', 'solo-HARD-1', 'coop2-NORMAL-3', 'coop4-ABYSS-7', 'solo-HARD-9-boosted']);
|
||||
const FAST_MATCHES = new Set(['solo-FUNNY-1', 'solo-HARD-1', 'coop2-NORMAL-3', 'coop4-ABYSS-7', 'solo-HARD-9-boosted', 'coop2-NORMAL-14-standins']);
|
||||
const FAST_FIELDS = new Set(['boss-boss_1-pair', 'boss-boss_4-solo', 'boss-boss_7-pair', 'hidden-boss_9-pair', 'unite-1', 'unite-2']);
|
||||
export function isFast(sc) {
|
||||
if (sc.family === 'matches') return FAST_MATCHES.has(sc.id);
|
||||
if (sc.family === 'standins') return sc.pass === 0; // the normal records (the elites with GOLDEN_FULL)
|
||||
if (sc.family === 'fields') return FAST_FIELDS.has(sc.id);
|
||||
if (sc.family === 'bonds') return sc.id.endsWith('-high');
|
||||
return sc.pass === 0;
|
||||
|
||||
Reference in new issue
Block a user