feedback5: merge W5K — 自选编队 in play: room.diy picks (kitted, ISW-A refused), the player's data view (a slotted DIY slot is its operator: faction bonds everywhere, no 特质), per-player stock 8 / 5 and shop draws from 调度中心 level 5 / 6, banned-bond rule, summon pieces to the hand, the 自选编队 tab with skill / module choice, 「自选」 cards; golden: + coop2-NORMAL-70-diy, nothing else moved

This commit is contained in:
sganggs committed 2026-10-06 00:31:36 +08:00
commit d6fd800efd
67 files changed
+3103 -219

No files matched your search

+17 -7
View File
@@ -506,7 +506,8 @@ Glyph legend (`rows`):
2. **DIY chess** (`chess_char_5_diy1/2`, `chess_char_6_diy1/2`, `_a` and `_b` = 8 records): no `charId`, no stats/skill; `visible:false`,
name placeholder `甄选干员`, not in the shop. Their picks and bond rule are in `backups.json diy` (§18); a slot fights
only as a 自选 piece — a PlayerBattleInput entry with its `diy` pick (docs/SIM.md §12; shared/diy.js) — and a slot
without a legal pick fields nothing. The picker and the per-player shop are not implemented yet.
without a legal pick fields nothing. Played since 0.2.0: the player fills the slots on the 自选编队 tab (`room.diy`)
and its own shop sells them (server/match/player/diy.js; §18).
3. **Module-less chess**: 蒂比 (`chess_char_2_13`) and 凛御银灰 (`chess_char_5_14`) have no module; their golden
record has `module:{id:null,active:false}` and no module stat bonus.
4. **Hidden chess (17)** are kept with `visible:false`; several operators exist in two tiers with one hidden
@@ -602,8 +603,12 @@ official stand-in (原型干员); played since 0.2.0: the player marks operators
(`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. Built by `tools/build-data.mjs buildBackups`; `shared/standIn.js` composes it into chess-shaped records,
`shared/diy.js` adds the 自选 rules on top. The sim fields both (docs/SIM.md §12); the 自选 picker and per-player shop
come later. The rules in the data (activity_table act2autochess `charShopChessDatas`; PRTS 卫戍协议, 卫戍协议:盟约
`shared/diy.js` adds the 自选 rules on top. The sim fields both (docs/SIM.md §12). Played since 0.2.0: the player fills
the four slots on the 自选编队 tab (`room.diy { picks }`, docs/PLAYING.md §3; shared/protocol.js checkDiyPicks keeps the
legal picks), the match takes the picks the seat had at its start (`PlayerState.diy`), and each slotted piece is sold in
that player's shop only — its own stock (the tier's pool copies, 8 / 5 [ASSUMED]), from the 调度中心 level `shopLevel`,
none when every bond of it is banned this match — and is the operator for every rule of that player (its data view:
server/match/player/diy.js, docs/META.md §3). The rules in the data (activity_table act2autochess `charShopChessDatas`; PRTS 卫戍协议, 卫戍协议:盟约
下半/PRTS盟约记录):
- PRESET (74, 特许干员) always fields the real operator (`backup.charId` = itself); NORMAL (55) names one of 17 stand-ins —
@@ -655,13 +660,16 @@ composes the same record (`public/js/ui/gameLogic/standIn.js standInOf`, the ren
A 自选 piece is composed by `shared/diy.js`:
`checkDiyPick(slotId, pick, data)` checks one pick `{ charId, skillIndex?, uniEquipId? }` against a slot (a pick of the
slot's tier; a prototype takes its `diy.locked` selection, another skill is refused; an owned pick names one of its three
skills and optionally a module of its elite form at the slot's stage), `diyRecord(slotId, pick, { elite, data })` /
skills and optionally a module of its elite form at the slot's stage), `diySlot(id, data)` names a slot's tier, elite
twin and `shopLevel`, `diyRecord(slotId, pick, { elite, data })` /
`diyRecordOf(slot, pick, data)` give the record — the slot's identity (tier, price, merge, status; no 特质), the pick's
derived bonds, the operator's form at the slot's status with that skill and module (active on the elite only), plus
`diyFor` = the slot's base id —, `diyPool(tier, { data, kitted })` the legal picks of a tier (prototypes, then the owned
pool; with `kitted`, only operators with a kit: server/sim/content/kits/index.js `KITTED_CHARS`) and
`validateDiyPicks(picks, { data, kitted })` a roster (a prototype may fill a tier-5 and a tier-6 slot, an owned operator
one slot, the picks of a tier differ). **In battle** a PlayerBattleInput entry of a DIY slot carries `diy` (the pick) and
one slot, the picks of a tier differ, no 集成战略 module — `isDiyModule`: the ISW-A modules of 凯尔希, 傀影, 菲亚梅塔, 提丰,
艾丽妮 and 霍尔海雅 are never a player's choice [ASSUMED], the owner's decision of 2026-10-05, while the record and the sim
still compose them for the kits' tests). **In battle** a PlayerBattleInput entry of a DIY slot carries `diy` (the pick) and
is fielded as `getChess(slotId, { diy })` (docs/SIM.md §12); its kit is `KITS[charId]` (kits/README.md "How to add an
operator (自选)"). (`shared/standIn.js diyRecord(slot, charId, backups, sel)` is the older prototype-only form.)
@@ -684,5 +692,7 @@ fill one slot with different operators. A variant is the token at the owner's st
trait and talents (`tools/build-data.mjs buildDiyTokens`); simdata `getToken` merges them with the pick as the owner's
loadout. No id is also a tokens.json record.
Not in this data (the gameplay workstreams): a player's ownership roster, 助战 borrows (`borrowCount` 20), DIY stock, the
owned-operator training bonus (`prepareStateDict`), potential (0 for every 自选 pick, [ASSUMED]: no account).
Not in this data: a player's ownership roster and 自选 picks (browser settings sent with `room.ownership` / `room.diy`),
the DIY stock (per player in the match, `PlayerState.diyStock`: `config.economy.poolCopies` of the slot's tier — no excel
field), 助战 borrows (`borrowCount` 20), the owned-operator training bonus (`prepareStateDict`), potential (0 for every
自选 pick, [ASSUMED]: no account).
+6 -3
View File
@@ -21,7 +21,7 @@ In scope:
- Rendering with the real Spine battle chibis (PixiJS 7 + pixi-spine 4), procedural tiles, VFX, damage numbers, real BGM/SFX, emotes, broadcast ticker.
- Reconnect, AI take-over of disconnected players, robust validation of every client intent.
Out of scope v1: matchmaking queue, training/tutorial, DIY (甄选) slots (the 4 DIY chess are removed from the pool — 0.2.0 adds their data and battle side: DATA.md §18, shared/diy.js), trophies/progression persistence, reporting.
Out of scope v1: matchmaking queue, training/tutorial, DIY (甄选) slots (the 4 DIY chess are removed from the shared pool — 0.2.0 plays them as 自选编队: the picks of the 干员调配 overlay's 自选编队 tab, each sold only in its player's shop; DATA.md §18, shared/diy.js, server/match/player/diy.js), trophies/progression persistence, reporting.
---
@@ -433,10 +433,10 @@ 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`) · `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}`
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`) · `room.diy {picks}` (0.2.0 自选编队: `{ [slotBaseId]: { charId, skillIndex?, uniEquipId? } | null }` for the four DIY slots — ≤ 8 entries, kept leniently: shared/protocol.js `checkDiyPicks` drops an illegal pick (an operator without a kit, another tier's prototype, a prototype off its locked skill, a 集成战略 module, an owned operator's second slot, the same operator twice in a tier, an unknown slot), only malformed picks are `BAD_MSG`; stored on the session and the seat like the not-owned list, a match takes the seat's picks when it starts — during a match `ROOM_STARTED` 'stored for the next match'; heavy bucket; the match exposes them as `m.private.diy`) · `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.
Platform additions (implemented): `welcome.{resumed, version}`, `welcome.diyKitted` (0.2.0: the operators a 自选 slot may field — server/sim/content/kits/index.js `KITTED_CHARS`, the picker's list), `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.
**Spectator seats** (a remake feature — the official room has none; community report #26, owner's decision 2026-10-04; the spectator-seat subsection of §23): `room.spectate {code}` takes one of a co-op room's `MAX_SPECTATORS` (2) spectator seats, in its lobby or during its match; `room.removeSpectator {playerId}` (host) frees one (`room.closed {reason:'kicked'}` to it); `room.state.spectators: [{playerId, name, connected}]`. A spectator is never a player (not in `seats`, not counted for the start, never host, keeps no room alive — `room.closed {empty}` when the last human leaves), receives the room's broadcasts and watches like an eliminated player (`Match.addSpectator`: `b.start` watch / `m.field`, never `m.private`); it may only `g.watch`, leave, and store a `room.loadout` — anything else answers `error SPECTATOR`. Its seat is kept and given back on reconnect like a player's.
@@ -471,6 +471,8 @@ S→C:
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)
diy: { [slotBaseId]: { charId, skillIndex, uniEquipId } }, // 0.2.0 自选编队: this player's picks in this match ({} = none)
diyBanned: [ slotBaseId ], // 0.2.0: slotted slots out of the shop this match (every bond of the operator 本局禁用)
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 */ }
```
@@ -629,6 +631,7 @@ Official rule (bwiki 盟约, 更新公告 5114): before a match the player canno
**导出 / 导入 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.
**自选编队 (0.2.0):** the overlay's third tab fills the four DIY slots (5阶 ×2, 6阶 ×2) with an owned 6★ outside the chess pool (any of its three skills, any module of its elite form but a 集成战略 one) or a prototype (its locked skill / module); only operators with a kit are offered (`welcome.diyKitted`). Per-browser `sp.pref.diy` = `{ v, picks }`, synced as `room.diy { picks }` by the same sync engine; 导出 / 导入 as `{ kind: 'stronghold.diy', v, exportedAt, count, picks }` (`ui/diyModel.js`, `screens/diy.js`). In the match a slotted piece is the operator for its player (name, art, class, bonds from its factions, no 特质, the pick's skill and module; the slot's tier, price and merge), sold only in that player's shop from the slot's 调度中心 level with its own stock; the shop / reward / detail cards and the own pieces carry a 「自选」 badge (`ui/gameLogic/diy.js`); the 0.2.0 自选编队 design subsection has the rules and sources.
---
+17 -1
View File
@@ -14,7 +14,8 @@ server/match/
PlayerState.js per-player economy / shop / hand / board / items / bonds / LP + every prep intent handler — the class:
constructor + method install
player/ PlayerState's methods by concern: basics, pieces, acquire (gains, merges), economy (funds, layers,
the shop), placement (g.move), items, prep, round (lifecycle, battleInput), views; common.js
the shop), placement (g.move), items, prep, round (lifecycle, battleInput), views, diy (自选编队: the
picks, the player's data view, the DIY stock); common.js
gamedata.js typed, defaulted view of data/*.json (config tunables with research defaults) + the balance layer
(data/tuning.json, §3.1)
pool.js SharedPool (copies per base chess, across players), per-match bans, copy-weighted rolls
@@ -507,6 +508,21 @@ Any `choice:` handler whose EffectRef reuses its own key must guard like this (o
frozen). Unfrozen slots are cleared at combat start. Slot positions are stable (frozen slots keep their index).
* **Pool**: copies 12/14/18/16/8/5 (缪尔赛思 4); a normal piece holds 1 copy, an elite 3; displays never reserve copies;
selling, temp resolution and elimination return exactly what a piece holds (`left + held = cap` always).
* **自选编队 (0.2.0, `player/diy.js`; research 0.2.0 §2, the owner's decisions of 2026-10-05)**: a human's `seat.diy`
picks (room.diy, checked again against the match's data and kits) are fixed for the match; bots field none [ASSUMED].
The player's `ps.gd` is then a view of the match's GameData whose `chess(id)` of a slotted slot (normal and elite) is
the composed 自选 record (shared/diy.js `diyRecord`): every rule that reads a record through `ps.gd` — bonds
(bondsMeta: BOARD / BOARD_AND_DECK distinct pieces, 煌's 炎 + 维多利亚, 绝技 elites), 特质 / meta effects (`makeCtx`
hands handlers the player's view), placement and summon ranges, the AI 托管 evaluation (bot.js), names in toasts and
tickers — sees the operator; a player without picks keeps the match's GameData itself. Each slotted piece has its own
stock (`ps.diyStock`: `poolCopies` of the slot's tier, 8 / 5 [ASSUMED]; none — `diyBanned` — when every bond of it is
off this match; `initDiyStock` once the bans are drawn); `poolOf(baseId)` routes its copy accounting (buy, reward
picks, merges, promotions, sells, temp, elimination; `invariants.js` checks `left + held = cap` per player). The
shop's chess slots and the reward offers' temporary refreshes roll it with the shared pool (`pool.roll({ extra })`,
copy-weighted like any chess of its tier) once the 调度中心 reaches the slot's `shopLevel` (5 / 6) [ASSUMED for the
reward offers]; effects that draw a random operator from the shared pool never draw it, and 信标 never sends one to a
teammate [ASSUMED]. `battleInput` carries `diy` (the pick); `m.private.diy` / `diyBanned`; `prepFieldMeta` and
`m.result` lineups carry the pick (`diy`) so other players' cards compose the operator.
* **Hand**: 10 slots filled right→left, 5 temp slots for passive overflow (merge results, grants, returned equipment);
a full hand refuses every buy and reward pick — also one whose copy would complete a merge at once (PRTS
卫戍协议/帮助 §手牌区 "例如招募/购入等通常情况下会增加手牌的操作"; GitHub #82) — and withdrawals unless the withdrawn
+1
View File
@@ -60,6 +60,7 @@
- **晋升精锐**:3 名同名干员(无论是否部署)自动合成**精锐**(等级、技能、模组大幅提升;风丸只需 2 名),精锐不会再合成。如果被合成的干员里有**已经部署在战场上**的,精锐会直接出现在**那名干员的位置**(保持原来的朝向,不多占部署位;有两三名都在场上时,取最先部署的那名——最左一列优先,同一列从上到下);否则精锐进入整备区(满了进入临时整备区)。原先配发的装备都退回整备区,需要重新配发;被合成干员的召唤物随之移除,场上的精锐会把自己的召唤物送进整备区。点选一张能合成的商店卡片时,精锐将出现的那一格会亮起金色边框,同时打开的详情卡里也会写明去向(「可晋升:精锐干员将出现在作战区原位置 / 将进入整备区」;详情卡会自动停靠在不遮挡这一格的一侧)。在回合开始或结算时因策略、奖励等完成的合成,精锐出现时同样会播放金色的晋升特效。
- **晋级奖励**:每次晋升后获得一次特殊招募:从 3 名 **高一阶**(最高 VI)、**互不相同**的随机干员中**免费**挑 1 名(该阶剩余干员不足 3 名时由低一阶补足)。它不能刷新、不能冻结、本回合结束失效。
- **干员持有(补位)**:大厅、房间和「确认本局信息」里的「干员调配」按钮打开的界面有两个标签页:「干员调配」选择每名干员携带的技能和精锐的模组;「**干员持有**」可以把你没有的干员**下掉**(点一下卡片切换「持有 / 未持有」)。和官方一样只有 55 张「普通」棋子能下掉(界面按阶级列出本赛季商店里的 53 名,每张写着由谁替补、用几技能);74 张「预设」棋子(多是 1–2 阶的常见干员)无论是否持有都由本人上场,不在其中。默认全部持有。下掉的干员照常出现在商店和整备区,**名字、盟约、特质、阶级、价格、合成都按原干员**,卡片和整备区里的单位下方标着「替补:Sharp」这样的标记;部署到战场上后换成**替补干员**的模型,作战时的属性、技能、天赋、模组、攻击范围、近战位 / 远程位都是替补干员的(详情卡显示替补的技能和天赋,例如银灰 → Sharp 的 2 技能「亮剑」)。替补的技能和模组由棋子固定,「干员调配」对它不生效;替补干员没有召唤物(下掉缪尔赛思就没有流形)。这是**局外设置**:下一局开始时生效,对局中修改只保存到下一局;同盟模拟中只影响你自己的棋子,队友的棋子按队友的设置。没有官方的好友借用:想用本人上场,改回「持有」即可。「全部持有」一键恢复,「导出 / 导入」可以把设置带到别的浏览器;大厅的「干员调配」按钮上会显示「替补 N」提醒你有几名干员被下掉。
- **自选编队**:同一个界面的第三个标签页「**自选编队**」有 4 个名额:5 阶两个、6 阶两个。点名额打开选择列表,每个名额可以放一名**你持有的 6★ 干员**(棋子池里没有的那些,标「已持有」:3 个技能任选其一,精锐的模组任选其一或不装备;只在集成战略里生效的 ISW-A 模组不能选)或一名**原型干员**(标「原型」:Sharp、Mechanist 等 9 名 6★,5 阶另有 6 名 4★ 预备干员;技能和模组与补位时相同,不能更改)。同一名原型干员可以同时放进一个 5 阶和一个 6 阶名额;你持有的干员只能放一个名额;同一阶的两个名额不能放同一人。列表只列出已经做好战斗逻辑的干员(原型干员全部可选,持有的 6★ 随战斗逻辑完成陆续加入)。对局中,自选干员**只出现在你自己的商店里**:调度中心升到 5 级后才会刷出 5 阶名额的干员,6 级后才会刷出 6 阶的;刷新概率和同阶干员一样,每个名额有自己的库存(5 阶 8 张、6 阶 5 张,不与队友共用),价格与同阶干员相同,晋升奖励里也可能出现。三张合成精锐,精锐时所选模组生效(5 阶 1 级、6 阶 3 级)。自选干员**没有特质**,盟约按干员的实际阵营分配(推进之王是维多利亚,煌同时是炎和维多利亚,没有对应阵营的是协防干员),和普通棋子一样计入盟约人数;本局禁用了它全部的盟约时,它不会出现在商店里(协防不会被禁用)。商店卡片、整备区、作战区和详情卡上都标着「自选」,详情卡显示所选的技能和模组。这同样是**局外设置**:下一局开始时生效,对局中修改只保存到下一局;AI 队友不使用自选;信标不会把自选干员送给队友。「导出 / 导入」「全部清空」可用,大厅的「干员调配」按钮上会显示「自选 N」。
- 每回合商店会自动刷新未冻结的格子。
- **「本回合首次主动刷新」按每名干员各自计算**(拉普兰德):她在本回合遇到的第一次主动刷新生效,在整备区里也算——先刷新、再买下拉普兰德,下一次刷新她仍会叠层;同一回合内用已经生效过的拉普兰德合成的精锐,本回合不再生效。
+1 -1
View File
@@ -1228,7 +1228,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?, diy? }` (`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; `diy`: a 自选 piece's pick `{ charId, skillIndex, uniEquipId }` — `defId` is its slot, `spine` / `avatar` / `name` the operator's; `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?, diy? }` (`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; `diy`: a 自选 piece's pick `{ charId, skillIndex, uniEquipId }` — `defId` is its slot, `spine` / `avatar` / `name` the operator's; the prep scouting units of `Match.prepFieldMeta` and the m.result lineups carry it too (server/match/player/diy.js, the client composes the operator's card from it); `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);
+2
View File
@@ -91,6 +91,7 @@
.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-diy { font-size: .13rem; font-weight: 700; padding: .01rem .08rem; letter-spacing: .08em; background: var(--mint-500); color: var(--text-on-mint); }
.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; }
@@ -558,6 +559,7 @@ button.bpop__harmony:hover { border-style: solid; color: #fff; }
.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; }
.sitag--diy { background: var(--mint-500); color: var(--text-on-mint); }
.gm.is-dragging .sitags { display: none; }
/* ---- underframe: the selection diamond of a tapped own piece (ui/underframe.js): 撤退 / 出售 +N / 销毁 ----
+3
View File
@@ -133,6 +133,9 @@
/* 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); }
/* 0.2.0 自选编队: a DIY slot the player filled — the card is its operator, only in this player's shop (ui/shopBar.js) */
.scard__diy { position: absolute; z-index: 3; left: 0; top: .38rem; font-size: .12rem; font-weight: 700; padding: .01rem .06rem;
background: var(--mint-500); color: var(--text-on-mint); letter-spacing: .08em; white-space: nowrap; 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; }
+98
View File
@@ -385,6 +385,104 @@
.lo-tab { padding: .02rem .08rem; letter-spacing: .04em; }
}
/* ---- 自选编队 (screens/diy.js): four DIY slots by tier, the slot picker (operator list + skill / module) --------------- */
.diy { position: relative; flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; gap: .12rem; padding: .16rem .3rem .2rem; }
.diy__body { 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; }
.diy-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(max(5.2rem, 300px), 1fr)); gap: .1rem; }
.diy-slot {
position: relative; display: grid; grid-template-columns: auto minmax(0, 1fr) auto; gap: .1rem; align-items: start; min-width: 0;
padding: .08rem; background: rgba(255, 255, 255, .03); border: 1px solid var(--line); border-left: 3px solid var(--tc, var(--line-3));
}
.diy-slot--t5 { --tc: var(--tier-5); } .diy-slot--t6 { --tc: var(--tier-6); }
.diy-slot.is-empty { display: flex; flex-direction: column; padding: 0; border-style: dashed; border-left-style: solid; }
.diy-slot__fill {
display: flex; flex-direction: column; align-items: center; justify-content: center; gap: .05rem; width: 100%; min-height: max(1.3rem, 84px);
padding: .1rem; font: inherit; color: var(--text-md); background: none; border: 0; cursor: pointer; touch-action: manipulation;
}
.diy-slot__fill:hover { background: rgba(255, 255, 255, .05); color: var(--text-hi); }
.diy-slot__fill b { font-size: max(.16rem, 11px); }
.diy-slot__fill small { font-size: max(.12rem, 9px); color: var(--text-lo); }
.diy-slot__plus { display: grid; place-items: center; width: max(.4rem, 26px); height: max(.4rem, 26px); border: 1px solid var(--line-3); border-radius: 50%; }
.diy-slot__plus .icon { width: 60%; height: 60%; }
.diy-slot__art { display: grid; place-items: center; width: max(.9rem, 56px); height: max(1.3rem, 84px); overflow: hidden; background: #0a0d0c; border: 1px solid var(--line-2); }
.diy-slot__art img { width: 100%; height: 100%; object-fit: cover; object-position: top; }
.diy-slot__art b { font-size: max(.3rem, 18px); color: var(--text-lo); }
.diy-slot__body { display: flex; flex-direction: column; gap: .03rem; min-width: 0; }
.diy-slot__head { display: flex; align-items: center; gap: .05rem; }
.diy-slot__label { font-size: max(.12rem, 9px); color: var(--text-lo); letter-spacing: .06em; }
.diy-slot__name { font-size: max(.17rem, 12px); font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.diy-slot__class { font-size: max(.12rem, 9px); color: var(--text-lo); }
.diy-slot__kit { display: flex; align-items: center; gap: .05rem; min-width: 0; font-size: max(.13rem, 9px); color: var(--text-md); }
.diy-slot__kit > span { min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.diy-slot__sicon, .diy-slot__micon { flex: none; display: inline-grid; place-items: center; width: max(.24rem, 16px); height: max(.24rem, 16px); object-fit: contain; font-size: .8em; color: var(--text-lo); }
.diy-slot__micon--none { border: 1px solid var(--line-2); }
.diy-slot__acts { display: flex; flex-direction: column; gap: .04rem; }
.diy-slot__bad { display: flex; align-items: center; gap: .04rem; margin: .02rem 0 0; font-size: max(.12rem, 9px); color: var(--amber); }
.diy-slot.is-bad { border-color: rgba(255, 196, 0, .45); }
.diy-tag { display: inline-flex; align-items: center; height: 1.5em; padding: 0 .4em; font-size: max(.11rem, 8px); font-weight: 700; border-radius: 1em; white-space: nowrap; }
.diy-tag--proto { color: var(--ice-900); background: var(--ice); }
.diy-tag--own { color: var(--text-on-mint); background: var(--mint-500); }
.diy-bonds { display: flex; flex-wrap: wrap; gap: .03rem .06rem; }
.diy-bond { display: inline-flex; align-items: center; gap: .03rem; font-size: max(.12rem, 9px); color: var(--text-md); }
.diy-bond .bglyph, .diy-bond img { width: max(.16rem, 11px); height: max(.16rem, 11px); }
/* the picker covers the tab: the list of the slot's tier on the left, the chosen operator's skill / module on the right */
.diy-pick { position: absolute; inset: 0; z-index: 3; display: flex; flex-direction: column; gap: .08rem; padding: .12rem .3rem .14rem; background: var(--bg-1, #0d1110); }
.diy-pick__head { flex: none; display: flex; align-items: center; gap: .08rem; font-size: max(.16rem, 11px); }
.diy-pick__note { flex: 1; min-width: 0; font-size: max(.12rem, 9px); color: var(--text-lo); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.diy-pick__x { display: grid; place-items: center; width: max(.4rem, 28px); height: max(.4rem, 28px); color: var(--text-md); background: none; border: 1px solid var(--line-2); cursor: pointer; }
.diy-pick__bar { flex: none; display: flex; align-items: center; gap: .1rem; flex-wrap: wrap; }
.diy-pick__search { flex: 1 1 2rem; min-width: 120px; height: max(.36rem, 26px); padding: 0 .08rem; font: inherit; font-size: max(.13rem, 10px); color: var(--text-hi); background: rgba(255, 255, 255, .04); border: 1px solid var(--line-2); }
.diy-pick__main { flex: 1 1 auto; min-height: 0; display: grid; grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr); gap: .12rem; }
.diy-pick__list, .diy-pick__detail { min-height: 0; overflow: auto; -webkit-overflow-scrolling: touch; overscroll-behavior: contain; scrollbar-width: thin; scrollbar-color: var(--line-3) transparent; }
.diy-pick__list { display: grid; grid-template-columns: repeat(auto-fill, minmax(max(3.2rem, 200px), 1fr)); align-content: start; gap: .06rem; }
.diy-opt {
position: relative; display: flex; align-items: center; gap: .07rem; min-width: 0; padding: .06rem; font: inherit; color: var(--text-hi); text-align: left;
background: rgba(255, 255, 255, .03); border: 1px solid var(--line); cursor: pointer; touch-action: manipulation;
}
.diy-opt:hover { background: rgba(255, 255, 255, .06); }
.diy-opt.is-sel { border-color: var(--mint-500); background: rgba(52, 211, 153, .08); }
.diy-opt.is-taken { opacity: .45; cursor: not-allowed; }
.diy-opt__ava { flex: none; display: grid; place-items: center; width: max(.5rem, 32px); height: max(.5rem, 32px); overflow: hidden; background: #0a0d0c; border: 1px solid var(--line-2); }
.diy-opt__ava img { width: 100%; height: 100%; object-fit: cover; }
.diy-opt__txt { display: flex; flex-direction: column; gap: .02rem; min-width: 0; }
.diy-opt__top { display: flex; align-items: center; gap: .04rem; min-width: 0; }
.diy-opt__name { font-size: max(.15rem, 11px); font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.diy-opt__class { display: inline-flex; align-items: center; gap: .03rem; font-size: max(.11rem, 8px); color: var(--text-lo); }
.diy-opt__prof { width: max(.14rem, 10px); height: max(.14rem, 10px); }
.diy-opt__taken { position: absolute; right: .04rem; bottom: .03rem; font-size: max(.1rem, 8px); color: var(--amber); }
.diy-pick__detail { display: flex; flex-direction: column; gap: .06rem; padding: .06rem; background: rgba(255, 255, 255, .02); border: 1px solid var(--line); }
.diy-pick__who { display: flex; align-items: center; gap: .08rem; }
.diy-pick__ava { display: grid; place-items: center; width: max(.6rem, 40px); height: max(.6rem, 40px); overflow: hidden; background: #0a0d0c; border: 1px solid var(--line-2); }
.diy-pick__ava img { width: 100%; height: 100%; object-fit: cover; }
.diy-pick__wtxt { display: flex; flex-direction: column; flex: 1; min-width: 0; }
.diy-pick__wtxt b { font-size: max(.17rem, 12px); }
.diy-pick__wtxt small { font-size: max(.12rem, 9px); color: var(--text-lo); }
.diy-pick__sec { display: flex; align-items: baseline; gap: .06rem; margin: .04rem 0 0; font-size: max(.14rem, 10px); color: var(--text-md); }
.diy-pick__sec small { font-size: max(.11rem, 8px); color: var(--text-lo); font-weight: 400; }
.diy-pick__choices { display: flex; flex-direction: column; gap: .04rem; }
.diy-choice {
display: flex; align-items: flex-start; gap: .07rem; padding: .05rem; font: inherit; color: var(--text-hi); text-align: left;
background: rgba(255, 255, 255, .03); border: 1px solid var(--line); cursor: pointer; touch-action: manipulation;
}
.diy-choice:disabled { cursor: default; }
.diy-choice.is-on { border-color: var(--mint-500); background: rgba(52, 211, 153, .08); }
.diy-choice__icon { flex: none; display: grid; place-items: center; width: max(.36rem, 24px); height: max(.36rem, 24px); color: var(--text-lo); }
.diy-choice__icon img { width: 100%; height: 100%; object-fit: contain; }
.diy-choice__body { display: flex; flex-direction: column; gap: .02rem; min-width: 0; }
.diy-choice__head { display: flex; align-items: center; gap: .05rem; flex-wrap: wrap; font-size: max(.14rem, 10px); }
.diy-choice__desc { font-size: max(.12rem, 9px); line-height: 1.45; color: var(--text-md); }
.diy-pick__foot { flex: none; display: flex; justify-content: flex-end; gap: .08rem; }
/* 0.2.0 自选编队: the slots the player filled (the entry button's chip) */
.lo-entry__diy { color: var(--text-on-mint); background: var(--mint-500); }
@media (max-width: 760px) {
.diy { padding: .1rem .16rem .16rem; }
.diy-pick { padding: .08rem .12rem .1rem; }
.diy-pick__main { grid-template-columns: minmax(0, 1fr); grid-template-rows: minmax(0, 1fr) minmax(0, 1.1fr); }
.diy-slot { grid-template-columns: auto minmax(0, 1fr); }
.diy-slot__acts { grid-column: 1 / -1; flex-direction: row; justify-content: flex-end; }
}
/* ---- 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; }
+55 -1
View File
@@ -845,5 +845,59 @@
"【奇迹】下次刷新不消耗资金": "[Marvel] Your next refresh is free",
"【维多利亚】获得{n}件维式重锤": "[Victoria] Obtained {n} {n|Victorian Hammer|Victorian Hammers}",
"【远见】所有干员购买价格永久降低": "[Foresight] Every Operator's purchase price is permanently reduced",
"【远见】远见干员购买价格永久降低": "[Foresight] [Foresight] Operators' purchase price is permanently reduced"
"【远见】远见干员购买价格永久降低": "[Foresight] [Foresight] Operators' purchase price is permanently reduced",
"原型": "Prototype",
"已持有": "Owned",
"{tier}阶 自选": "Tier {tier} Custom",
"{slot}:选择干员": "{slot}: choose an Operator",
"选择干员": "Choose an Operator",
"调度中心等级 {level} 起出现在商店": "In the shop from Dispatch Center level {level}",
"这项自选在当前版本不可用,开局时会被移除": "This pick is not available in this version; it is dropped when the match starts",
"(锁定)": "(locked)",
"(精锐时生效)": "(as an Elite)",
"更换": "Change",
"清空": "Clear",
"已在{tier}阶自选中": "Already in a Tier {tier} Custom slot",
"锁定": "Locked",
"6★ 原型干员与持有的 6★ 干员,另有 4★ 原型干员(先锋、特种除外)": "6★ prototype Operators and the 6★ Operators you own, plus 4★ Reserve Operators (no Vanguards or Specialists)",
"6★ 原型干员与持有的 6★ 干员": "6★ prototype Operators and the 6★ Operators you own",
"筛选": "Filter",
"可选干员": "Operators to pick",
"连接服务器后才能选择自选干员": "Connect to the server to pick Custom Squad Operators",
"原型干员的技能与补位时一致,不可更改": "A prototype carries the skill it uses as a stand-in; it cannot be changed",
"精锐时生效 · 模组等级 {stage}": "As an Elite · module stage {stage}",
"从左侧选择一名干员": "Choose an Operator on the left",
"5阶与6阶各有两个自选名额:选择持有的 6★ 干员(任选技能与模组)或原型干员(技能锁定)。自选干员只出现在你自己的商店里,调度中心达到该阶等级后才会刷新,价格与同阶干员相同;盟约按干员实际所属阵营分配,没有特质。": "Tiers 5 and 6 have two Custom Squad slots each: pick a 6★ Operator you own (any skill and module) or a prototype Operator (skill locked). A Custom Squad Operator appears only in your own shop, once the Dispatch Center reaches its tier, at the price of any Operator of that tier; its Alliances follow its actual factions, and it has no Attribute.",
"{tier}阶": "Tier {tier}",
"阶": "Tier",
"自选编队:{name}(只在你的商店出现)": "Custom Squad: {name} (only in your shop)",
"自选": "Custom",
"自选编队:所选技能与模组,没有特质,盟约按所属阵营分配": "Custom Squad: the chosen skill and module, no Attribute, Alliances by its factions",
"自选编队:{name}": "Custom Squad: {name}",
"全部清空": "Clear All",
"清空全部 {n} 个自选名额?": "Clear all {n} Custom Squad {n|slot|slots}?",
"文件过大,请选择「导出」下载的自选编队文件": "The file is too large. Choose a Custom Squad file downloaded with \"Export\".",
"导入失败:{error}": "Import failed: {error}",
"已导入 {n} 个自选名额": "Imported {n} Custom Squad {n|slot|slots}",
"(另有 {n} 项不可用,未导入)": " ({n} unavailable {n|entry was|entries were} not imported)",
"已导入:全部名额为空": "Imported: every slot empty",
"自选编队是局外设置:本局按开局时的设置进行,修改将在下一局生效": "The Custom Squad is set outside matches: this match keeps the picks it started with; changes apply from the next match",
"局外设置,下一局生效 · 联机时只进入你自己的商店": "Set outside matches, applies from the next match · in team play only in your own shop",
"干员调配 / 干员持有 / 自选编队": "Operator Loadout / Operator Roster / Custom Squad",
"5阶、6阶的自选名额:持有的 6★ 干员或原型干员": "The Custom Squad slots of tiers 5 and 6: 6★ Operators you own or prototype Operators",
"自选编队": "Custom Squad",
"{n} 个自选名额已选择": "{n} Custom Squad {n|slot|slots} filled",
"已选": "Filled",
"导出自选编队(可复制或下载)": "Export the Custom Squad (copy or download)",
"导入自选编队(粘贴或选择文件)": "Import a Custom Squad (paste or choose a file)",
"共 {n} 个自选名额已选择。复制或下载这份数据,即可在别的设备或浏览器上导入。": "{n} Custom Squad {n|slot|slots} filled. Copy or download this data to import it on another device or browser.",
"把导出的自选编队数据粘贴到下方,或点「选择文件」。导入会覆盖当前的自选编队。": "Paste exported Custom Squad data below, or click \"Choose File\". Importing replaces the current Custom Squad.",
"在此粘贴导出的自选编队内容…": "Paste the exported Custom Squad here…",
"导出自选编队": "Export Custom Squad",
"导入自选编队": "Import Custom Squad",
"自选编队是局外设置,修改将在下一局生效": "The Custom Squad is set outside matches; changes apply from the next match",
"下一局生效": "Applies next match",
"这份数据来自更新的版本(v{v}),请先更新游戏": "This data comes from a newer version (v{v}). Update the game first.",
"这不是自选编队的数据": "This is not Custom Squad data",
"里面没有自选编队的数据": "It holds no Custom Squad data"
}
+4 -2
View File
@@ -21,7 +21,8 @@
// 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), its 干员持有 tab's
// not-owned list (0.2.0 补位) by installOwnershipSync (room.ownership, likewise).
// not-owned list (0.2.0 补位) by installOwnershipSync (room.ownership, likewise), the 自选编队 picks (0.2.0 DIY) by
// installDiySync (room.diy, likewise; it also keeps welcome.diyKitted for the picker).
// 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.
// Language (ui/lang.js, docs/I18N.md): chosen before the first render (initLang); App re-renders on a switch (useLang).
@@ -48,7 +49,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, installOwnershipSync } from './ui/loadoutSync.js';
import { installLoadoutSync, installOwnershipSync, installDiySync } from './ui/loadoutSync.js';
import { startBuildGuard } from './ui/buildGuard.js';
import { initLang, useLang, tickerText } from './ui/lang.js';
import { t, N_, translateWire } from '../../shared/i18n.js';
@@ -340,6 +341,7 @@ async function boot() {
wireNet();
installLoadoutSync({ net });
installOwnershipSync({ net });
installDiySync({ 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() });
+9 -3
View File
@@ -276,6 +276,7 @@ export async function createFieldView(host, options = {}) {
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 diyPicks = {}; // the own m.private.diy (0.2.0 自选编队): pieces of these DIY slots draw the operator
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)
@@ -288,7 +289,7 @@ export async function createFieldView(host, options = {}) {
timeScale: () => (mode === 'battle' ? interp.rate : 1),
// (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)),
: (info.standInFor && data.standIn(info.defId)) || (info.diy && data.diy(info.defId, info.diy)) || data.chess(info.defId) || data.token(info.defId)),
crowded: () => views.size > 90,
renderer: app.renderer,
frameNo: () => frameNo,
@@ -304,7 +305,7 @@ export async function createFieldView(host, options = {}) {
timeScale: () => ctx.timeScale(),
loadLevel: () => loadLevel,
fieldRect: () => (mode === 'battle' && battleMeta ? battleMeta.rect : null),
subProfOf: (defId, info = null) => ((info && info.standInFor && data.standIn(defId)) || data.chess(defId))?.subProfessionId || null,
subProfOf: (defId, info = null) => ((info && info.standInFor && data.standIn(defId)) || (info && info.diy && data.diy(defId, info.diy)) || data.chess(defId))?.subProfessionId || null,
view: (id) => views.get(id) || null,
screenSize: size,
fieldTop: () => {
@@ -633,12 +634,16 @@ export async function createFieldView(host, options = {}) {
// 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;
// 0.2.0 自选编队: a DIY slot the player filled is its operator, on the board and on the bench alike
const pick = chess && chess.isDiy ? diyPicks[chess.baseId || chess.chessId] : null;
const dr = pick ? data.diy(piece.id, pick) : null;
const rec = si || dr || chess;
return {
kind: 'op', side: 'ally', defId: piece.id,
spine: rec?.assets?.spine || rec?.charId || null, avatar: rec?.assets?.avatar || rec?.charId || null,
tier: chess?.tier || piece.tier || 1, golden: !!(piece.golden || chess?.isGolden), dir,
...(si ? { standInFor: si.standInFor } : null),
...(dr ? { diy: { charId: pick.charId, skillIndex: pick.skillIndex ?? null, uniEquipId: pick.uniEquipId ?? null } } : null),
};
}
@@ -687,6 +692,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') : [];
diyPicks = src.diy && typeof src.diy === 'object' ? src.diy : {};
ownPen = Array.isArray(src.nextEnemies) ? src.nextEnemies : null;
setPenList(ownPen);
addList(src.hand, 'hand');
+20 -2
View File
@@ -2,6 +2,7 @@
import { createAssets, assets as defaultAssets } from '../../assets.js';
import { standInRecord } from '../../../../shared/standIn.js';
import { diyRecordOf } from '../../../../shared/diy.js';
function withTimeout(p, ms) {
return Promise.race([p, new Promise((resolve) => setTimeout(resolve, ms))]);
@@ -40,10 +41,27 @@ function makeData(src) {
standIns.set(c, rec);
return rec;
};
// 0.2.0 自选编队: the composed 自选 record of a DIY slot filled with a pick (shared/diy.js over chess.json + backups.json)
const diys = new Map();
const chessMap = () => {
try { return (src && typeof src.get === 'function' ? src.get('chess') : src && src.chess) || null; } catch { return null; }
};
const diy = (id, pick) => {
const c = look('chess', id);
const b = backups();
if (!c || !c.isDiy || !b || !pick || typeof pick.charId !== 'string') return null;
const key = `${id}|${pick.charId}|${pick.skillIndex ?? ''}|${pick.uniEquipId ?? ''}`;
if (diys.has(key)) return diys.get(key);
let rec;
try { rec = diyRecordOf(c, pick, { chess: chessMap(), backups: b }); } catch { rec = null; }
diys.set(key, rec);
return rec;
};
return {
chess: (id) => look('chess', id), token: (id) => look('tokens', id), item: (id) => look('items', id),
// (a 自选 operator's summons are data/backups.json tokens, 0.2.0)
chess: (id) => look('chess', id), token: (id) => look('tokens', id) || (backups()?.tokens && Object.hasOwn(backups().tokens, id) ? backups().tokens[id] : null), item: (id) => look('items', id),
enemy: (id) => look('enemies', id), stage: (id) => look('stages', id), bond: (id) => look('bonds', id),
standIn,
standIn, diy,
};
}
+5
View File
@@ -45,5 +45,10 @@ export function renderInfo(u) {
// 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,
// 0.2.0 自选编队: the pick of a DIY slot's unit (UnitInfo.diy { charId, skillIndex, uniEquipId }; spine / avatar / name
// are already the operator's) — the detail card composes the operator from it, the data lookups follow it
diy: u.diy && typeof u.diy === 'object' && typeof u.diy.charId === 'string'
? { charId: u.diy.charId, skillIndex: Number.isInteger(u.diy.skillIndex) ? u.diy.skillIndex : null, uniEquipId: typeof u.diy.uniEquipId === 'string' ? u.diy.uniEquipId : null }
: undefined,
};
}
+244
View File
@@ -0,0 +1,244 @@
// 自选编队 (0.2.0 DIY; research 0.2.0 §2, the owner's decisions of 2026-10-05): the third tab of the 干员调配 overlay
// (screens/loadout.js). Four slots — 5阶 ×2, 6阶 ×2 — each filled with an operator: a 6★ the player owns outside the
// chess pool (any of its three skills, any module of its elite form but a 集成战略 one) or a prototype (原型干员, its skill
// and module locked as when it stands in). Only operators with a kit are listed (the server's welcome.diyKitted). The
// slot picker lists the legal picks of the slot's tier — avatar, name, class, the bonds derived from its factions, a
// 原型 / 已持有 tag —, an operator held by another slot greyed out with where it is. In the match the operator is sold
// only in this player's shop, from 调度中心 level 5 (tier 5) / 6 (tier 6), and fights with the pick (server/match/player
// /diy.js). Out of match: the next match takes the picks. The picks live in ui/loadoutSync.js (localStorage + room.diy);
// the model is ui/diyModel.js. Styles: css/screens/loadout.css (diy-*).
import { useState } from '../../vendor/hooks.module.js';
import { html, Icon, Button, TierChip } from '../ui/components.js';
import { Img, RichText, BondGlyph } from '../ui/gameComponents.js';
import { chessAvatarUrl, chessPortraitUrl, profIconUrl, skillRecordIconUrl, moduleTypeIconUrl } from '../ui/assetUrls.js';
import { data } from '../data.js';
import { PROF_NAME, skillLabel, moduleBadge } from '../ui/loadoutModel.js';
import { diySlotList, pickChoices, pickOptions, slotRecord, defaultPick } from '../ui/diyModel.js';
import { t } from '../../../shared/i18n.js';
const cx = (...p) => p.flat().filter(Boolean).join(' ');
const ROMAN = ['', 'I', 'II', 'III', 'IV', 'V', 'VI'];
/** The data the 自选 rules read (shared/diy.js): chess.json + backups.json (localized copies). */
export const diyData = () => ({ chess: data.get('chess'), backups: data.get('backups') });
/** A unit summary's avatar / portrait URL (a backups.json unit or a composed record). */
const avatarOf = (m, unit, elite = false) => chessAvatarUrl(m, unit && { charId: unit.charId, assets: { avatar: elite ? unit.assets?.avatarGolden : unit.assets?.avatar } });
const classLine = (u) => `${t(PROF_NAME[u?.profession] || '') || ''}${u?.subProfessionName ? ` · ${u.subProfessionName}` : ''}`;
const bondName = (id) => data.lookup('bonds', id)?.name || id;
/** The 原型 / 已持有 tag of a pick. */
function KindTag({ proto }) {
return html`<span class=${cx('diy-tag', proto ? 'diy-tag--proto' : 'diy-tag--own')}>${proto ? t('原型') : t('已持有')}</span>`;
}
/** Bond chips (the bonds derived from the operator's factions; 协防 when none matches). */
function Bonds({ bonds }) {
return html`<span class="diy-bonds">${(bonds || []).map((b) => html`<span key=${b} class="diy-bond"><${BondGlyph} bondId=${b} /><span>${bondName(b)}</span></span>`)}</span>`;
}
/** One slot: the pick (operator, class, bonds, skill, module) or an empty slot to fill. */
function SlotCard({ m, slot, pick, illegal, onOpen, onClear }) {
const D = diyData();
const rec = pick ? slotRecord(slot.slotId, pick, D) : null;
const elite = pick ? slotRecord(slot.slotId, pick, D, { elite: true }) : null;
const label = t('{tier}阶 自选', { tier: slot.tier });
if (!rec) {
return html`<div class=${cx('diy-slot', `diy-slot--t${slot.tier}`, 'is-empty', pick && 'is-bad')} data-slot=${slot.slotId}>
<button type="button" class="diy-slot__fill" onClick=${() => onOpen(slot.slotId)} aria-label=${t('{slot}:选择干员', { slot: label })}>
<${TierChip} tier=${slot.tier} size="sm" />
<span class="diy-slot__plus"><${Icon} name="plus" /></span>
<b>${t('选择干员')}</b>
<small>${t('调度中心等级 {level} 起出现在商店', { level: slot.shopLevel })}</small>
</button>
${pick ? html`<p class="diy-slot__bad"><${Icon} name="warn" />${t('这项自选在当前版本不可用,开局时会被移除')}</p>` : null}
</div>`;
}
const proto = !!pick && D.backups?.diy?.prototypes?.[slot.tier]?.includes(pick.charId);
const sk = rec.skill;
const mod = elite?.module?.active ? elite.module : null;
return html`<div class=${cx('diy-slot', `diy-slot--t${slot.tier}`, illegal && 'is-bad')} data-slot=${slot.slotId} data-char=${rec.charId}>
<div class="diy-slot__art"><${Img} src=${chessPortraitUrl(m, elite || rec)} fallback=${html`<b>${[...(rec.name || '?')][0]}</b>`} /></div>
<div class="diy-slot__body">
<div class="diy-slot__head"><${TierChip} tier=${slot.tier} size="sm" /><span class="diy-slot__label">${label}</span><${KindTag} proto=${proto} /></div>
<b class="diy-slot__name">${rec.name}</b>
<small class="diy-slot__class">${classLine(rec)}</small>
<${Bonds} bonds=${rec.bonds} />
<span class="diy-slot__kit" data-skill=${sk?.index ?? ''}>
<${Img} src=${skillRecordIconUrl(m, sk, { empty: false })} class="diy-slot__sicon" fallback=${html`<b class="num">${skillLabel(sk?.index)}</b>`} />
<span><b class="num">${skillLabel(sk?.index)}</b> ${sk?.name || ''}${proto ? html` <small class="t-dim">${t('(锁定)')}</small>` : null}</span>
</span>
<span class="diy-slot__kit" data-module=${mod ? mod.id : 'none'}>
${mod ? html`<${Img} src=${moduleTypeIconUrl(data.get('local'), mod.type)} class="diy-slot__micon" fallback=${html`<b class="num">${moduleBadge({ typeName: mod.type })}</b>`} />` : html`<span class="diy-slot__micon diy-slot__micon--none">—</span>`}
<span>${mod ? html`<b class="num">${mod.type || ''}</b> ${mod.name || ''}` : t('不装备模组')} <small class="t-dim">${t('(精锐时生效)')}</small></span>
</span>
${illegal ? html`<p class="diy-slot__bad"><${Icon} name="warn" />${t('这项自选在当前版本不可用,开局时会被移除')}</p>` : null}
</div>
<div class="diy-slot__acts">
<${Button} variant="secondary" size="sm" icon="refresh" data-testid="diy-change" onClick=${() => onOpen(slot.slotId)}>${t('更换')}<//>
<${Button} variant="ghost" size="sm" icon="close" data-testid="diy-clear" onClick=${() => onClear(slot.slotId)}>${t('清空')}<//>
</div>
</div>`;
}
/** One operator of the picker's list. */
function OptionCard({ m, opt, selected, onSel }) {
const u = opt.unit;
const name = u?.name || opt.charId;
const takenBy = opt.taken ? diySlotList(diyData()).find((s) => s.slotId === opt.taken) : null;
return html`<button type="button" role="option" aria-selected=${selected ? 'true' : 'false'} data-char=${opt.charId}
class=${cx('diy-opt', selected && 'is-sel', opt.taken && 'is-taken')} disabled=${!!opt.taken} onClick=${() => onSel(opt.charId)}
title=${takenBy ? t('已在{tier}阶自选中', { tier: takenBy.tier }) : name}>
<span class="diy-opt__ava"><${Img} src=${avatarOf(m, u)} fallback=${html`<b>${[...name][0]}</b>`} /></span>
<span class="diy-opt__txt">
<span class="diy-opt__top"><b class="diy-opt__name">${name}</b><${KindTag} proto=${opt.proto} /></span>
<small class="diy-opt__class"><${Img} src=${profIconUrl(m, u?.profession)} class="diy-opt__prof" />${classLine(u)}</small>
<${Bonds} bonds=${opt.bonds} />
</span>
${takenBy ? html`<span class="diy-opt__taken">${t('已在{tier}阶自选中', { tier: takenBy.tier })}</span>` : null}
</button>`;
}
/** A skill choice of an owned pick (or the locked skill of a prototype). */
function SkillRow({ m, s, on, locked, onPick }) {
const rec = s.elite || s.normal;
return html`<button type="button" role="radio" aria-checked=${on ? 'true' : 'false'} data-skill=${s.index} disabled=${locked}
class=${cx('diy-choice', on && 'is-on')} onClick=${() => onPick(s.index)}>
<span class="diy-choice__icon"><${Img} src=${skillRecordIconUrl(m, rec, { empty: false })} fallback=${html`<b class="num">${skillLabel(s.index)}</b>`} /></span>
<span class="diy-choice__body">
<span class="diy-choice__head"><b class="num">${skillLabel(s.index)}</b><b>${rec?.name || ''}</b>${on ? html`<span class="lo-badge lo-badge--on"><${Icon} name="check" />${locked ? t('锁定') : t('已选择')}</span>` : null}</span>
<${RichText} as="span" class="diy-choice__desc" text=${s.normal?.descRaw || s.normal?.desc || ''} />
</span>
</button>`;
}
/** A module choice of an owned pick (不装备 or a module of the elite form). */
function ModuleRow({ id, rec, on, locked, onPick }) {
const none = id == null;
const talents = rec && Array.isArray(rec.talentChanges) ? rec.talentChanges.filter((x) => x && x.desc && !x.hidden) : [];
const trait = rec?.traitOverride ? (rec.traitOverride.moduleDescRaw || rec.traitOverride.moduleDesc || rec.traitOverride.descRaw || rec.traitOverride.desc) : null;
return html`<button type="button" role="radio" aria-checked=${on ? 'true' : 'false'} data-module=${none ? 'none' : id} disabled=${locked}
class=${cx('diy-choice', 'diy-choice--mod', on && 'is-on')} onClick=${() => onPick(id)}>
<span class="diy-choice__icon">${none ? html`<b>—</b>` : html`<${Img} src=${moduleTypeIconUrl(data.get('local'), rec.typeName)} fallback=${html`<b class="num">${moduleBadge(rec)}</b>`} />`}</span>
<span class="diy-choice__body">
<span class="diy-choice__head">${none ? html`<b>${t('不装备')}</b>` : html`<b class="num">${rec.typeName || ''}</b><b>${rec.name || id}</b>`}${on ? html`<span class="lo-badge lo-badge--on"><${Icon} name="check" />${locked ? t('锁定') : t('已选择')}</span>` : null}</span>
${trait ? html`<${RichText} as="span" class="diy-choice__desc" text=${trait} />` : null}
${talents.map((x, i) => html`<${RichText} key=${i} as="span" class="diy-choice__desc" text=${x.descRaw || x.desc} />`)}
</span>
</button>`;
}
/**
* The slot picker: the legal operators of the slot's tier (prototypes first, then the owned 6★; a 原型 / 已持有 filter and
* a search), the chosen one's skill / module (an owned pick) or its locked selection (a prototype), 确认 / 取消. The
* state (filter, search, the draft pick) lives here; DiyPickerView draws it.
* @param {{ m: any, slot: any, picks: Record<string, any>, kitted: string[]|null, onDone: (pick: any) => void, onClose: () => void }} props
*/
export function DiyPicker(props) {
const cur = props.picks[props.slot.slotId] || null;
const [filter, setFilter] = useState('all');
const [query, setQuery] = useState('');
const [draft, setDraft] = useState(cur ? { ...cur } : null);
return DiyPickerView({ ...props, filter, query, draft, onFilter: setFilter, onQuery: setQuery, onDraft: setDraft });
}
/**
* The picker's view (no hooks: the tests draw it): `filter` 'all' | 'proto' | 'owned', `query`, `draft` the pick being
* made (null = none chosen yet) and their setters.
*/
export function DiyPickerView({ m, slot, picks, kitted, onDone, onClose, filter, query, draft, onFilter: setFilter, onQuery: setQuery, onDraft: setDraft }) {
const D = diyData();
const cur = picks[slot.slotId] || null;
const options = pickOptions(slot.slotId, picks, D, kitted);
const q = query.trim().toLowerCase();
const list = options.filter((o) => (filter === 'all' || (filter === 'proto') === o.proto)
&& (!q || [o.unit?.name, o.unit?.appellation, o.unit?.subProfessionName, t(PROF_NAME[o.unit?.profession] || ''), ...o.bonds.map(bondName)].some((x) => typeof x === 'string' && x.toLowerCase().includes(q))));
const ch = draft ? pickChoices(draft.charId, slot.slotId, D) : null;
const sel = (charId) => setDraft(cur && cur.charId === charId ? { ...cur } : defaultPick(charId, slot.slotId, D));
const lk = ch?.locked || null;
const skillOn = ch ? (ch.proto ? lk?.skillIndex : draft.skillIndex) : null;
const modOn = ch ? (ch.proto ? lk?.uniEquipId ?? null : draft.uniEquipId ?? null) : null;
const unit = draft ? D.backups?.units?.[draft.charId] : null;
const label = t('{tier}阶 自选', { tier: slot.tier });
return html`<section class="diy-pick" role="dialog" aria-label=${t('{slot}:选择干员', { slot: label })} data-testid="diy-picker" data-slot=${slot.slotId}>
<header class="diy-pick__head">
<${TierChip} tier=${slot.tier} size="sm" /><b>${label}</b>
<span class="diy-pick__note">${slot.tier === 5 ? t('6★ 原型干员与持有的 6★ 干员,另有 4★ 原型干员(先锋、特种除外)') : t('6★ 原型干员与持有的 6★ 干员')}</span>
<button type="button" class="diy-pick__x" aria-label=${t('关闭')} onClick=${onClose}><${Icon} name="close" /></button>
</header>
<div class="diy-pick__bar">
<div class="lo-chips" role="group" aria-label=${t('筛选')}>
${[['all', t('全部')], ['proto', t('原型')], ['owned', t('已持有')]].map(([k, text]) => html`<button key=${k} type="button" class=${cx('lo-chip', filter === k && 'is-on')}
aria-pressed=${filter === k ? 'true' : 'false'} data-filter=${k} onClick=${() => setFilter(k)}>${text}</button>`)}
</div>
<input type="search" class="diy-pick__search" value=${query} placeholder=${t('搜索干员 / 职业 / 盟约')} aria-label=${t('搜索干员 / 职业 / 盟约')}
onInput=${(e) => setQuery(String(e.currentTarget.value).slice(0, 24))} />
</div>
<div class="diy-pick__main">
<div class="diy-pick__list" role="listbox" aria-label=${t('可选干员')}>
${kitted == null ? html`<p class="lo-empty t-dim">${t('连接服务器后才能选择自选干员')}</p>`
: list.length ? list.map((o) => html`<${OptionCard} key=${o.charId} m=${m} opt=${o} selected=${draft?.charId === o.charId} onSel=${sel} />`)
: html`<p class="lo-empty t-dim">${t('没有符合条件的干员')}</p>`}
</div>
<div class="diy-pick__detail">
${ch && unit ? html`
<div class="diy-pick__who">
<span class="diy-pick__ava"><${Img} src=${avatarOf(m, unit, true)} fallback=${html`<b>${[...(unit.name || '?')][0]}</b>`} /></span>
<span class="diy-pick__wtxt"><b>${unit.name}</b><small>${classLine(unit)}</small></span>
<${KindTag} proto=${ch.proto} />
</div>
<h4 class="diy-pick__sec">${t('技能')}${ch.proto ? html`<small>${t('原型干员的技能与补位时一致,不可更改')}</small>` : null}</h4>
<div role="radiogroup" aria-label=${t('选择技能')} class="diy-pick__choices">
${ch.skills.filter((s) => !ch.proto || s.index === skillOn).map((s) => html`<${SkillRow} key=${s.index} m=${m} s=${s} on=${s.index === skillOn} locked=${ch.proto}
onPick=${(i) => setDraft({ ...draft, skillIndex: i })} />`)}
</div>
<h4 class="diy-pick__sec">${t('模组')}<small>${t('精锐时生效 · 模组等级 {stage}', { stage: ch.stage })}</small></h4>
<div role="radiogroup" aria-label=${t('选择模组')} class="diy-pick__choices">
${ch.proto
? html`<${ModuleRow} id=${modOn} rec=${modOn ? ch.elite.modules?.find((x) => x.uniEquipId === modOn) || null : null} on=${true} locked=${true} onPick=${() => {}} />`
: [html`<${ModuleRow} key="none" id=${null} rec=${null} on=${modOn == null} locked=${false} onPick=${() => setDraft({ ...draft, uniEquipId: null })} />`,
...ch.modules.map((x) => html`<${ModuleRow} key=${x.uniEquipId} id=${x.uniEquipId} rec=${x.rec} on=${modOn === x.uniEquipId} locked=${false}
onPick=${(id) => setDraft({ ...draft, uniEquipId: id })} />`)]}
</div>` : html`<p class="lo-empty t-dim">${t('从左侧选择一名干员')}</p>`}
</div>
</div>
<footer class="diy-pick__foot">
<${Button} variant="ghost" onClick=${onClose}>${t('取消')}<//>
<${Button} variant="primary" icon="check" data-testid="diy-confirm" disabled=${!ch} onClick=${() => onDone(ch.proto ? { charId: draft.charId } : draft)}>${t('确认')}<//>
</footer>
</section>`;
}
/**
* The tab's body: the four slots by tier, and the picker of the slot being filled (`picking`, kept here).
* @param {{ m: any, picks: Record<string, any>, legal: Record<string, any>, kitted: string[]|null,
* onSet: (slotId: string, pick: any) => void }} props
*/
export function DiyPanel(props) {
const [picking, setPicking] = useState(null);
return DiyPanelView({ ...props, picking, onPicking: setPicking });
}
/** The tab's view (no hooks: the tests draw it): `picking` = the slot being filled, or null. */
export function DiyPanelView({ m, picks, legal, kitted, onSet, picking, onPicking: setPicking }) {
const slots = diySlotList(diyData());
const slot = picking ? slots.find((s) => s.slotId === picking) : null;
const tiers = [...new Set(slots.map((s) => s.tier))];
return html`<main class="diy" data-testid="diy">
<div class="own__bar">
<p class="own__lead">${t('5阶与6阶各有两个自选名额:选择持有的 6★ 干员(任选技能与模组)或原型干员(技能锁定)。自选干员只出现在你自己的商店里,调度中心达到该阶等级后才会刷新,价格与同阶干员相同;盟约按干员实际所属阵营分配,没有特质。')}</p>
</div>
<div class="diy__body">
${tiers.map((tier) => html`<section key=${tier} class="own-tier diy-tier" data-tier=${tier} aria-label=${t('{tier}阶', { tier })}>
<h3 class="own-tier__head"><${TierChip} tier=${tier} size="sm" /><span class="num">${ROMAN[tier]}</span><span>${t('阶')}</span></h3>
<div class="diy-grid">
${slots.filter((s) => s.tier === tier).map((s) => html`<${SlotCard} key=${s.slotId} m=${m} slot=${s} pick=${picks[s.slotId] || null}
illegal=${!!picks[s.slotId] && !legal[s.slotId]} onOpen=${setPicking} onClear=${(id) => onSet(id, null)} />`)}
</div>
</section>`)}
</div>
${slot ? html`<${DiyPicker} m=${m} slot=${slot} picks=${picks} kitted=${kitted} onClose=${() => setPicking(null)}
onDone=${(pick) => { onSet(slot.slotId, pick); setPicking(null); }} />` : null}
</main>`;
}
+13 -9
View File
@@ -96,7 +96,7 @@ import {
snapHud, activeBubbles, shortcutFor, shortcutBlocked, closesOnFieldPress, phaseTotalSeconds, homeFieldId, ownFieldId, normalizeSp, sortedPlayers,
countdownState, shopBlockReason, stageOverrides, effectiveStage, watchTarget, dropFailureReason,
previewEnemyKey, prepCamera, prepCameraFor, foldCamera, deployFieldOf, panelSide, panelSlots, bondPopupPlace, unitLoadout, deployedRecord,
mergeTarget, modeOffBonds, readyFundsPrompt, ownerBandId,
mergeTarget, modeOffBonds, readyFundsPrompt, ownerBandId, ownDiyRecord,
} from '../ui/gameLogic.js';
import { toast } from '../ui/toasts.js';
import { BriefingScreen } from './briefing.js';
@@ -236,9 +236,13 @@ function MatchScreen() {
// the field the own pieces are deployed on: the own board, or the player's half of the boss field in a boss round's
// prep (user playtest #5 item 7: legality and the legal-tile highlights read THOSE tiles, like the server)
const deployField = deployFieldOf(pub, myId);
// the own pieces' records: a DIY slot the player filled is its 自选 operator (0.2.0, m.private.diy — position, range,
// name; gameLogic/diy.js), every other chess its data record
const ownChess = (id) => { const c = gd.chess(id); return ownDiyRecord(c, live.current.priv, { chess: data.get('chess'), backups: data.get('backups') }) || c; };
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, backups: gd.backups,
getChess: (id) => { const c = gd.chess(id); return ownDiyRecord(c, priv, { chess: data.get('chess'), backups: data.get('backups') }) || c; },
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 };
@@ -719,12 +723,12 @@ 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) => deployedRecord(rec, live.current.priv, gd.chess, gd.backups) };
const lookups = { getChess: ownChess, getToken: gd.token, getItem: gd.item, chessRecord: (rec) => deployedRecord(rec, live.current.priv, ownChess, gd.backups) };
const runIntent = async (intent) => {
const L = live.current;
if (intent.confirmReplace) {
// both slots used: the player picks the equipped item to destroy (cancel ⇒ nothing is sent)
const request = replaceRequest(L.placeCtx, intent, gd.chess, gd.item);
const request = replaceRequest(L.placeCtx, intent, ownChess, gd.item);
if (request) {
const uid = await openReplaceRef.current(request);
if (!Number.isInteger(uid)) { audio.sfx('back', { volume: 0.5 }); return; }
@@ -750,7 +754,7 @@ function MatchScreen() {
/** Open the direction wheel for a legal board drop (the piece stays on the tile meanwhile). */
const openFacing = (entry, t) => {
const piece = entry.piece;
const rec = piece.kind === 'item' ? gd.item(piece.id) : piece.kind === 'token' ? gd.token(piece.id) : gd.chess(piece.id);
const rec = piece.kind === 'item' ? gd.item(piece.id) : piece.kind === 'token' ? gd.token(piece.id) : ownChess(piece.id);
holdPiece(view, piece.uid, { row: t.row, col: t.col });
setSel(null);
setFacing({ uid: piece.uid, piece, row: t.row, col: t.col, grid: previewGrid(lookups, piece), name: rec?.name || '' });
@@ -889,8 +893,8 @@ 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); 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) => deployedRecord(rec, live.current.priv, gd.chess, gd.backups) }), [gd.ready]);
const lookups = useMemo(() => ({ getChess: ownChess, getToken: gd.token, getItem: gd.item,
chessRecord: (rec) => deployedRecord(rec, live.current.priv, ownChess, 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);
@@ -1200,7 +1204,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}
${showPrep && view && viewKind !== 'loading' && priv && !pen ? html`<${StandInTags} view=${view} priv=${priv} getChess=${gd.chess} backups=${gd.backups} diyData=${{ chess: data.get('chess'), backups: data.get('backups') }} />` : null}
<div class="gm__hud" ref=${hudElRef}>
<${TopBar} pub=${pub} priv=${priv} conn=${conn} hud=${hud} total=${total} drawer=${drawer}
@@ -1270,7 +1274,7 @@ function MatchScreen() {
${selEntry && editable && !facing && !drag && showPrep ? html`<${Underframe} key=${sel.uid} view=${view} uid=${sel.uid}
row=${pieceTile(selEntry)?.row} col=${pieceTile(selEntry)?.col} actions=${underframeActions(placeCtx, sel.uid)} busy=${selBusy}
name=${(selEntry.piece.kind === 'item' ? gd.item(selEntry.piece.id) : selEntry.piece.kind === 'token' ? gd.token(selEntry.piece.id) : gd.chess(selEntry.piece.id))?.name || ''}
name=${(selEntry.piece.kind === 'item' ? gd.item(selEntry.piece.id) : selEntry.piece.kind === 'token' ? gd.token(selEntry.piece.id) : ownChess(selEntry.piece.id))?.name || ''}
onRetreat=${retreatSel} onSell=${sellSel} onDestroy=${sellSel} />` : null}
</div>
+29 -7
View File
@@ -1,12 +1,15 @@
// 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.
// chess it is either way. 0.2.0 自选编队: the 「自选」 tag under the player's own pieces of a DIY slot it filled
// (m.private.diy) — the model is the operator's everywhere. 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';
import { fieldsStandIn, standInOf, standInLabel, ownDiyRecord } from '../../ui/gameLogic.js';
import { t } from '../../../../shared/i18n.js';
const rawOf = (view) => (view && view.raw) || view || null;
@@ -30,9 +33,27 @@ export function standInPieces(priv, getChess, backups) {
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);
/**
* The player's own prep pieces of a DIY slot it filled (0.2.0 自选编队): [{ uid, label, name, area, diy: true }].
* @param {any} priv m.private @param {(id: string) => any} getChess @param {{ chess: any, backups: any }} data
*/
export function diyPieces(priv, getChess, data) {
const out = [];
if (!priv || !priv.diy || typeof priv.diy !== 'object' || !Object.keys(priv.diy).length) return out;
const add = (p, area) => {
if (!p || p.kind !== 'chess' || !Number.isInteger(p.uid)) return;
const rec = ownDiyRecord(getChess(p.id), priv, data);
if (rec) out.push({ uid: p.uid, label: t('自选'), name: rec.name, area, diy: true });
};
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, diyData?: { chess: any, backups: any } | null }} props */
export function StandInTags({ view, priv, getChess, backups, diyData = null }) {
const items = [...standInPieces(priv, getChess, backups), ...(diyData ? diyPieces(priv, getChess, diyData) : [])];
const [pos, setPos] = useState({});
const last = useRef('');
const key = items.map((it) => it.uid).join(',');
@@ -64,7 +85,8 @@ export function StandInTags({ view, priv, getChess, backups }) {
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} 上场`}
return p ? html`<span key=${it.uid} class=${`sitag sitag--${it.area}${it.diy ? ' sitag--diy' : ''}`} data-uid=${it.uid} data-diy=${it.diy ? '1' : undefined}
title=${it.diy ? t('自选编队:{name}', { name: it.name }) : `未持有:由 ${it.name} 上场`}
style=${`left:${p.x.toFixed(1)}px;top:${p.y.toFixed(1)}px`}>${it.label}</span>` : null;
})}
</div>`;
+67 -16
View File
@@ -14,6 +14,8 @@
// The loadout lives in ui/loadoutSync.js (localStorage + room.loadout); the model is ui/loadoutModel.js.
// 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 导出 / 导入 / 全部持有.
// The third tab, 自选编队 (0.2.0 DIY, screens/diy.js): the operators of the four DIY slots (room.diy; ui/diyModel.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';
@@ -29,9 +31,12 @@ 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, setNotOwned, applyOwnershipImport } from '../ui/loadoutSync.js';
import { loadoutStore, openLoadout, closeLoadout, setEntries, applyLoadoutEntries, setNotOwned, applyOwnershipImport, setDiyPicks, applyDiyImport } from '../ui/loadoutSync.js';
import { setOwned, notOwnedCount, serializeOwnership, parseOwnershipImport, OWNERSHIP_IMPORT_MAX_BYTES } from '../ui/ownershipModel.js';
import { OwnershipPanel, useOwnershipRoster } from './ownership.js';
import { DiyPanel, diyData } from './diy.js';
import { diyCount, sanitizeDiyPicks, setPick, serializeDiy, parseDiyImport, DIY_IMPORT_MAX_BYTES } from '../ui/diyModel.js';
import { t } from '../../../shared/i18n.js';
import { copyText } from '../ui/clipboard.js';
import { toast } from '../ui/toasts.js';
@@ -71,7 +76,7 @@ function readFileText(file) {
});
}
/** `stronghold-loadout-20261003-1245.json` (`stronghold-ownership-…` for the 干员持有 list) */
/** `stronghold-loadout-20261003-1245.json` (`stronghold-ownership-…` for the 干员持有 list, `stronghold-diy-…` for 自选编队) */
function exportFilename(now = new Date(), what = 'loadout') {
const p = (n) => String(n).padStart(2, '0');
return `stronghold-${what}-${now.getFullYear()}${p(now.getMonth() + 1)}${p(now.getDate())}-${p(now.getHours())}${p(now.getMinutes())}.json`;
@@ -383,10 +388,12 @@ const SYNC_TEXT = {
};
/** The 干员持有 tab's status line: the setting never applies to a running match. */
const OWN_SYNC_TEXT = { ...SYNC_TEXT, locked: ['下一局生效', 'is-warn'] };
/** The 自选编队 tab's status line (msgids, translated where shown): out of match, like 干员持有. */
const DIY_SYNC_TEXT = { ...OWN_SYNC_TEXT };
/** The overlay screen. */
function LoadoutScreen({ st }) {
const ready = useData('chess', 'bonds', 'assets', 'local');
const ready = useData('chess', 'bonds', 'assets', 'local', 'backups');
const phase = useStore((s) => s.match?.public?.phase || null);
const inMatch = useStore((s) => !!s.room?.inMatch);
// co-op briefing (INFO_CHECK, 25 s): the overlay covers the briefing's own countdown, so it shows the time left — the
@@ -411,9 +418,18 @@ 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 tab = st.tab === 'ownership' || st.tab === 'diy' ? st.tab : 'loadout';
const ownRoster = useOwnershipRoster(ready);
const nNotOwned = notOwnedCount(st.notOwned, ownRoster);
// 自选编队 (0.2.0 DIY): the stored picks, those the server would keep (the kit list of the last welcome)
const nDiy = diyCount(st.diy);
const diyLegal = ready ? sanitizeDiyPicks(st.diy, diyData(), st.diyKitted || []) : {};
const setDiySlot = (slotId, pick) => setDiyPicks(setPick(loadoutStore.get().diy, slotId, pick));
const clearDiy = async () => {
if (!nDiy) return;
const ok = await confirmDialog({ title: t('全部清空'), text: t('清空全部 {n} 个自选名额?', { n: nDiy }), okText: t('全部清空') });
if (ok) setDiyPicks({});
};
const setTab = (t) => loadoutStore.set({ tab: t });
const toggleOwned = (id, owned) => setNotOwned(setOwned(loadoutStore.get().notOwned, id, owned));
const ownAll = async () => {
@@ -434,27 +450,38 @@ function LoadoutScreen({ st }) {
// the clipboard, or the textarea); `io.kind` says which
const ioText = io?.text ?? '';
const ioOwn = io?.kind === 'ownership';
const ioDiy = io?.kind === 'diy';
const openExport = () => setIo(tab === 'ownership'
? { mode: 'export', kind: 'ownership', text: serializeOwnership(loadoutStore.get().notOwned) }
: { mode: 'export', kind: 'loadout', text: serializeExport(loadoutStore.get().entries) });
: tab === 'diy' ? { mode: 'export', kind: 'diy', text: serializeDiy(loadoutStore.get().diy) }
: { 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(new Date(), ioOwn ? 'ownership' : 'loadout'), ioText);
const ioDownload = () => downloadText(exportFilename(new Date(), ioOwn ? 'ownership' : ioDiy ? 'diy' : '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 (ioDiy && f.size > DIY_IMPORT_MAX_BYTES) { toast(t('文件过大,请选择「导出」下载的自选编队文件'), 'error'); return; }
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 (ioDiy) {
const r = parseDiyImport(ioText);
if (!r.ok) { toast(t('导入失败:{error}', { error: t(r.error, r.params) }), 'error'); return; }
const { applied, dropped } = applyDiyImport(r.picks, diyData(), loadoutStore.get().diyKitted || []);
setIo(null);
toast(applied ? t('已导入 {n} 个自选名额', { n: applied }) + (dropped ? t('(另有 {n} 项不可用,未导入)', { n: dropped }) : '') : t('已导入:全部名额为空'), dropped ? 'warn' : 'success');
return;
}
if (ioOwn) {
const r = parseOwnershipImport(ioText);
if (!r.ok) { toast(`导入失败:${r.error}`, 'error'); return; }
@@ -482,7 +509,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') && loadoutStore.get().tab !== 'ownership') {
if ((e.key === 'ArrowRight' || e.key === 'ArrowLeft') && loadoutStore.get().tab !== 'ownership' && loadoutStore.get().tab !== 'diy') {
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));
@@ -503,12 +530,15 @@ function LoadoutScreen({ st }) {
if (el && typeof el.scrollIntoView === 'function') el.scrollIntoView({ block: 'nearest' });
}, [selId]);
const [syncText, syncCls] = tab === 'ownership' ? OWN_SYNC_TEXT[st.ownSync] || OWN_SYNC_TEXT.idle : SYNC_TEXT[st.sync] || SYNC_TEXT.idle;
const [syncText, syncCls] = tab === 'ownership' ? OWN_SYNC_TEXT[st.ownSync] || OWN_SYNC_TEXT.idle
: tab === 'diy' ? DIY_SYNC_TEXT[st.diySync] || DIY_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 ? '干员持有是局外设置:本局按开局时的设置进行,修改将在下一局生效'
: '局外设置,下一局生效 · 联机时只影响你自己的棋子 · 默认全部持有';
const diyText = ownLocked ? t('自选编队是局外设置:本局按开局时的设置进行,修改将在下一局生效')
: t('局外设置,下一局生效 · 联机时只进入你自己的商店');
return html`<${Fragment}>
<div class="lo" role="dialog" aria-modal="true" aria-label="干员调配">
@@ -518,15 +548,23 @@ 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">${tab === 'ownership' ? 'OPERATOR ROSTER' : 'OPERATOR LOADOUT'}<//>
<div class="lo-tabs" role="tablist" aria-label="干员调配 / 干员持有">
<${MicroLabel} tone="mint">${tab === 'ownership' ? 'OPERATOR ROSTER' : tab === 'diy' ? 'SELF-SELECT SQUAD' : 'OPERATOR LOADOUT'}<//>
<div class="lo-tabs" role="tablist" aria-label=${t('干员调配 / 干员持有 / 自选编队')}>
<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>
<button type="button" role="tab" aria-selected=${tab === 'diy' ? 'true' : 'false'} data-tab="diy" class=${cx('lo-tab', tab === 'diy' && 'is-on')} onClick=${() => setTab('diy')}
title=${t('5阶、6阶的自选名额:持有的 6★ 干员或原型干员')}>${t('自选编队')}${nDiy ? html`<span class="lo-tab__n num" aria-label=${t('{n} 个自选名额已选择', { n: nDiy })}>${nDiy}</span>` : null}</button>
</div>
</div>
${tab === 'ownership' ? html`<div class="lo-top__right">
${tab === 'diy' ? html`<div class="lo-top__right">
${syncText ? html`<span class=${cx('lo-sync', syncCls)} role="status" data-testid="diy-sync">${t(syncText)}</span>` : null}
<span class="lo-count">${t('已选')} <b class="num">${nDiy}</b><span class="num t-dim">/4</span></span>
<${Button} variant="ghost" size="sm" data-testid="diy-export" disabled=${!nDiy} onClick=${openExport} title=${t('导出自选编队(可复制或下载)')}>${t('导出')}<//>
<${Button} variant="ghost" size="sm" data-testid="diy-import" disabled=${!ready} onClick=${openImport} title=${t('导入自选编队(粘贴或选择文件)')}>${t('导入')}<//>
<${Button} variant="secondary" size="sm" icon="refresh" data-testid="diy-reset" disabled=${!nDiy} onClick=${clearDiy}>${t('全部清空')}<//>
</div>` : 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="导出干员持有(可复制或下载)">导出<//>
@@ -541,10 +579,14 @@ function LoadoutScreen({ st }) {
<${Button} variant="secondary" size="sm" icon="refresh" disabled=${!nChanged} onClick=${resetAll}>全部恢复默认<//>
</div>`}
</header>
${tab === 'ownership'
${tab === 'diy'
? html`<p class=${cx('lo-note', ownLocked && 'is-locked')}><${Icon} name="info" />${diyText}</p>`
: 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'
${!ready ? html`<div class="lo-loading"><${Spinner} size="sm" />正在载入干员数据(打开页面后仅载入一次)…</div>` : tab === 'diy'
? html`<${DiyPanel} m=${m} picks=${st.diy || {}} legal=${diyLegal} kitted=${st.diyKitted} onSet=${setDiySlot} />`
: 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">
@@ -562,7 +604,8 @@ function LoadoutScreen({ st }) {
</main>`}
</div>
${io ? html`<${Modal} open=${true} onClose=${() => setIo(null)}
title=${`${io.mode === 'export' ? '导出' : '导入'}${ioOwn ? '干员持有' : '干员调配'}`} micro=${ioOwn ? 'OPERATOR ROSTER' : 'OPERATOR LOADOUT'}
title=${ioDiy ? t(io.mode === 'export' ? '导出自选编队' : '导入自选编队') : `${io.mode === 'export' ? '导出' : '导入'}${ioOwn ? '干员持有' : '干员调配'}`}
micro=${ioOwn ? 'OPERATOR ROSTER' : ioDiy ? 'SELF-SELECT SQUAD' : '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}>复制<//>
@@ -570,7 +613,11 @@ 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">${ioOwn
<p class="lo-io__hint">${ioDiy
? (io.mode === 'export'
? t('共 {n} 个自选名额已选择。复制或下载这份数据,即可在别的设备或浏览器上导入。', { n: nDiy })
: t('把导出的自选编队数据粘贴到下方,或点「选择文件」。导入会覆盖当前的自选编队。'))
: ioOwn
? (io.mode === 'export'
? html`共 <b class="num">${nNotOwned}</b> 名干员未持有。复制或下载这份数据,即可在别的设备或浏览器上导入。`
: html`把导出的干员持有数据粘贴到下方,或点「选择文件」。导入会<strong>覆盖</strong>当前的干员持有设置。`)
@@ -578,7 +625,7 @@ function LoadoutScreen({ st }) {
? 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' ? '' : ioOwn ? '在此粘贴导出的干员持有内容…' : '在此粘贴导出的调配内容…'}
placeholder=${io.mode === 'export' ? '' : ioDiy ? t('在此粘贴导出的自选编队内容…') : 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}
@@ -632,14 +679,18 @@ export function LoadoutButton({ from, size = 'md', variant = 'secondary', class:
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 diy = useStore((s) => s.diy, 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;
// 0.2.0 自选编队: how many DIY slots the player filled
const nDiy = diyCount(diy);
return html`<button type="button" class=${cx('btn', `btn--${variant}`, `btn--${size}`, 'lo-entry', cls)} data-testid="loadout-open"
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}
${nDiy ? html`<span class="lo-entry__own lo-entry__diy" aria-label=${t('{n} 个自选名额已选择', { n: nDiy })} title=${t('{n} 个自选名额已选择', { n: nDiy })}>${t('自选')} <b class="num">${nDiy}</b></span>` : null}
</button>`;
}
+7 -3
View File
@@ -6,7 +6,9 @@
// { victory, roundsPassed, lastRound?, hiddenCleared?, bossId?, hiddenBossId?, difficulty?, modeId?, durationMs?,
// players: [{ playerId, seat, name, isBot, alive, lp, bandId, roundsPassed?,
// title: 'comment_1' | { id, name? } | null,
// lineup: [{ id /* chessId, golden id if elite */, golden?, tier?, items?: [itemId] }],
// lineup: [{ id /* chessId, golden id if elite */, golden?, tier?, items?: [itemId],
// diy? /* 0.2.0 自选编队: a DIY slot's pick { charId, skillIndex, uniEquipId } — the card
// draws the operator (gameLogic diyRecordFor) */ }],
// bonds?: [{ bondId, layers, active }],
// stats: { dmgDealt, kills, leaks, gold /* funds SPENT */, refreshes, merges, bossDamage?, itemsEquipped?,
// activatedLayers?, lpLost?, perfectRounds? } }] }
@@ -14,7 +16,8 @@
import { useEffect } from '../../vendor/hooks.module.js';
import { html, Button, Icon, MicroLabel, DifficultyTag } from '../ui/components.js';
import { useGameData, Img, UnitThumb, BandIcon, PlayerAvatar, BondGlyph, LpTower, Sprite } from '../ui/gameComponents.js';
import { normalizeResult, fmtNum } from '../ui/gameLogic.js';
import { normalizeResult, fmtNum, diyRecordFor } from '../ui/gameLogic.js';
import { data } from '../data.js';
import { enemyIconUrl, titleIconUrl, uiUrl } from '../ui/assetUrls.js';
import { store, useStore, emptyMatch } from '../store.js';
import { audio } from '../audio.js';
@@ -46,7 +49,8 @@ function PlayerCard({ p, myId, titles, best, solo = false }) {
</div>
<div class="rcard__mid">
<div class="rcard__lineup">
${lineup.length ? lineup.map((u, i) => html`<${UnitThumb} key=${i} kind=${u.kind === 'token' ? 'token' : 'chess'} id=${u.id} golden=${!!u.golden} tier=${u.tier} size="sm" />`)
${lineup.length ? lineup.map((u, i) => html`<${UnitThumb} key=${i} kind=${u.kind === 'token' ? 'token' : 'chess'} id=${u.id} golden=${!!u.golden} tier=${u.tier} size="sm"
rec=${u.diy ? diyRecordFor(gd.chess(u.id), u.diy, { chess: data.get('chess'), backups: data.get('backups') }) : null} />`)
: html`<span class="rcard__noinfo">${p.alive === false ? '阵容已撤离' : 'NO INFO'}</span>`}
</div>
${bonds.length ? html`<div class="rcard__bonds">${bonds.map((b) => html`<span key=${b.bondId} class=${cx('rbond', b.active && 'is-on')} title=${gd.bond(b.bondId)?.name || b.bondId}>
+9 -4
View File
@@ -21,10 +21,11 @@
import { html, BondDisc, Icon, MicroLabel, Tooltip } from './components.js';
import { RichText, UnitThumb, BondGlyph, GIcon } from './gameComponents.js';
import { sortBonds, bondMembers, nextThreshold, bondTier, harmonyMembers, HARMONY_BOND, memberHeadCount, briefingBondTip } from './gameLogic.js';
import { sortBonds, bondMembers, nextThreshold, bondTier, harmonyMembers, HARMONY_BOND, memberHeadCount, briefingBondTip, diyGetter, diyRecordFor } from './gameLogic.js';
import { formatBondEffect } from './richText.js';
import { bondIconUrl } from './assetUrls.js';
import { data } from '../data.js';
import { t } from '../../../shared/i18n.js';
const cx = (...p) => p.flat().filter(Boolean).join(' ');
@@ -99,8 +100,11 @@ export function BondPopup({ bondId, entry, priv, banned = [], onClose, onMember,
const th = Array.isArray(entry?.thresholds) && entry.thresholds.length ? entry.thresholds : b.thresholds || [];
const tier = off ? 0 : entry?.tier ?? bondTier(count, th, b.maxCount);
const active = !off && (entry ? !!entry.active : tier > 0);
const getChess = (id) => data.lookup('chess', id);
const members = bondMembers(b, priv, banned, getChess, (id) => data.lookup('items', id));
// (自选 pieces are their operators: the own ones by m.private.diy, a watched teammate's by the pick its unit carries)
const dd = { chess: data.get('chess'), backups: data.get('backups') };
const getChess = diyGetter((id) => data.lookup('chess', id), priv, dd);
const pieceRecord = (p) => (p && p.diy && typeof p.diy === 'object' ? diyRecordFor(data.lookup('chess', p.id), p.diy, dd) : null) || getChess(p.id);
const members = bondMembers(b, priv, banned, getChess, (id) => data.lookup('items', id), pieceRecord);
const countsHand = entry?.countsHand ?? b.countsHand;
const next = nextThreshold(count, th);
const hasNow = !off && !!(b.effectDescRaw || b.effectDesc);
@@ -149,10 +153,11 @@ export function BondPopup({ bondId, entry, priv, banned = [], onClose, onMember,
${members.map((mb) => html`<button key=${mb.id} type="button" class=${cx('bpop__member', mb.onBoard && 'is-on', mb.owned && !mb.onBoard && 'is-owned', mb.banned && 'is-banned', mb.granted && 'is-granted')}
onClick=${() => onMember?.(mb.id, mb.granted && Array.isArray(mb.items) ? mb.items : null)} data-granted=${mb.granted ? '1' : null}
title=${`${mb.name}${mb.granted ? '(变形同构体:视为本盟约成员)' : ''}${mb.banned ? '(本局禁用)' : mb.onBoard ? '(在场)' : mb.owned ? '(整备区)' : ''}`}>
<${UnitThumb} kind="chess" id=${mb.id} size="sm" dim=${!mb.owned || mb.banned} />
<${UnitThumb} kind="chess" id=${mb.id} size="sm" dim=${!mb.owned || mb.banned} rec=${mb.diy ? mb.rec : null} />
<span class="bpop__mname">${mb.name}</span>
${mb.banned ? html`<span class="bpop__ban"><${Icon} name="close" /></span>` : null}
${mb.granted ? html`<span class="bpop__iso" aria-hidden="true">同构</span>` : null}
${mb.diy ? html`<span class="bpop__iso bpop__diy" data-diy="1" aria-hidden="true">${t('自选')}</span>` : null}
</button>`)}
</div>
</section>
+29 -9
View File
@@ -32,14 +32,15 @@
// screen's 局内数值 section draws the same ones for the chosen skill / module, without a live entry (GitHub issue #64).
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, fieldsStandIn, standInOf, standInLoadout, standInLabel } from './gameLogic.js';
import { Img, RichText, UnitThumb, BondGlyph, GIcon, diyToken } from './gameComponents.js';
import { attackInterval, rangeGridBox, fmtNum, tileKey, chessLoadout, nextThreshold, bondTier, briefingBondTip, pieceBondIds, grantedBonds, morphPairings, fieldsStandIn, standInOf, standInLoadout, standInLabel, ownDiyRecord, ownDiyPick, diyRecordFor, pickGetter } 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';
import { attackRangeGrid } from '../../../shared/loadoutRecord.js';
import { SKILL_SUMMON_START_DEPLOY } from '../../../shared/constants.js';
import { moduleBadge } from './loadoutModel.js';
import { t } from '../../../shared/i18n.js';
const cx = (...p) => p.flat().filter(Boolean).join(' ');
@@ -354,10 +355,12 @@ 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, standIn = null }) {
export function ChessDetail({ chess, piece, unit, snapHp, editable, onSell, bonds, offBonds = null, loadout, onBond, live = null, hint = null, unitItems = null, standIn = null, diy = null }) {
const m = data.get('assets');
const hp = hpOf(live, snapHp);
const getChess = (id) => data.lookup('chess', id);
// 0.2.0 自选编队: `chess` is then the composed 自选 record (the operator, the slot's tier / price); its skill and module are
// the pick's — the lookups resolve the slot's ids to the same pick (shared/diy.js), so the loadout reads them as defaults
const getChess = diy ? pickGetter((id) => data.lookup('chess', id), diy, { chess: data.get('chess'), backups: data.get('backups') }) : (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;
@@ -392,6 +395,7 @@ export function ChessDetail({ chess, piece, unit, snapHp, editable, onSell, bond
${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}
${diy ? html`<span class="dtag-diy" data-diy=${c.charId || ''} title=${t('自选编队:所选技能与模组,没有特质,盟约按所属阵营分配')}>${t('自选')}</span>` : null}
</div>
<h3 class="dhead__name">${c.name}</h3>
<span class="dhead__en">${si ? `${si.name}${si.appellation && si.appellation !== si.name ? ` · ${si.appellation}` : ''}` : c.appellation || ''}</span>
@@ -613,18 +617,27 @@ export function TokenDetail({ token, piece, ownerId = null, snapHp = null, live
* @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)
* card shows as the body); 0.2.0 自选编队: the player's own pieces and cards of a DIY slot it filled (m.private.diy) —
* and a unit carrying `diy` — resolve to the composed 自选 record (`chess`, the operator) with `diy` = the pick
*/
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);
const dd = { chess: data.get('chess'), backups };
/** the own card of chess `c`: its 自选 record and pick when the player filled that DIY slot */
const ownDiy = (c) => {
const rec = c ? ownDiyRecord(c, priv, dd) : null;
return rec ? { chess: rec, diy: ownDiyPick(priv, c) } : null;
};
if (target.kind === 'piece') {
const e = pieces?.get(target.uid);
if (!e) return null;
const p = e.piece;
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; }
if (p.kind === 'token') { const t = data.lookup('tokens', p.id) || diyToken(p.id); return t ? { type: 'token', token: t, piece: p, ownerId: tokenOwnerId(p, pieces) } : null; }
const c = data.lookup('chess', p.id);
const d = ownDiy(c);
if (d) return { type: 'chess', chess: d.chess, piece: p, standIn: null, diy: d.diy };
return c ? { type: 'chess', chess: c, piece: p, standIn: ownStandIn(c) } : null;
}
if (target.kind === 'chess') {
@@ -634,11 +647,13 @@ export function resolveDetail(target, pieces, { priv = null, backups = data.get(
// (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);
const d = foreign ? null : ownDiy(c);
if (d) return { type: 'chess', chess: d.chess, hint: target.hint || null, standIn: null, diy: d.diy, ...(items.length ? { unitItems: items } : {}) };
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; }
if (target.kind === 'token') { const t = data.lookup('tokens', target.id); return t ? { type: 'token', token: t } : null; }
if (target.kind === 'token') { const t = data.lookup('tokens', target.id) || diyToken(target.id); return t ? { type: 'token', token: t } : null; }
if (target.kind === 'unit') {
const u = target.unit || {};
const own = Number.isInteger(u.uid) ? pieces?.get(u.uid) : null;
@@ -651,8 +666,13 @@ export function resolveDetail(target, pieces, { priv = null, backups = data.get(
let si = null;
if (c && typeof u.standInFor === 'string' && u.standInFor) si = standInOf(c, backups);
else if (c && own?.piece) si = ownStandIn(c);
// 0.2.0 自选编队: a unit says itself which operator fills its DIY slot (UnitInfo diy); an own piece's unit follows
// m.private.diy like the piece
const pick = c && u.diy && typeof u.diy === 'object' ? u.diy : own?.piece ? ownDiyPick(priv, c) : null;
const dr = pick ? diyRecordFor(c, pick, dd) : null;
if (dr) return { type: 'chess', chess: dr, piece: own?.piece || null, unitId: u.id, unitItems: Array.isArray(u.items) ? u.items : null, standIn: null, diy: pick };
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);
const t = data.lookup('tokens', u.defId) || diyToken(u.defId);
if (t) return { type: 'token', token: t, unitId: u.id, ownerId: tokenOwnerId(own?.piece, pieces) };
const en = data.lookup('enemies', u.defId);
return en ? { type: 'enemy', enemy: en, unitId: u.id } : null;
@@ -695,7 +715,7 @@ export function DetailPanel({ detail, editable, snapHp, onClose, onSell, onDestr
<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}
standIn=${detail.standIn || null} />` : null}
standIn=${detail.standIn || null} diy=${detail.diy || 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}
+200
View File
@@ -0,0 +1,200 @@
// 自选编队 (0.2.0 DIY; research 0.2.0 §2, the owner's decisions of 2026-10-05) — pure logic of the 自选编队 tab of the
// 干员调配 overlay (screens/diy.js), shared with the sync (ui/loadoutSync.js), the match UI (ui/gameLogic/diy.js) and
// the tests. Four DIY slots (data/backups.json `diy.slots`: two at tier 5, two at tier 6, sold from 调度中心 level 5 / 6);
// each takes an operator: a 6★ the player owns outside the chess pool (`diy.ownedPool`) — any of its three skills and
// any module of its elite form but a 集成战略 one (shared/diy.js isDiyModule) — or a prototype (原型干员) with its locked
// skill / module; only operators with a kit are offered (`kitted`: the server's `welcome.diyKitted`). A prototype may
// fill a tier-5 and a tier-6 slot, an owned operator one slot; the picks of a tier differ (shared/diy.js
// validateDiyPicks).
//
// The per-browser setting is `{ [slotBaseId]: { charId, skillIndex?, uniEquipId? } }`, persisted in localStorage
// (`sp.pref.diy` = { v: 1, picks }) and sent with C2S `room.diy { picks }` (the server keeps the legal picks and drops
// the rest — shared/protocol.js checkDiyPicks — so a stale pick never gets the roster refused). Out of match: the next
// match takes it. 导出 / 导入 use a versioned envelope like the 干员持有 list.
import { DIY_LIMITS, checkDiyPicks } from '../../../shared/protocol.js';
import { diySlotIds, diySlot, diyPool, isPrototypePick, lockedSelection, isDiyModule, diyRecord } from '../../../shared/diy.js';
import { unitForm } from '../../../shared/standIn.js';
/** localStorage key (store.js loadPref/savePref prefix `sp.pref.`) and format version. */
export const DIY_PREF = 'diy';
export const DIY_VERSION = 1;
/** `kind` of an exported 自选编队 envelope. */
export const DIY_EXPORT_KIND = 'stronghold.diy';
/** A picked file / pasted payload longer than this is refused before parsing (a real payload is under 1 KB). */
export const DIY_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);
const isId = (v) => typeof v === 'string' && ID_RE.test(v) && !UNSAFE_IDS.has(v);
/**
* The structurally valid picks of a raw map (junk dropped, empty slots left out; at most DIY_LIMITS.slots): each pick
* `{ charId, skillIndex?, uniEquipId? }` (skill 0–9, module an id; absent / null = the locked or no module).
* @param {any} raw
* @returns {Record<string, { charId: string, skillIndex?: number, uniEquipId?: string|null }>}
*/
export function cleanPicks(raw) {
const out = {};
if (!isObj(raw)) return out;
for (const [slotId, p] of Object.entries(raw)) {
if (Object.keys(out).length >= DIY_LIMITS.slots) break;
if (!isId(slotId) || !isObj(p) || !isId(p.charId)) continue;
const pick = { charId: p.charId };
if (Number.isInteger(p.skillIndex) && p.skillIndex >= 0 && p.skillIndex <= 9) pick.skillIndex = p.skillIndex;
if (isId(p.uniEquipId)) pick.uniEquipId = p.uniEquipId;
else if (p.uniEquipId === null) pick.uniEquipId = null;
out[slotId] = pick;
}
return out;
}
/** Parse a stored setting (any junk → {}): `{ v, picks }`. @param {any} raw */
export function parseStoredDiy(raw) {
return isObj(raw) ? cleanPicks(raw.picks) : {};
}
/** Serialised form for localStorage. */
export const toStoredDiy = (picks) => ({ v: DIY_VERSION, picks: cleanPicks(picks) });
/** Number of filled slots. */
export const diyCount = (picks) => Object.keys(cleanPicks(picks)).length;
/**
* The legal picks of a stored roster against the loaded data and the kit list (checkDiyPicks: what the server keeps).
* @param {any} picks @param {any} data `{ chess, backups }` @param {Iterable<string>|null} kitted
* @returns {Record<string, { charId: string, skillIndex: number, uniEquipId: string|null }>}
*/
export function sanitizeDiyPicks(picks, data, kitted) {
const res = checkDiyPicks(cleanPicks(picks), { data, kitted });
return res && 'ok' in res ? res.picks : {};
}
/**
* The four slots in data order: [{ slotId, goldenId, tier, shopLevel }].
* @param {any} data
*/
export function diySlotList(data) {
return diySlotIds(data).map((id) => diySlot(id, data)).filter(Boolean)
.map((s) => ({ slotId: s.baseId, goldenId: s.goldenId, tier: s.tier, shopLevel: s.shopLevel }));
}
const backupsOf = (data) => (isObj(data?.backups) ? data.backups : null);
/**
* What a slot's tier offers of an operator: its two forms (normal E2 Lv1 skill rank 4; elite E2 Lv60 rank 7, module
* stage 1 at tier 5 / 3 at tier 6), the skills (normal + elite record per index), the modules it may carry (the elite
* form's, 集成战略 ones left out) and, for a prototype, its locked selection. Null when the data lacks a form.
* @param {string} charId @param {string} slotId a slot base id @param {any} data
*/
export function pickChoices(charId, slotId, data) {
const slot = diySlot(slotId, data);
const backups = backupsOf(data);
if (!slot || !backups || !slot.normal || !slot.golden) return null;
const normal = unitForm(backups, charId, slot.normal.status);
const elite = unitForm(backups, charId, slot.golden.status);
if (!normal || !elite) return null;
const proto = isPrototypePick(data, slot.tier, charId);
const skills = (normal.skills || []).filter(Boolean).map((s) => ({ index: s.index, normal: s, elite: (elite.skills || []).find((e) => e && e.index === s.index) || s }));
const modules = (elite.modules || []).filter(isDiyModule).map((m) => ({ uniEquipId: m.uniEquipId, rec: m }));
return { tier: slot.tier, stage: slot.golden.status?.equipLevel ?? 0, proto, locked: proto ? lockedSelection(data, slot.tier, charId) : null, skills, modules, normal, elite };
}
/**
* The pick a newly chosen operator starts with: a prototype's locked selection (`{ charId }` — the server completes it),
* an owned operator's third skill (its last) and the first module it may carry (none when it has none).
* @param {string} charId @param {string} slotId @param {any} data
*/
export function defaultPick(charId, slotId, data) {
const ch = pickChoices(charId, slotId, data);
if (!ch) return { charId };
if (ch.proto) return { charId };
const last = ch.skills.length ? ch.skills[ch.skills.length - 1].index : 0;
return { charId, skillIndex: last, uniEquipId: ch.modules[0]?.uniEquipId ?? null };
}
/**
* The operators a slot lists (shared/diy.js diyPool of its tier with the kit list — prototypes first, then the owned
* 6★), each with what the picker card shows and whether another slot holds it: `taken` names that slot (an owned
* operator fills one slot; the two picks of a tier differ).
* @param {string} slotId @param {Record<string, any>} picks the current roster @param {any} data
* @param {Iterable<string>|null} kitted
* @returns {Array<{ charId: string, unit: any, bonds: string[], proto: boolean, taken: string|null }>}
*/
export function pickOptions(slotId, picks, data, kitted) {
const slot = diySlot(slotId, data);
const backups = backupsOf(data);
if (!slot || !backups) return [];
const kit = kitted ? [...kitted] : [];
const tierOf = (id) => diySlot(id, data)?.tier ?? null;
const roster = cleanPicks(picks);
return diyPool(slot.tier, { data, kitted: kit }).map((charId) => {
const proto = isPrototypePick(data, slot.tier, charId);
let taken = null;
for (const [other, p] of Object.entries(roster)) {
if (other === slot.baseId || p.charId !== charId) continue;
// the same tier: never twice; another tier: an owned operator only once (a prototype may repeat across tiers)
if (tierOf(other) === slot.tier || !isPrototypePick(data, tierOf(other), charId)) { taken = other; break; }
}
return { charId, unit: backups.units?.[charId] ?? null, bonds: backups.diy?.operators?.[charId]?.bonds ?? [], proto, taken };
});
}
/**
* The record a slot shows for a pick (shared/diy.js diyRecord: the operator at the slot's normal form, or the elite).
* @param {string} slotId @param {any} pick @param {any} data @param {{ elite?: boolean }} [opts]
*/
export function slotRecord(slotId, pick, data, { elite = false } = {}) {
if (!isObj(pick)) return null;
try { return diyRecord(slotId, pick, { elite, data }); } catch { return null; }
}
/** Set (or with null, clear) one slot. Returns a new roster (the input is not changed). */
export function setPick(picks, slotId, pick) {
const next = { ...cleanPicks(picks) };
if (isObj(pick) && isId(pick.charId)) next[slotId] = cleanPicks({ [slotId]: pick })[slotId];
else delete next[slotId];
return next;
}
// ---- export / import -------------------------------------------------------------------------------------------------
/** Portable payload of a roster, as downloaded / copied by 导出. */
export function exportDiy(picks, { now = Date.now() } = {}) {
const clean = cleanPicks(picks);
return {
kind: DIY_EXPORT_KIND,
v: DIY_VERSION,
exportedAt: new Date(Number.isFinite(now) ? now : Date.now()).toISOString(),
count: Object.keys(clean).length,
picks: clean,
};
}
/** Pretty JSON of `exportDiy`. */
export const serializeDiy = (picks, opts) => JSON.stringify(exportDiy(picks, opts), null, 2);
/**
* Parse an imported roster: the envelope, the stored `{ v, picks }` form, or the text of either. STRUCTURAL only — the
* caller sanitises against the loaded data (sanitizeDiyPicks). An envelope may hold no picks (= every slot empty).
* Errors are msgids (shown through t()).
* @param {any} input payload or text
* @returns {{ ok: true, picks: Record<string, any> } | { ok: false, error: string, params?: Record<string, any> }}
*/
export function parseDiyImport(input) {
let raw = input;
if (typeof raw === 'string') {
if (raw.length > DIY_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 (!isObj(raw)) return { ok: false, error: '无法识别的格式' };
const v = Number.isInteger(raw.v) ? raw.v : null;
if (v != null && v > DIY_VERSION) return { ok: false, error: '这份数据来自更新的版本(v{v}),请先更新游戏', params: { v } };
const kind = typeof raw.kind === 'string' ? raw.kind : null;
if (kind && kind !== DIY_EXPORT_KIND) return { ok: false, error: '这不是自选编队的数据' };
if (!isObj(raw.picks)) return { ok: false, error: '里面没有自选编队的数据' };
return { ok: true, picks: cleanPicks(raw.picks) };
}
+8 -3
View File
@@ -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, fieldsStandIn, standInOf } from './gameLogic.js';
import { tileKey, hasFlag, UF, penPlacement, PEN, fieldTile, fieldsStandIn, standInOf, ownDiyRecord } from './gameLogic.js';
const DRAG_PX = 6;
const DMG_TTL = 900;
@@ -126,12 +126,17 @@ export function createFallbackView(host, opts = {}) {
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);
return chessAvatarUrl(mm, si || ownDiy(chess) || chess);
}
/** 0.2.0 自选编队: the player's own piece of a DIY slot it filled is its operator (gameLogic ownDiyRecord) */
function ownDiy(chess) {
return chess ? ownDiyRecord(chess, st.priv, { chess: dataStore?.get?.('chess') ?? null, backups: dataStore?.get?.('backups') ?? null }) : null;
}
function pieceName(p) {
if (p.kind === 'item') return lookup('items', p.id)?.name || '道具';
if (p.kind === 'token') return lookup('tokens', p.id)?.name || '召唤物';
return lookup('chess', p.id)?.name || '干员';
const chess = lookup('chess', p.id);
return (ownDiy(chess) || chess)?.name || '干员';
}
function Piece({ p, x, y, L, area }) {
+12 -4
View File
@@ -26,6 +26,12 @@ export function useGameData() {
return useMemo(() => makeLookups(ready), [ready]);
}
/** A 自选 operator's summon record (data/backups.json `tokens`, 0.2.0), or null. */
export function diyToken(id) {
const t = typeof id === 'string' ? data.get('backups')?.tokens : null;
return t && Object.hasOwn(t, id) ? t[id] : null;
}
/** Non-hook lookups (for event handlers). */
export function makeLookups(ready = true) {
return {
@@ -39,7 +45,8 @@ export function makeLookups(ready = true) {
enemy: (k) => data.lookup('enemies', k),
boss: (id) => data.lookup('bosses', id),
stage: (id) => data.lookup('stages', id),
token: (id) => data.lookup('tokens', id),
// (a 自选 operator's summons are data/backups.json tokens — 0.2.0, the hand piece of a placeable one)
token: (id) => data.lookup('tokens', id) || diyToken(id),
effect: (id) => data.lookup('effects', id),
garrison: (id) => data.lookup('garrisons', id),
factions: data.get('factions'),
@@ -93,15 +100,16 @@ export const isGoldenPiece = (piece, chess) => !!(piece?.golden || chess?.isGold
/**
* Square unit thumbnail for a piece / chess / item / token / enemy: art + tier chip + elite frame.
* @param {{ kind?: 'chess'|'item'|'token'|'enemy', id: string, golden?: boolean, size?: 'xs'|'sm'|'md'|'lg', tier?: number,
* showTier?: boolean, class?: string, dim?: boolean, badge?: any, title?: string }} props
* showTier?: boolean, class?: string, dim?: boolean, badge?: any, title?: string, rec?: any }} props
* `rec`: the chess record to draw instead of the data's (a 自选 piece's composed record — 0.2.0, gameLogic/diy.js)
*/
export function UnitThumb({ kind = 'chess', id, golden, size = 'md', tier, showTier = true, class: cls, dim = false, badge = null, title }) {
export function UnitThumb({ kind = 'chess', id, golden, size = 'md', tier, showTier = true, class: cls, dim = false, badge = null, title, rec = null }) {
const m = data.get('assets');
let src = null;
let name = '';
let t = tier;
if (kind === 'chess') {
const c = data.lookup('chess', id);
const c = rec || data.lookup('chess', id);
src = chessAvatarUrl(m, c);
name = c?.name || '';
t = t ?? c?.tier;
+1
View File
@@ -42,4 +42,5 @@ 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 { diyPicks, ownDiyPick, diyRecordFor, ownDiyRecord, cardDiy, diyGetter, pickGetter, isDiyRecord } from './gameLogic/diy.js';
export { panelSide, panelSlots, PANEL_RIGHT_GAP, PANEL_RIGHT_BOTTOM, PANEL_RIGHT_BOTTOM_SHOP, BPOP, bondPopupPlace } from './gameLogic/panel.js';
+18 -1
View File
@@ -155,14 +155,18 @@ export function harmonyMembers(priv, getChess = () => null) {
* operators that are members through 变形同构体 (grantedBonds; `granted: true` and `items`: the item ids of the copy that
* wears the pair — the card the row opens shows them; one row per operator — normal and elite copies are one member,
* like the count's distinct members), so "成员 x/y" agrees with the count the server reports (在场; memberHeadCount).
* 0.2.0 自选编队: the 自选 pieces of the bond (a DIY slot filled with an operator, whose bonds come from its factions — the
* own pieces through `getChess` (gameLogic/diy.js diyGetter), a teammate's through `pieceRecord` (its UnitInfo `diy`
* pick) are rows too (`diy: true`, the operator's name and record `rec`).
* `inHand`: in the 整备区 (hand), not the 5 temporary slots — what BOARD_AND_DECK bonds (投资人 远见 奇迹) count.
* @param {any} bond bonds.json record
* @param {any} priv m.private (hand/board/temp) — or a teammate's field operators (ui/watchBonds.js ownerBoard)
* @param {Set<string>|string[]} [banned] banned base chess ids
* @param {(id:string)=>any} [getChess]
* @param {(id:string)=>any} [getItem] items.json lookup (the 变形同构体 pairings)
* @param {((p: any) => any)|null} [pieceRecord] the record of one of the pieces (default: getChess(p.id))
*/
export function bondMembers(bond, priv, banned = [], getChess = () => null, getItem = () => null) {
export function bondMembers(bond, priv, banned = [], getChess = () => null, getItem = () => null, pieceRecord = null) {
const bannedSet = banned instanceof Set ? banned : new Set(Array.isArray(banned) ? banned : []);
const members = Array.isArray(bond?.visibleMembers) && bond.visibleMembers.length ? bond.visibleMembers : (Array.isArray(bond?.members) ? bond.members : []);
const baseOf = (id) => getChess(id)?.baseId || (typeof id === 'string' ? id.replace(/_b$/, '_a') : id);
@@ -173,11 +177,22 @@ export function bondMembers(bond, priv, banned = [], getChess = () => null, getI
/** base id → { on: on the board?, hand: in the hand?, items: the wearer's item ids } — operators of the player that join this bond through 变形同构体 */
const granted = new Map();
const grants = (p) => typeof bond?.bondId === 'string' && grantedBonds(p.items, getItem).includes(bond.bondId);
/** base id → { on, hand, rec } — the player's 自选 pieces whose operator carries this bond */
const diy = new Map();
const diyOf = (p, on, hand) => {
const rec = pieceRecord ? pieceRecord(p) : getChess(p.id);
if (!rec || typeof rec.diyFor !== 'string' || !Array.isArray(rec.bonds) || !rec.bonds.includes(bond?.bondId)) return false;
const g = diy.get(rec.diyFor);
if (!g) diy.set(rec.diyFor, { on, hand, rec });
else { g.on = g.on || on; g.hand = g.hand || hand; }
return true;
};
const itemIds = (p) => p.items.map((it) => (typeof it === 'string' ? it : it?.id)).filter((x) => typeof x === 'string');
for (const p of Array.isArray(priv?.board) ? priv.board : []) {
if (p?.kind !== 'chess') continue;
const base = baseOf(p.id);
onBoard.add(base); owned.add(base);
if (diyOf(p, true, false)) continue;
if (!memberSet.has(base) && !granted.has(base) && grants(p)) granted.set(base, { on: true, hand: false, items: itemIds(p) });
}
for (const [list, hand] of [[priv?.hand, true], [priv?.temp, false]]) {
@@ -186,6 +201,7 @@ export function bondMembers(bond, priv, banned = [], getChess = () => null, getI
const base = baseOf(p.id);
owned.add(base);
if (hand) inHand.add(base);
if (diyOf(p, false, hand)) continue;
if (memberSet.has(base) || !grants(p)) continue;
const g = granted.get(base);
if (!g) granted.set(base, { on: false, hand, items: itemIds(p) });
@@ -200,6 +216,7 @@ export function bondMembers(bond, priv, banned = [], getChess = () => null, getI
const c = getChess(id);
rows.push({ id, tier: c?.tier ?? 0, name: c?.name ?? id, onBoard: g.on, owned: true, inHand: g.hand, banned: false, granted: true, items: g.items });
}
for (const [id, g] of diy) rows.push({ id, tier: g.rec.tier ?? 0, name: g.rec.name ?? id, onBoard: g.on, owned: true, inHand: g.hand, banned: false, diy: true, rec: g.rec });
return rows.sort((a, b) => (b.onBoard - a.onBoard) || (b.owned - a.owned) || (a.tier - b.tier) || (a.id < b.id ? -1 : 1));
}
+94
View File
@@ -0,0 +1,94 @@
// ui/gameLogic/diy.js — 自选编队 pieces in the match UI (0.2.0 DIY; the owner's decisions of 2026-10-05). Re-exported
// from ../gameLogic.js.
//
// A DIY slot (chess_char_5_diy1_a …) has no body of its own (甄选干员): each player's piece of it is the operator that
// player slotted — its name, art, class, bonds (from its factions), the pick's skill and module, no 特质; the slot's tier,
// price and merge. Whose pick: the player's own pieces and cards follow m.private.diy (the picks its 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 `diy`: the sim's, Match.prepFieldMeta's for scouting, m.result lineups). The
// composed record is shared/diy.js diyRecordOf over chess.json + backups.json — the one the server and the sim use.
// `data` below = `{ chess, backups }` (the client's data.get('chess') / data.get('backups'), localized copies).
import { diyRecordOf } from '../../../../shared/diy.js';
import { isObj } from './shared.js';
/** The player's 自选 picks in this match (m.private.diy; {} when absent). */
export function diyPicks(priv) {
return isObj(priv) && isObj(priv.diy) ? priv.diy : {};
}
/**
* The pick the player's own piece / card of chess record `chess` fields: its slot's in m.private.diy, or null (not a DIY
* slot, or one the player did not fill). A composed record (`diyFor`) names its slot itself.
* @param {any} priv @param {any} chess
*/
export function ownDiyPick(priv, chess) {
if (!isObj(chess) || !chess.isDiy) return null;
const slot = typeof chess.diyFor === 'string' ? chess.diyFor : chess.baseId || chess.chessId;
const p = diyPicks(priv)[slot];
return isObj(p) && typeof p.charId === 'string' ? p : null;
}
/** Per backups object: `chessId|charId|skill|module` → composed record (or null). */
const CACHE = new WeakMap();
/**
* The 自选 record of DIY slot record `chess` (normal or elite) filled with `pick` (shared/diy.js diyRecordOf), cached
* per data; null for an illegal pick or missing data.
* @param {any} chess the slot's own chess.json record @param {any} pick `{ charId, skillIndex, uniEquipId }`
* @param {{ chess?: any, backups?: any }} data
*/
export function diyRecordFor(chess, pick, data) {
if (!isObj(chess) || !chess.isDiy) return null;
if (chess.diyFor) return chess; // already composed
if (!isObj(pick) || !isObj(data?.backups)) return null;
let m = CACHE.get(data.backups);
if (!m) { m = new Map(); CACHE.set(data.backups, m); }
const key = `${chess.chessId}|${pick.charId}|${pick.skillIndex ?? ''}|${pick.uniEquipId ?? ''}`;
if (m.has(key)) return m.get(key);
let rec;
try { rec = diyRecordOf(chess, pick, data); } catch { rec = null; }
m.set(key, rec);
return rec;
}
/** The record the player's own piece / card of `chess` shows: its 自选 record (own pick), or null. */
export function ownDiyRecord(chess, priv, data) {
const pick = ownDiyPick(priv, chess);
return pick ? diyRecordFor(chess, pick, data) : null;
}
/**
* The 自选 record a card of `chess` shows for this viewer, or null: a unit that carries `diy` (another player's — the
* sim's UnitInfo, prep scouting, a result lineup entry), else the player's own piece / card (m.private.diy).
* @param {any} chess @param {{ priv?: any, unit?: any, data?: any }} o
*/
export function cardDiy(chess, { priv = null, unit = null, data = null } = {}) {
if (!isObj(chess) || !chess.isDiy) return null;
if (unit) return isObj(unit.diy) ? diyRecordFor(chess, unit.diy, data) : null;
return ownDiyRecord(chess, priv, data);
}
/**
* id → the player's 自选 record of a filled DIY slot (both forms), else the chess record: the lookup the player's own
* pieces and cards resolve against (names, art, position, the loadout / range of a 自选 piece — shared/protocol.js
* resolveLoadout then reads the pick's skill and module as the record's defaults).
* @param {(id: string) => any} getChess @param {any} priv m.private (or a getter of the current one) @param {any} data
*/
export function diyGetter(getChess, priv, data) {
return (id) => {
const c = getChess(id);
return ownDiyRecord(c, typeof priv === 'function' ? priv() : priv, typeof data === 'function' ? data() : data) || c;
};
}
/** id → the 自选 record of a slot filled with `pick` (another player's unit), else the chess record. */
export function pickGetter(getChess, pick, data) {
return (id) => {
const c = getChess(id);
return (isObj(pick) && c && c.isDiy ? diyRecordFor(c, pick, data) : null) || c;
};
}
/** Whether a record is a composed 自选 record. */
export const isDiyRecord = (rec) => isObj(rec) && typeof rec.diyFor === 'string';
+67 -13
View File
@@ -1,23 +1,28 @@
// Operator loadout + ownership state and server sync (DESIGN §16; 干员持有 — 0.2.0 补位).
// Operator loadout + ownership + 自选编队 state and server sync (DESIGN §16; 干员持有 — 0.2.0 补位; 自选编队 — 0.2.0 DIY).
//
// `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
// per-browser not-owned list of the 干员持有 tab (`notOwned`, likewise), the 自选编队 picks (`diy`, likewise; with
// `diyKitted` = the operators the server lets a DIY slot field, from every `welcome`) and the overlay's screen state
// (open / origin / tab / selection / filters). `installLoadoutSync()` / `installOwnershipSync()` / `installDiySync()`
// (called once by main.js) keep the server's copies current — one sync engine (installPrefSync), three 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.
// the whole loadout), `room.ownership { notOwned }` (sent as stored: the server keeps the droppable chess and drops
// the rest, so no data is needed) and `room.diy { picks }` (sent as stored, structurally clean: the server keeps the
// legal picks). Replies: RATE → retried later; WRONG_PHASE / ROOM_STARTED → the running match keeps what it took (the
// loadout locks when INFO_CHECK ends; the ownership and the 自选 picks never change during a match) — stored for the
// next match, not an error for the player; anything else is logged. `sync` / `ownSync` / `diySync` ∈ '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 { DIY_PREF, parseStoredDiy, toStoredDiy, cleanPicks, sanitizeDiyPicks } from './diyModel.js';
import { toast } from './toasts.js';
import { t } from '../../../shared/i18n.js';
export const SYNC_DEBOUNCE_MS = 500;
export const RETRY_MS = 1500;
@@ -28,18 +33,24 @@ function readStored() {
function readStoredOwnership() {
try { return parseStoredOwnership(loadPref(OWNERSHIP_PREF, null)); } catch { return []; }
}
function readStoredDiy() {
try { return parseStoredDiy(loadPref(DIY_PREF, null)); } catch { return {}; }
}
/** 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)
diy: readStoredDiy(), // 自选编队: { [slotBaseId]: { charId, skillIndex?, uniEquipId? } } ({} = every slot empty)
diyKitted: null, // the operators a DIY slot may field (welcome.diyKitted; null before the first welcome)
open: false,
from: null, // 'lobby' | 'room' | 'briefing'
tab: 'loadout', // 'loadout' (干员调配) | 'ownership' (干员持有)
tab: 'loadout', // 'loadout' (干员调配) | 'ownership' (干员持有) | 'diy' (自选编队)
sel: null, // selected base chess id
filters: { tier: null, prof: null, bond: null, query: '', changedOnly: false },
sync: 'idle',
ownSync: 'idle',
diySync: 'idle',
});
/** Replace the stored entries (persisted at once; the sync picks the change up). */
@@ -86,9 +97,30 @@ export function applyOwnershipImport(list, lookup) {
return { applied: clean.length, dropped: Math.max(0, asked - clean.length) };
}
/** Replace the stored 自选编队 picks (persisted at once, the sync picks the change up). */
export function setDiyPicks(picks) {
const next = cleanPicks(picks);
try { savePref(DIY_PREF, toStoredDiy(next)); } catch { /* private mode: the session keeps it */ }
loadoutStore.set({ diy: next });
}
/**
* Apply an imported roster (parseDiyImport(...).picks), sanitised against the loaded data and the kit list (the picks
* the server would keep), persisted and synced like an edit. An empty roster is a valid import (= every slot empty).
* @param {Record<string, any>} picks @param {any} data `{ chess, backups }` @param {Iterable<string>|null} kitted
* @returns {{ applied: number, dropped: number }}
*/
export function applyDiyImport(picks, data, kitted) {
const asked = Object.keys(cleanPicks(picks)).length;
const clean = sanitizeDiyPicks(picks, data, kitted);
setDiyPicks(clean);
const applied = Object.keys(clean).length;
return { applied, dropped: Math.max(0, asked - applied) };
}
/**
* 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)
* @param {'loadout'|'ownership'|'diy'|null} [tab] the tab to show (default: the last one)
*/
export function openLoadout(from = 'lobby', sel = null, tab = null) {
data.load('chess');
@@ -96,7 +128,7 @@ export function openLoadout(from = 'lobby', sel = null, tab = null) {
data.load('assets');
data.load('local');
data.load('backups');
loadoutStore.set({ open: true, from, ...(sel ? { sel } : {}), ...(tab === 'loadout' || tab === 'ownership' ? { tab } : {}) });
loadoutStore.set({ open: true, from, ...(sel ? { sel } : {}), ...(tab === 'loadout' || tab === 'ownership' || tab === 'diy' ? { tab } : {}) });
}
export const closeLoadout = () => loadoutStore.set({ open: false });
@@ -107,7 +139,8 @@ export const closeLoadout = () => loadoutStore.set({ open: false });
*/
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 tell = notify || ((text) => toast(text, 'warn'));
// (`lockedText` is a msgid: the toast is translated when it shows — docs/I18N.md)
const tell = notify || ((text) => toast(t(text), 'warn'));
let timer = null;
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
@@ -228,3 +261,24 @@ export function installOwnershipSync({ net, timers, target = loadoutStore, notif
prepare: async () => cleanIds(target.get().notOwned),
});
}
/**
* Wire the 自选编队 sync once (0.2.0 DIY): `room.diy { picks }` after every welcome and edit. The picks go as stored
* (structurally clean); the server keeps the legal ones. During a match the server stores them for the next one
* (ROOM_STARTED → 'locked'): the setting is out of match. Every `welcome` also brings the operators a DIY slot may field
* (`diyKitted`, the picker's list).
* @param {{ net: any, timers?: { setTimeout: Function, clearTimeout: Function }, target?: ReturnType<typeof createStore>,
* notify?: (text: string) => void }} deps
*/
export function installDiySync({ net, timers, target = loadoutStore, notify } = {}) {
const offKit = net.on('welcome', (msg) => {
const list = msg && Array.isArray(msg.diyKitted) ? msg.diyKitted.filter((x) => typeof x === 'string') : null;
target.set({ diyKitted: list });
});
const sync = installPrefSync({
net, timers, target, notify, key: 'diy', stateKey: 'diySync', msgType: 'room.diy', field: 'picks', tag: 'diy',
lockedText: '自选编队是局外设置,修改将在下一局生效',
prepare: async () => cleanPicks(target.get().diy),
});
return { flush: sync.flush, dispose() { offKit?.(); sync.dispose(); } };
}
+12 -4
View File
@@ -19,14 +19,17 @@
// §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). 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).
// and the skill icon is the stand-in's (its backup skill: the one that fights). A 自选 piece (0.2.0, m.private.diy — a DIY
// slot the player filled, only ever in its own shop) shows the operator — name, portrait, class, the bonds of its
// factions, the pick's skill and module — with a mint 「自选」 badge.
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, fieldsStandIn, standInOf, standInLoadout, standInLabel } from './gameLogic.js';
import { priceTone, mergeProgress, mergeTarget, shopBlockReason, chessLoadout, offerHeader, briefingBondTip, fieldsStandIn, standInOf, standInLoadout, standInLabel, ownDiyRecord, diyGetter } from './gameLogic.js';
import { chessPortraitUrl, itemIconUrl, profIconUrl, uiUrl, skillIconUrl, skillRecordIconUrl, moduleTypeIconUrl } from './assetUrls.js';
import { data } from '../data.js';
import { t } from '../../../shared/i18n.js';
const cx = (...p) => p.flat().filter(Boolean).join(' ');
@@ -64,7 +67,11 @@ function ArmedTag({ reason, free }) {
* onTap?:(idx:number)=>void, onBuy:Function, onDetail:Function }} props
*/
export function ChessCard({ slot, idx, priv, frozen = false, reason = null, free = false, armed = false, onTap = null, onBuy, onDetail, offBonds = null }) {
const c = data.lookup('chess', slot.id);
const c0 = data.lookup('chess', slot.id);
// 0.2.0 自选编队: a DIY slot the player filled is its operator (shared/diy.js, the pick of m.private.diy)
const diyData = { chess: data.get('chess'), backups: data.get('backups') };
const dr = c0 ? ownDiyRecord(c0, priv, diyData) : null;
const c = dr || c0;
const m = data.get('assets');
const tier = c?.tier ?? 1;
const prog = mergeProgress(priv, slot.id, (id) => data.lookup('chess', id));
@@ -74,7 +81,7 @@ export function ChessCard({ slot, idx, priv, frozen = false, reason = null, free
const disabled = !!reason;
// 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 lo = si ? standInLoadout(si, LOOKUPS.getChess, data.get('backups')) : c ? chessLoadout(c, priv?.loadout, dr ? diyGetter(LOOKUPS.getChess, priv, diyData) : 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); }}
@@ -90,6 +97,7 @@ export function ChessCard({ slot, idx, priv, frozen = false, reason = null, free
</span>` : null}
</span>
${si ? html`<span class="scard__standin" data-standin=${si.charId} title=${`未持有:由替补干员 ${si.name} 上场(盟约、阶级与价格不变)`}>${standInLabel(si)}</span>` : null}
${dr ? html`<span class="scard__diy" data-diy=${dr.charId} title=${t('自选编队:{name}(只在你的商店出现)', { name: dr.name })}>${t('自选')}</span>` : null}
<span class="scard__body">
${lo?.skill ? html`<${SkillBadge} chess=${si || c} lo=${lo} standIn=${!!si} />` : null}
<span class="scard__name">${c?.name || slot.id}</span>
+6 -1
View File
@@ -162,7 +162,12 @@ export function ownerBoard(field, ownerId, extra = null) {
if (!fromField && !fromBattle) return null;
const board = [...(fromField || []), ...(fromBattle || [])]
.filter((u) => isObj(u) && u.ownerId === ownerId && u.kind === 'op' && typeof u.defId === 'string')
.map((u) => (Array.isArray(u.items) && u.items.length ? { kind: 'chess', id: u.defId, items: u.items.filter((x) => typeof x === 'string') } : { kind: 'chess', id: u.defId }));
.map((u) => {
const p = Array.isArray(u.items) && u.items.length ? { kind: 'chess', id: u.defId, items: u.items.filter((x) => typeof x === 'string') } : { kind: 'chess', id: u.defId };
// 0.2.0 自选编队: a DIY slot's unit names its operator (UnitInfo diy) — the popup counts it for that operator's bonds
if (isObj(u.diy) && typeof u.diy.charId === 'string') p.diy = u.diy;
return p;
});
return { board, hand: [], temp: [] };
}
+50 -4
View File
@@ -71,6 +71,16 @@
// 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.
// * 自选编队 (0.2.0 DIY, the owner's decisions of 2026-10-05): room.diy { picks } — the player's picks for the four DIY
// slots ({ [slotBaseId]: { charId, skillIndex?, uniEquipId? } | null }) — is checked leniently (shared/protocol.js
// checkDiyPicks against the game data and the kit registry, server/sim/content/kits/index.js KITTED_CHARS: an
// illegal pick — an operator without a kit, another tier's prototype, a prototype off its locked skill, a second slot
// of one owned operator, the same operator twice in a tier, an unknown slot / skill / module — is dropped, never the
// whole roster; only malformed picks are BAD_MSG) and stored on the session and the seat exactly like the
// not-owned list: the match receives seats[].diy when it starts (bots: none — they field no 自选 piece [ASSUMED]),
// and a change while it runs is stored for the next match (ROOM_STARTED 'stored for the next match'). Every
// `welcome` carries `diyKitted` (welcomeInfo): the operators a DIY slot may field, so the client's picker offers
// exactly what the server accepts.
// * 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
@@ -78,18 +88,19 @@
// {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 / room.ownership (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 /
// room.diy (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, checkNotOwned } from '../shared/protocol.js';
import { checkLoadout, checkNotOwned, checkDiyPicks } 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';
import { KITTED_CHARS } from './sim/content/kits/index.js';
/** Room code alphabet: uppercase letters without I and O (and no digits, so no 0/1). */
export const CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ';
@@ -117,7 +128,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,
* notOwned?: readonly string[] | null }} Seat
* notOwned?: readonly string[] | null, diy?: Readonly<Record<string, DiyLoadout>> | null }} Seat
* @typedef {{ charId: string, skillIndex: number, uniEquipId: string|null }} DiyLoadout
*/
/** Deep-frozen copy of a checked loadout (shared by the session, the seat and the match's PlayerState). */
@@ -127,6 +139,13 @@ function freezeLoadout(loadout) {
return Object.freeze(out);
}
/** Deep-frozen copy of checked 自选 picks (shared by the session, the seat and the match's PlayerState). */
function freezeDiy(picks) {
const out = {};
for (const [id, p] of Object.entries(picks || {})) out[id] = Object.freeze({ charId: p.charId, skillIndex: p.skillIndex, uniEquipId: p.uniEquipId ?? null });
return Object.freeze(out);
}
/** One room: 4 seat slots, host, difficulty, optional running match. */
export class Room {
/** @param {string} code @param {'solo'|'coop'} mode @param {string} difficulty @param {number} now */
@@ -298,6 +317,7 @@ export class Lobby {
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.diy': return this.diy(session, msg);
case 'room.spectate': return this.spectate(session, msg);
case 'room.removeSpectator': return this.removeSpectator(session, msg);
default:
@@ -602,6 +622,29 @@ export class Lobby {
return OK;
}
/**
* room.diy (0.2.0 自选编队): keep the legal picks (checkDiyPicks against the data and KITTED_CHARS), store them on the
* session and the seat (see the header). A running match never takes them: it keeps the picks its seat had at its
* start.
*/
diy(session, { picks }) {
const res = checkDiyPicks(picks, { data: this.safeData(), kitted: KITTED_CHARS });
if (!res || !('ok' in res)) return fail(ERR.BAD_MSG, res && res.detail);
const kept = freezeDiy(res.picks);
session.diy = kept;
const room = this.roomOf(session);
if (!room) return OK;
const seat = room.seatOf(session.playerId);
if (seat) seat.diy = kept;
if (room.match && seat) return fail(ERR.ROOM_STARTED, 'stored for the next match');
return OK;
}
/** Extra fields of every `welcome` (net.js): the operators a 自选 slot may field (shared/diy.js `kitted`). */
welcomeInfo() {
return { diyKitted: KITTED_CHARS };
}
// ---------------------------------------------------------------------------------------------------
// Match wiring
// ---------------------------------------------------------------------------------------------------
@@ -616,6 +659,8 @@ export class Lobby {
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,
// 0.2.0 自选编队: the human's checked DIY picks (bots field no 自选 piece [ASSUMED])
diy: s.isBot ? null : s.diy || 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() };
@@ -875,6 +920,7 @@ export class Lobby {
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,
diy: session.diy || null,
};
}
+8 -1
View File
@@ -14,13 +14,16 @@
// 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,
// notOwned?: string[] | null }>
// notOwned?: string[] | null,
// diy?: { [slotBaseId]: { charId, skillIndex, uniEquipId } } | 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).
// `diy` (0.2.0 自选编队, optional): the human's 自选 picks (shared/protocol.js checkDiyPicks) — fixed for
// the match; the slotted operators join the human's own shop (PlayerState setDiy / initDiyStock).
// 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
@@ -316,6 +319,10 @@ export class Match {
this.staticInactiveBonds = bans.staticOff;
this.bannedChess = bans.banned;
this.pool = new SharedPool(this.gd, { banned: bans.banned });
// 自选编队 (0.2.0): each human's slotted DIY pieces get their own stock — none for one whose bonds are all off this
// match (player/diy.js initDiyStock); no randomness is drawn here
const off = new Set([...bans.drawn, ...bans.staticOff]);
for (const ps of this.order) ps.initDiyStock(off);
this.phase = PHASE.LOBBY;
this.round = 0;
+25 -2
View File
@@ -81,6 +81,14 @@
// 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`.
// * 自选编队 (0.2.0 DIY, the owner's decisions of 2026-10-05; player/diy.js): the human's `seat.diy` picks are re-checked
// against this match's data and kits and fixed for the match (`diy`; bots none [ASSUMED]). With picks, `this.gd` is the
// player's data view: the slotted slots' ids (normal and elite) are the composed 自选 records, so the player's piece
// of a slot is the operator for every rule (name, class, position, bonds from its factions, no 特质, the pick's skill
// and module; tier, price, sell price and the 3 → elite merge are the slot's). Each slotted piece has its own stock
// (8 at tier 5, 5 at tier 6 [ASSUMED]; none when every bond of it is banned this match) and joins this player's shop
// rolls once the 调度中心 reaches the slot's level; it never enters the shared pool. battleInput carries `diy` (the
// pick), m.private exposes `diy` (and `diyBanned`), the scouting views draw the operator with its pick.
//
// 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,
@@ -99,6 +107,8 @@
// round.js the round lifecycle the match calls (startRound, endPrep, eliminate, recompute, bond views) and
// battleInput
// views.js pieceView, effectsView, m.private (privateView)
// diy.js 自选编队: the picks (setDiy), the player's data view, the DIY stock (initDiyStock, poolOf), the shop's
// DIY draws (diyRollEntries), the pick of a piece (diyPickOf)
// common.js HAND_SIZE, TEMP_SIZE, MAX_OFFER_SLOTS, OK, fail
import { FIELD } from './board.js';
@@ -113,11 +123,12 @@ import { PlayerItems } from './player/items.js';
import { PlayerPrep } from './player/prep.js';
import { PlayerRound } from './player/round.js';
import { PlayerViews } from './player/views.js';
import { PlayerDiy, DiyStock } from './player/diy.js';
export class PlayerState {
/**
* @param {import('./Match.js').Match} m owning match
* @param {{ seat: number, playerId: string, name: string, isBot: boolean, connected: boolean }} seat
* @param {{ seat: number, playerId: string, name: string, isBot: boolean, connected: boolean, loadout?: any, notOwned?: any, diy?: any }} seat
*/
constructor(m, seat) {
this.m = m;
@@ -148,6 +159,18 @@ export class PlayerState {
/** @type {Set<string>} lookup set of `standIns` */
this._standInSet = new Set();
if (!this.isBot && seat.notOwned) this.setNotOwned(seat.notOwned);
/**
* 自选编队 (0.2.0): the slotted picks { [slotBaseId]: { charId, skillIndex, uniEquipId } } — the seat's when the match
* started, re-checked; frozen; {} = none (bots always). setDiy makes `this.gd` the player's data view.
*/
this.diy = Object.freeze({});
/** @type {Map<string, object>} slot id (normal / elite) → composed 自选 record (setDiy) */
this._diyRecords = new Map();
/** the copies of the slotted pieces (initDiyStock, once the match's bans are drawn) */
this.diyStock = new DiyStock();
/** slotted slots without stock: every bond of the operator is switched off this match */
this.diyBanned = Object.freeze([]);
if (!this.isBot && seat.diy) this.setDiy(seat.diy);
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 = [];
@@ -204,7 +227,7 @@ export class PlayerState {
}
// the method modules, in this order (a name defined twice is an error, never a silent override)
for (const part of [PlayerBasics, PlayerPieces, PlayerAcquire, PlayerEconomy, PlayerPlacement, PlayerItems, PlayerPrep, PlayerRound, PlayerViews]) {
for (const part of [PlayerBasics, PlayerPieces, PlayerAcquire, PlayerEconomy, PlayerPlacement, PlayerItems, PlayerPrep, PlayerRound, PlayerViews, PlayerDiy]) {
for (const key of Reflect.ownKeys(part.prototype)) {
if (key === 'constructor') continue;
if (Object.prototype.hasOwnProperty.call(PlayerState.prototype, key)) throw new Error(`PlayerState.${String(key)} is defined twice`);
+5 -2
View File
@@ -127,7 +127,10 @@ export function attachAudit(m, { invariants = true, limit = 200 } = {}) {
check('shop roll', () => {
const base = gd.baseIdOf(s.id);
if (t > ps.shop.level) fail(`${ps.playerId}: rolled tier ${t} at shop level ${ps.shop.level}`);
if (!m.pool.has(base)) fail(`${ps.playerId}: rolled ${s.id} outside the match pool (banned/hidden)`);
// (a slotted 自选 piece comes from the player's own stock, 0.2.0 player/diy.js — once the 调度中心 is at its level)
const diy = ps.diyStock && ps.diyStock.has(base) ? ps.diyStock.entries.get(base) : null;
if (!m.pool.has(base) && !diy) fail(`${ps.playerId}: rolled ${s.id} outside the match pool (banned/hidden)`);
if (diy && ps.shop.level < diy.shopLevel) fail(`${ps.playerId}: rolled 自选 ${s.id} at shop level ${ps.shop.level} < ${diy.shopLevel}`);
if (s.basePrice !== gd.chessPrice(s.id)) fail(`${ps.playerId}: ${s.id} basePrice ${s.basePrice} != ${gd.chessPrice(s.id)}`);
});
}
@@ -243,7 +246,7 @@ export function attachAudit(m, { invariants = true, limit = 200 } = {}) {
if (ps.deployCount > deployed0) fail(`${id}: a merge of ${baseId} grew the deploy count ${deployed0} → ${ps.deployCount}`);
// a pure read of the deploy field (Match.deployMapFor, as invariants.js): the audit must not refresh the cache
const dmap = typeof m.deployMapFor === 'function' ? m.deployMapFor(ps) : ps.deployMap();
const pos = placeClass(ps, gd.chess(elite.id));
const pos = placeClass(ps, (ps.gd || gd).chess(elite.id));
const want = mergeTile([...tiles.keys()].map((key) => ({ key })), (r, c) => canPlace(dmap, pos, r, c));
if (want) {
if (loc.area !== 'board' || loc.key !== want.key) fail(`${id}: the elite of ${baseId} went to ${loc.area} ${loc.key || ''}, expected the deployed copy's tile ${want.key}`);
+37 -34
View File
@@ -133,7 +133,7 @@ const DEFAULT_MELEE_RANGE = [[0, 0], [0, 1]];
* without inactive bonds keep exactly the earlier picks.
*/
export function botPickBand(m, ps) {
const gd = m.gd;
const gd = ps?.gd || m.gd;
const ids = gd.bandIds();
if (!ids.length) return gd.defaultBandId;
const lateFunds = (id) => /暂存/.test(String(gd.band(id)?.desc || ''));
@@ -185,7 +185,7 @@ export function botPickCard(m, ps, cards, available) {
* first blocker's hold on the ground. 0.5 without data.
*/
export function bountyKillChance(m, ps, card) {
const gd = m.gd;
const gd = ps?.gd || m.gd;
const e = card && gd.enemy(card.enemyKey);
if (!e || !e.stats) return 0.5;
const model = fieldModel(m, ps);
@@ -274,7 +274,8 @@ function tacticScore(m, ps, card) {
// ---------------------------------------------------------------------------------------------------
// evaluation helpers
const chessRec = (m, id) => m.gd.chess(id);
// a player's record of a chess id (its data view: a human's slotted 自选 slots are its operators, 0.2.0 player/diy.js)
const chessRec = (m, id, ps = null) => (ps?.gd || m.gd).chess(id);
const isHealer = (c) => !!c && (c.dmgType === 'heal' || c.attackKind === 'heal');
// the record's own position: a 钩索师 / 推击手 is a MELEE blocker like any other (a 高台 does not block)
const isBlocker = (c) => !!c && basePositionClass(c) === 'melee' && (c.stats?.blockCnt ?? 1) > 0 && c.attackKind !== 'none';
@@ -291,7 +292,7 @@ function ownedBonds(m, ps, exclude = null) {
const base = m.gd.baseIdOf(p.id);
if (seen.has(base)) continue;
seen.add(base);
const c = chessRec(m, p.id);
const c = chessRec(m, p.id, ps);
for (const b of (c && c.bonds) || []) counts.set(b, (counts.get(b) || 0) + 1);
}
return { counts, bases: seen };
@@ -393,7 +394,7 @@ function keeperBases(m, ps, plan) {
const out = new Set();
for (const p of ps.board.values()) if (p.kind === 'chess') out.add(m.gd.baseIdOf(p.id));
for (const p of ps.allChess()) {
const c = chessRec(m, p.id);
const c = chessRec(m, p.id, ps);
if (!c) continue;
const bonds = c.bonds || [];
if ((plan.focus && bonds.includes(plan.focus)) || (plan.second && bonds.includes(plan.second)) || c.tier >= ps.shop.level - 1) out.add(m.gd.baseIdOf(p.id));
@@ -405,7 +406,7 @@ function keeperBases(m, ps, plan) {
function roles(m, ps) {
const r = { blockers: 0, antiAir: 0, healers: 0, total: 0 };
for (const p of ps.allChess()) {
const c = chessRec(m, p.id);
const c = chessRec(m, p.id, ps);
if (!c) continue;
r.total++;
if (isBlocker(c)) r.blockers++;
@@ -473,7 +474,7 @@ function traitsOf(m, c) {
/** Stand-alone value of an owned piece (no bond context): power, items, role fit, layer engines. */
function unitBase(m, piece, ctx) {
const c = chessRec(m, piece.id);
const c = chessRec(m, piece.id, ctx.ps);
if (!c) return 0;
let v = power(c) + (piece.items ? piece.items.length * 5 : 0);
if (m.round <= 11) v += traitsOf(m, c).recurring * 4;
@@ -485,7 +486,7 @@ function unitBase(m, piece, ctx) {
/** Value of an owned piece for bench / sell decisions (its bonds counted against the other owned chess). */
function pieceValue(m, ps, piece, ctx) {
const c = chessRec(m, piece.id);
const c = chessRec(m, piece.id, ps);
if (!c) return 0;
return unitBase(m, piece, ctx) + bondValue(m, c, ownedBonds(m, ps, piece.uid), ctx.focus, ctx.second) * 0.8;
}
@@ -495,7 +496,7 @@ function pieceValue(m, ps, piece, ctx) {
* members still count for BOARD_AND_DECK bonds) + composition (blockers, anti-air when the wave flies, ≤ 2 healers).
*/
function lineupScore(m, ps, set, ctx) {
const gd = m.gd;
const gd = ps?.gd || m.gd;
const board = new Map();
set.forEach((p, i) => board.set(`x${i}`, p));
const inSet = new Set(set.map((p) => p.uid));
@@ -518,7 +519,7 @@ function lineupScore(m, ps, set, ctx) {
let blockers = 0;
let air = 0;
let healers = 0;
for (const p of set) { const c = chessRec(m, p.id); if (isBlocker(c)) blockers++; if (hitsFly(c)) air++; if (isHealer(c)) healers++; }
for (const p of set) { const c = chessRec(m, p.id, ps); if (isBlocker(c)) blockers++; if (hitsFly(c)) air++; if (isHealer(c)) healers++; }
v -= Math.max(0, Math.min(2, ctx.roles.blockers) - blockers) * 15;
if (ctx.fly > 0 && ctx.roles.antiAir > 0 && air === 0) v -= 12;
v -= Math.max(0, healers - 2) * 10;
@@ -562,8 +563,8 @@ function chooseLineup(m, ps, ctx) {
/** Shop / reward score of acquiring one copy of chess `id` (0 = not worth it). */
function buyScore(m, ps, id, ctx) {
const gd = m.gd;
const c = chessRec(m, id);
const gd = ps?.gd || m.gd;
const c = chessRec(m, id, ps);
if (!c) return 0;
let s = power(c) * 0.6;
const tr = traitsOf(m, c);
@@ -639,7 +640,7 @@ export function fieldModel(m, ps = null) {
const wave = m.wave || (boss && boss.wave);
const cacheKey = `${m.round}|${m.stageId}|${wave ? wave.templateId : 'boss'}|${boss ? boss.side : ''}`;
if (m._botPath && m._botPath.key === cacheKey) return m._botPath;
const gd = m.gd;
const gd = ps?.gd || m.gd;
const st = m.stage;
const gpaths = (st && (st.groundPathsWithDevices || st.groundPaths)) || {};
const routesOut = [];
@@ -835,7 +836,8 @@ export function rangeRec(ps, rec) {
export function effDps(model, rec) {
if (!rec) return 0;
if (!model || !Array.isArray(model.armor) || !model.armor.length) return dpsOf(rec);
const id = rec.chessId || rec.tokenId || rec.id || rec.name;
// (a 自选 record — one DIY slot id, each player's own operator — is cached per operator and skill)
const id = rec.diyFor ? `${rec.chessId}@${rec.charId}#${rec.skill?.index ?? ''}` : rec.chessId || rec.tokenId || rec.id || rec.name;
const cache = model.dps instanceof Map ? model.dps : null;
if (cache && cache.has(id)) return cache.get(id);
let v = 0;
@@ -938,7 +940,8 @@ export function planLayout(m, ps, pieces, params = LAYOUT_PARAMS, opts = {}) {
export function* planLayoutSteps(m, ps, pieces, params = LAYOUT_PARAMS, { occupied = new Set(), recOf = null } = {}) {
const model = fieldModel(m, ps);
const map = ps.deployMap();
const rec = recOf || ((p) => (p.kind === 'token' ? m.gd.token(p.id) : rangeRec(ps, m.gd.chess(p.id))));
const pgd = ps.gd || m.gd;
const rec = recOf || ((p) => (p.kind === 'token' ? pgd.token(p.id) : rangeRec(ps, pgd.chess(p.id))));
const layout = new Layout(model, params);
const rank = (p) => { const r = rec(p); return isBlocker(r) ? 0 : isHealer(r) ? 2 : 1; };
const order = pieces.slice().sort((a, b) => rank(a) - rank(b) || dpsOf(rec(b)) - dpsOf(rec(a)) || a.uid - b.uid);
@@ -953,7 +956,7 @@ export function* planLayoutSteps(m, ps, pieces, params = LAYOUT_PARAMS, { occupi
const within = p.kind === 'token' && typeof ps.summonRange === 'function' ? ps.summonRange(p) : null;
// ground tiles for a MELEE blocker. placeClass 'all' is only elite 歌蕾蒂娅 + HOK-Y: she may stand on a 高台,
// and when one of those tiles covers the enemy road she is planned there (owner 2026-10-04: the bot uses the 高台).
const cls = p.kind === 'token' ? basePositionClass(r0) : placeClass(ps, m.gd.chess(p.id) || r0);
const cls = p.kind === 'token' ? basePositionClass(r0) : placeClass(ps, pgd.chess(p.id) || r0);
const preferHigh = cls === 'all' && basePositionClass(r0) === 'melee';
let bestHigh = null;
let bestHighV = -Infinity;
@@ -1161,7 +1164,7 @@ function context(m, ps) {
const copies = copyCounts(m, ps);
let pairs = 0;
for (const [b, k] of copies) if (k + 1 >= mergeNeed(m, b)) pairs++;
return { owned, focus: plan.focus, second: plan.second, keep: keeperBases(m, ps, plan), copies, pairs, roles: roles(m, ps), fly: model.flyTotal, model };
return { ps, owned, focus: plan.focus, second: plan.second, keep: keeperBases(m, ps, plan), copies, pairs, roles: roles(m, ps), fly: model.flyTotal, model };
}
/** Normal copies owned per base (board, hand, temp). */
@@ -1254,7 +1257,7 @@ function sellJunk(m, ps) {
const keep = [];
const junk = [];
for (const p of bench) {
const c = chessRec(m, p.id);
const c = chessRec(m, p.id, ps);
if (!c) continue;
const base = m.gd.baseIdOf(p.id);
const copies = c.isGolden ? 0 : ctx.copies.get(base) || 0;
@@ -1271,7 +1274,7 @@ function sellJunk(m, ps) {
/** Take the queued pick-one offers (merge rewards, special refreshes — free; they expire at prep end). */
function takeOffers(m, ps) {
const gd = m.gd;
const gd = ps?.gd || m.gd;
for (let guard = 0; guard < 4 && ps.offers.length; guard++) {
const offer = ps.offers[0];
const ctx = context(m, ps);
@@ -1378,7 +1381,7 @@ function maybeFreeze(m, ps) {
}
function levelUp(m, ps, { spare = false } = {}) {
const gd = m.gd;
const gd = ps?.gd || m.gd;
for (let guard = 0; guard < 6; guard++) {
if (ps.shop.level >= gd.maxShopLevel) return;
const price = Math.max(0, ps.shop.upgradePrice);
@@ -1413,7 +1416,7 @@ function fundsReserve(m, ps) {
/** Buying / rerolling toward the lineup; a step generator (yields after each purchase or reroll). */
function* buyLoopSteps(m, ps, { fillOnly = false, maxRefreshes = 0 } = {}) {
const gd = m.gd;
const gd = ps?.gd || m.gd;
let refreshes = 0;
const minPrice = 2;
const reserve = fundsReserve(m, ps);
@@ -1464,7 +1467,7 @@ function* buyLoopSteps(m, ps, { fillOnly = false, maxRefreshes = 0 } = {}) {
const gain = lineupGain(m, ps, s.id, ctx, cur);
if (gain <= 2) return;
sc += Math.min(15, gain * 0.5);
} else if (gd.isGolden(s.id) || (!ctx.keep.has(base) && !(ctx.pairs < MAX_PAIRS && (chessRec(m, s.id)?.tier ?? 6) <= 3 && countFree(ps.hand) >= 3))) {
} else if (gd.isGolden(s.id) || (!ctx.keep.has(base) && !(ctx.pairs < MAX_PAIRS && (chessRec(m, s.id, ps)?.tier ?? 6) <= 3 && countFree(ps.hand) >= 3))) {
// a second copy of a non-keeper with no bench room for another pair is clutter, sold later at a loss
return;
}
@@ -1607,7 +1610,7 @@ const SUPPORT_SPOTS = Object.freeze([[0, -1, 'RIGHT'], [1, 0, 'DOWN'], [-1, 0, '
* first — they take the hits —, then DPS), pointing at it; null when none is free.
*/
function supportSpot(m, ps, p) {
const rec = m.gd.token(p.id);
const rec = (ps.gd || m.gd).token(p.id);
if (!rec) return null;
const map = ps.deployMap();
const pos = positionClass(rec);
@@ -1618,7 +1621,7 @@ function supportSpot(m, ps, p) {
const [dr, dc] = rotateOffset(0, 1, pieceDir(q));
faced.add(tileKey(r + dr, c + dc));
}
const ops = [...ps.board.entries()].filter(([k, q]) => q.kind === 'chess' && !faced.has(k)).map(([k, q]) => [k, m.gd.chess(q.id)]).filter(([, c]) => c)
const ops = [...ps.board.entries()].filter(([k, q]) => q.kind === 'chess' && !faced.has(k)).map(([k, q]) => [k, (ps.gd || m.gd).chess(q.id)]).filter(([, c]) => c)
.sort((a, b) => Number(isBlocker(b[1])) - Number(isBlocker(a[1])) || dpsOf(b[1]) - dpsOf(a[1]) || (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
for (const [k] of ops) {
const [r, c] = parseKey(k);
@@ -1676,7 +1679,7 @@ const isMutationCell = (gd, itemId) => itemEffect(gd.item(itemId)) === 'char_che
* progress would be lost), nobody already carrying a cell. null: the cell waits in the hand.
*/
export function cellTarget(m, ps, ctx = context(m, ps)) {
const gd = m.gd;
const gd = ps?.gd || m.gd;
let best = null;
let bestV = Infinity;
for (const p of ps.allChess()) {
@@ -1707,7 +1710,7 @@ function lastPerfect(m, ps) {
* other equipment: the strongest deployed damage dealers with a free slot (SURVIVAL items blockers first).
*/
export function itemTarget(m, ps, item, ctx = context(m, ps)) {
const gd = m.gd;
const gd = ps?.gd || m.gd;
const rec = gd.item(item.id);
if (!rec) return null;
const key = itemEffect(rec);
@@ -1717,19 +1720,19 @@ export function itemTarget(m, ps, item, ctx = context(m, ps)) {
const owned = ps.allChess();
const normal = (p) => !gd.isGolden(p.id);
const byVal = (list, dir = -1) => list.slice().sort((a, b) => dir * (val(a) - val(b)) || a.uid - b.uid);
const rel = (p) => { let r = 0; for (const b of chessRec(m, p.id)?.bonds || []) r += b === ctx.focus ? 3 : b === ctx.second ? 2 : ps.bonds[b] && ps.bonds[b].active ? 1 : 0; return r; };
const rel = (p) => { let r = 0; for (const b of chessRec(m, p.id, ps)?.bonds || []) r += b === ctx.focus ? 3 : b === ctx.second ? 2 : ps.bonds[b] && ps.bonds[b].active ? 1 : 0; return r; };
switch (key) {
case 'use_equip_recruit_new_char_and_give_char_to_player_most_bond': {
// a bench single of the highest tier (a pick of two of its tier), else the weakest deployed normal operator
const counts = copyCounts(m, ps);
const bench = owned.filter((p) => normal(p) && ps.find(p.uid)?.area !== 'board' && (counts.get(gd.baseIdOf(p.id)) || 0) < 2)
.sort((a, b) => (chessRec(m, b.id)?.tier || 0) - (chessRec(m, a.id)?.tier || 0) || val(a) - val(b) || a.uid - b.uid);
.sort((a, b) => (chessRec(m, b.id, ps)?.tier || 0) - (chessRec(m, a.id, ps)?.tier || 0) || val(a) - val(b) || a.uid - b.uid);
return bench[0] || byVal(deployed.filter(normal), 1)[0] || null;
}
case 'use_equip_reward_char_chess': {
const counts = copyCounts(m, ps);
const pair = owned.filter((p) => normal(p) && (counts.get(gd.baseIdOf(p.id)) || 0) + 1 >= mergeNeed(m, gd.baseIdOf(p.id)))
.sort((a, b) => (chessRec(m, b.id)?.tier || 0) - (chessRec(m, a.id)?.tier || 0) || a.uid - b.uid);
.sort((a, b) => (chessRec(m, b.id, ps)?.tier || 0) - (chessRec(m, a.id, ps)?.tier || 0) || a.uid - b.uid);
return pair[0] || owned.slice().sort((a, b) => rel(b) - rel(a) || val(b) - val(a) || a.uid - b.uid)[0] || null;
}
// 博士投影: the normal one promotes at the next round start, the golden one (缪尔赛思's R1 item, item merges) at once;
@@ -1751,12 +1754,12 @@ export function itemTarget(m, ps, item, ctx = context(m, ps)) {
if (consume) return list[0] || owned[0] || null;
const free = (p) => (p.items || []).length < gd.equipPerChess;
if (rec.requiresBondId) {
const member = list.find((p) => free(p) && (chessRec(m, p.id)?.bonds || []).includes(rec.requiresBondId));
const member = list.find((p) => free(p) && (chessRec(m, p.id, ps)?.bonds || []).includes(rec.requiresBondId));
if (member) return member;
}
// damage dealers carry equipment first (healers / non-attackers last); survival items on blockers first
const dealer = (p) => { const c = chessRec(m, p.id); return c && !isHealer(c) && c.attackKind !== 'none'; };
const blocker = (p) => isBlocker(chessRec(m, p.id));
const dealer = (p) => { const c = chessRec(m, p.id, ps); return c && !isHealer(c) && c.attackKind !== 'none'; };
const blocker = (p) => isBlocker(chessRec(m, p.id, ps));
const order = rec.category === 'SURVIVAL'
? [...list.filter(blocker), ...list.filter((p) => !blocker(p) && dealer(p)), ...list.filter((p) => !blocker(p) && !dealer(p))]
: [...list.filter(dealer), ...list.filter((p) => !dealer(p))];
@@ -1794,7 +1797,7 @@ function useArt(m, ps, item, ctx) {
}
function equipItems(m, ps) {
const gd = m.gd;
const gd = ps?.gd || m.gd;
const tried = new Set();
for (let guard = 0; guard < 12; guard++) {
const item = [...ps.hand, ...ps.temp].find((p) => p && p.kind === 'item' && canUseItem(m, ps, p) && !tried.has(p.uid));
@@ -1820,7 +1823,7 @@ function makeHandRoom(m, ps, piece = null) {
const room = () => freeSlot(ps.hand) >= 0 || (piece != null && !ps.temp.includes(piece));
if (room()) return true;
if (sellWeakestHand(m, ps, { handOnly: true }) && room()) return true;
const gd = m.gd;
const gd = ps?.gd || m.gd;
const junk = ps.isBot && !ps.autoplay ? ps.hand.filter((p) => p && p.kind === 'item' && !isMutationCell(gd, p.id))
.sort((a, b) => ((gd.item(a.id) || {}).price || 0) - ((gd.item(b.id) || {}).price || 0) || a.uid - b.uid)[0] : null;
if (junk && tryDo(() => ps.destroy(junk.uid)) && room()) return true;
+4 -2
View File
@@ -172,8 +172,10 @@ const ITEM_HANDLERS = {
if (id) ids.push(id);
}
if (ids.length) ctx.offerChess(ids, { source: 'item', tier });
// co-op: next prep, send the original chess to the teammate with the most members of its bonds
const to = mostBondMate(ctx, bonds);
// co-op: next prep, send the original chess to the teammate with the most members of its bonds — except a 自选
// piece (0.2.0): its DIY slot is bound to this player's roster (no teammate's shop or slot can hold that operator),
// so nothing is sent [ASSUMED: the official text names no 自选 case]
const to = ctx.chessRecord(original)?.diyFor ? null : mostBondMate(ctx, bonds);
if (to) ctx.addEffect({ id: `gift:${ev.item.uid}`, key: 'effect:builtin_gift', hidden: true, battle: false, params: { toPlayerId: to.playerId, chessId: original, bonds } });
},
},
+9 -5
View File
@@ -280,7 +280,7 @@ export class EffectDispatcher {
}
_garrisons(ps, hook, ev) {
const gd = this.m.gd;
const gd = ps.gd || this.m.gd;
const run = (piece, where) => {
const rec = gd.chess(piece.id);
if (!rec || !Array.isArray(rec.garrisonIds)) return;
@@ -314,7 +314,7 @@ export class EffectDispatcher {
* SERVER_GAIN. SERVER_PRICE cannot be triggered. Returns the number of garrison handlers run.
*/
triggerGarrisons(ps, piece, eventType, { asPiece = null, triggeredBy = null } = {}) {
const gd = this.m.gd;
const gd = ps.gd || this.m.gd;
const hook = eventType === 'SERVER_PRICE' ? null : GARRISON_HOOK[eventType];
const rec = piece && piece.kind === 'chess' ? gd.chess(piece.id) : null;
if (!hook || !rec || !Array.isArray(rec.garrisonIds) || this.depth >= MAX_DEPTH) return 0;
@@ -426,7 +426,9 @@ function grantSpeaker(gd, source) {
* @param {object|null} [ev] the event being dispatched (onPrice: ctx.modifyPrice / ctx.setPrice edit ev.price)
*/
export function makeCtx(m, ps, source, hook, ev = null) {
const gd = m.gd;
// the player's view of the data: its slotted 自选 slots are its operators (0.2.0, player/diy.js) — chessRecord,
// pieceBonds, a strategy's / 特质's bond and tier reads see the operator; the match's GameData for everyone else
const gd = ps.gd || m.gd;
const view = (p) => (p ? ps.pieceView(p) : null);
const ctx = {
hook,
@@ -516,8 +518,10 @@ export function makeCtx(m, ps, source, hook, ev = null) {
if (opts.golden) id = gd.goldenIdOf(chessId) || chessId;
if (!gd.chess(id)) return null;
const base = gd.baseIdOf(id);
// "some effects fail when the cap is hit" (research 06 §7): by default a chess of the pool needs a free copy
if (opts.requirePool !== false && m.pool.has(base) && m.pool.left(base) < 1) return null;
// "some effects fail when the cap is hit" (research 06 §7): by default a chess of the pool needs a free copy (a
// 自选 piece: one of the player's own stock — player/diy.js poolOf)
const pool = typeof ps.poolOf === 'function' ? ps.poolOf(base) : m.pool;
if (opts.requirePool !== false && pool.has(base) && pool.left(base) < 1) return null;
const p = ps.acquireChess(id, { source: opts.source || source.key || 'effect', toTemp: !!opts.toTemp, fromPool: opts.fromPool !== false });
// 「歌蕾蒂娅:获得斯卡蒂」 — every silent grantChess (a 特质, 余 SERVER_MOST_BOND, a band, an item, a choice).
// opts.toast === false skips it. A caller that already says the same thing should pass that.
+13
View File
@@ -33,6 +33,7 @@ import { TICK, SNAPSHOT_EVERY } from '../sim/constants.js';
import { layerGainRoom } from '../../shared/constants.js';
import { uniteLeft } from '../sim/spec.js';
import { GRANTED_CAP_OVERRIDE } from '../sim/content/garrisons/battle.js';
import { diyTokenOwner } from '../../shared/diy.js';
export const MAX_TICKS_PER_INTERVAL = 8;
export const INTERVAL_MS = 1000 / 30;
@@ -542,6 +543,17 @@ function summonsOf(gd, chessId) {
for (const id of [chessId, rec && rec.baseId, rec && rec.goldenId]) for (const t of (id && idx.byOwner.get(id)) || []) out.add(t);
return [...out];
}
/**
* Summon ids a 自选 piece (0.2.0: a DIY slot `slotId` with its `pick`) can create: data/backups.json tokens whose owners
* name the pick's owner form (`<charId>@<statusKey>`, shared/diy.js diyTokenOwner).
*/
function diySummonsOf(gd, slotId, pick) {
const tokens = gd.raw && gd.raw.backups && gd.raw.backups.tokens && typeof gd.raw.backups.tokens === 'object' ? gd.raw.backups.tokens : null;
const slot = typeof gd.chess === 'function' && typeof slotId === 'string' ? gd.chess(slotId) : null;
if (!tokens || !slot || !pick || typeof pick.charId !== 'string') return [];
const owner = diyTokenOwner(pick.charId, slot.status);
return Object.keys(tokens).filter((id) => Array.isArray(tokens[id] && tokens[id].owners) && tokens[id].owners.includes(owner));
}
/** Summons no chess owns (bond / band units every player may field: 炎佑, 预备干员-医疗, Touch). */
function ownerlessSummons(gd) { return summonIndex(gd).ownerless; }
@@ -674,6 +686,7 @@ export function specBounds(spec, gd = null) {
if (u.kind !== 'token') {
chess.set(u.uid, defId);
if (gd) for (const t of summonsOf(gd, defId)) defIds.add(t);
if (gd && u.diy) for (const t of diySummonsOf(gd, defId, u.diy)) defIds.add(t);
}
}
// the layers each bond starts the battle with (PlayerBattleInput.bonds): a gain never passes BOND_LAYER_CAP
+23 -7
View File
@@ -2,7 +2,9 @@
// (test/match/harness.js checkInvariants asserts the list is empty) and by tools/matchrun.mjs --check sweeps.
//
// collectViolations(m) → string[] (empty when every invariant holds):
// pool 0 ≤ left ≤ cap and left + Σ copies held by pieces == cap per base chess; non-pool chess hold 0 copies
// pool 0 ≤ left ≤ cap and left + Σ copies held by pieces == cap per base chess; non-pool chess hold 0 copies;
// a player's 自选 stock (0.2.0, player/diy.js) the same against its own pieces of each slotted slot — a DIY
// piece is always a slotted slot of its owner's, and a DIY shop / reward card one of its stocked slots
// economy funds / pendingFunds non-negative integers, LP finite, shop level in range, prices ≥ 0
// pieces unique uids; hand 10 / temp 5 slots; temp holds pieces only while the hand is full (a free hand slot pulls
// a temp piece in, PlayerState._fillHandFromTemp); chess carry ≤ equipPerChess known items; a normal piece
@@ -47,6 +49,9 @@ export function collectViolations(m, { limit = 25 } = {}) {
for (const ps of m.players.values()) {
const id = ps.playerId;
// the player's view of the data (its slotted 自选 slots are its operators) and the copies its 自选 pieces hold
const pgd = ps.gd || gd;
const diyHeld = new Map();
if (!Number.isInteger(ps.funds) || ps.funds < 0) fail(`${id}: funds ${ps.funds}`);
if (!Number.isInteger(ps.pendingFunds) || ps.pendingFunds < 0) fail(`${id}: pendingFunds ${ps.pendingFunds}`);
if (!Number.isFinite(ps.lp)) fail(`${id}: lp ${ps.lp}`);
@@ -81,8 +86,9 @@ export function collectViolations(m, { limit = 25 } = {}) {
for (const p of all) {
note(ps, p);
if (p.kind === 'chess') {
const rec = gd.chess(p.id);
const rec = pgd.chess(p.id);
if (!rec) { fail(`${id}: unknown chess ${p.id}`); continue; }
if (rec.isDiy && !rec.diyFor) fail(`${id}: owns ${p.id}, a 自选 slot it has not filled`);
if (!Array.isArray(p.items) || p.items.length > gd.equipPerChess) fail(`${id}: ${p.id} carries ${p.items && p.items.length} items`);
for (const it of p.items || []) {
note(ps, it);
@@ -93,12 +99,13 @@ export function collectViolations(m, { limit = 25 } = {}) {
const maxCopies = rec.isGolden ? gd.goldenCopies : 1;
if (!Number.isInteger(p.poolCopies) || p.poolCopies < 0 || p.poolCopies > maxCopies) fail(`${id}: ${p.id} holds ${p.poolCopies} copies`);
const base = gd.baseIdOf(p.id);
held.set(base, (held.get(base) || 0) + (p.poolCopies || 0));
const tally = rec.isDiy ? diyHeld : held;
tally.set(base, (tally.get(base) || 0) + (p.poolCopies || 0));
} else if (p.kind === 'item') {
if (!gd.item(p.id)) fail(`${id}: unknown item ${p.id}`);
countItem(p);
} else if (p.kind === 'token') {
if (!gd.token(p.id)) fail(`${id}: unknown token ${p.id}`);
if (!pgd.token(p.id)) fail(`${id}: unknown token ${p.id}`);
if (!(p.count >= 1)) fail(`${id}: token stack count ${p.count}`);
// summons exist only while their owner is deployed (withdrawing / selling / merging it removes them)
if (!boardChessUids.has(p.ownerUid)) fail(`${id}: token ${p.uid} without a deployed owner (${p.ownerUid})`);
@@ -115,7 +122,7 @@ export function collectViolations(m, { limit = 25 } = {}) {
const [r, c] = parseKey(k);
if (!(r >= FIELD.r0 && r <= FIELD.r1 && c >= FIELD.c0 && c <= FIELD.c1)) fail(`${id}: piece outside the board at ${k}`);
if (p.kind === 'item') { fail(`${id}: item ${p.id} stands on the board`); continue; }
const rec = p.kind === 'token' ? gd.token(p.id) : gd.chess(p.id);
const rec = p.kind === 'token' ? pgd.token(p.id) : pgd.chess(p.id);
const cls = p.kind === 'chess' ? placeClass(ps, rec) : positionClass(rec);
if (rec && !canPlace(dmap, cls, r, c)) fail(`${id}: ${p.id} on an illegal tile ${k}`);
// a "只能部署在召唤者攻击范围内" summon inside its owner's attack range (PlayerState.summonRange: a pure read)
@@ -126,7 +133,7 @@ export function collectViolations(m, { limit = 25 } = {}) {
if (deployed > ps.deployCap) fail(`${id}: ${deployed} chess deployed > cap ${ps.deployCap}`);
// merges are immediate
const copies = new Map();
for (const p of all) if (p.kind === 'chess' && gd.chess(p.id) && !gd.isGolden(p.id)) { const b = gd.baseIdOf(p.id); copies.set(b, (copies.get(b) || 0) + 1); }
for (const p of all) if (p.kind === 'chess' && pgd.chess(p.id) && !gd.isGolden(p.id)) { const b = gd.baseIdOf(p.id); copies.set(b, (copies.get(b) || 0) + 1); }
for (const [b, n] of copies) {
const need = gd.mergeCount(b);
if (need > 1 && gd.goldenIdOf(b) && n >= need) fail(`${id}: owns ${n} normal copies of ${b} (merges at ${need})`);
@@ -140,7 +147,7 @@ export function collectViolations(m, { limit = 25 } = {}) {
}
// bonds are up to date
try {
const fresh = computeBonds(gd, ps);
const fresh = computeBonds(pgd, ps);
if (JSON.stringify(fresh) !== JSON.stringify(ps.bonds)) fail(`${id}: stale bonds (missing recompute)`);
} catch (e) {
fail(`${id}: computeBonds threw ${e && e.message}`);
@@ -154,15 +161,24 @@ export function collectViolations(m, { limit = 25 } = {}) {
if (s.kind === 'chess' ? !gd.chess(s.id) : !gd.item(s.id)) fail(`${id}: shop slot ${i} unknown ${s.kind} ${s.id}`);
if (!Number.isInteger(s.basePrice) || s.basePrice < 0) fail(`${id}: shop slot ${i} basePrice ${s.basePrice}`);
if (s.kind === 'chess' && banned.has(gd.baseIdOf(s.id))) fail(`${id}: banned chess ${s.id} in the shop`);
if (s.kind === 'chess' && gd.chess(s.id)?.isDiy && !(ps.diyStock && ps.diyStock.has(gd.baseIdOf(s.id)))) fail(`${id}: 自选 slot ${s.id} in the shop without its stock`);
});
for (const o of ps.offers) {
for (const s of o.slots || []) {
if (s.kind === 'item' ? !gd.item(s.id) : !gd.chess(s.id)) fail(`${id}: bad reward slot ${s.kind} ${s.id}`);
if (s.kind !== 'item' && banned.has(gd.baseIdOf(s.id)) && o.source === 'merge') fail(`${id}: banned chess ${s.id} offered as a merge reward`);
if (s.kind !== 'item' && gd.chess(s.id)?.isDiy && !(ps.diyStock && ps.diyStock.has(gd.baseIdOf(s.id)))) fail(`${id}: 自选 slot ${s.id} offered without its stock`);
}
if (!o.slots || !o.slots.length || o.slots.length > 6) fail(`${id}: reward offer with ${o.slots && o.slots.length} slots`);
}
}
// 自选 stock accounting (player/diy.js): left + held == cap per slotted slot; a slot without stock holds nothing
for (const [base, e] of ps.diyStock ? ps.diyStock.entries : []) {
if (!(e.left >= 0 && e.left <= e.cap)) fail(`${id}: 自选 stock ${base}: left ${e.left} cap ${e.cap}`);
const h = diyHeld.get(base) || 0;
if (e.left + h !== e.cap) fail(`${id}: 自选 stock ${base}: left ${e.left} + held ${h} != cap ${e.cap}`);
}
for (const [base, n] of diyHeld) if (!(ps.diyStock && ps.diyStock.has(base)) && n !== 0) fail(`${id}: 自选 slot ${base} without stock holds ${n} copies`);
}
// shared pool accounting
+13 -4
View File
@@ -81,7 +81,7 @@ export class MatchViews {
// nobody can watch an eliminated player (g.watch refuses them, they have no field) and the result screen reads
// m.result's own bonds, so their layers would only cost every m.public bytes for the rest of the match
// (the mode-off bonds with members included, `off: true`, as in m.private — bondsMeta.offBondCounts)
bonds: ps.alive ? bondList(this.gd, ps.bondsView(), { off: offBondCounts(this.gd, ps) }) : [],
bonds: ps.alive ? bondList(this.gd, ps.bondsView(), { off: offBondCounts(ps.gd || this.gd, ps) }) : [],
fieldId: this.fieldOf(ps),
status: this.statusOf(ps),
autoplay: ps.autoplay,
@@ -186,11 +186,18 @@ export class MatchViews {
* hand row (row 7) — the scout renders like the own prep bench. */
prepFieldMeta(ps) {
const units = [];
// the scouted player's own view of the data (0.2.0 自选编队: its slotted DIY slots are its operators — player/diy.js)
const gd = ps.gd || this.gd;
// a 自选 piece's pick (like the sim's UnitInfo.diy): the scout's card composes the operator from it (shared/diy.js)
const diyOf = (piece) => {
const p = piece.kind === 'chess' && typeof ps.diyPickOf === 'function' ? ps.diyPickOf(piece.id) : null;
return p ? { charId: p.charId, skillIndex: p.skillIndex, uniEquipId: p.uniEquipId } : undefined;
};
for (const { r, c, piece } of boardOrder(ps.board)) {
const chess = piece.kind === 'token' ? null : this.gd.chess(piece.id);
const chess = piece.kind === 'token' ? null : 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 rec = piece.kind === 'token' ? 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
@@ -205,6 +212,7 @@ export class MatchViews {
// 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,
diy: diyOf(piece),
});
}
// the hand (整备区) and the 临时整备区 scout exactly like the own prep bench renders them: pieces as units on
@@ -215,7 +223,7 @@ export class MatchViews {
// (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 rec = piece.kind === 'item' ? gd.item(piece.id) : piece.kind === 'token' ? gd.token(piece.id) : 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;
@@ -229,6 +237,7 @@ export class MatchViews {
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,
diy: diyOf(piece),
});
};
for (let i = 0; i < ps.hand.length; i++) {
+9 -5
View File
@@ -37,10 +37,12 @@ export class PlayerAcquire {
*/
acquireChess(chessId, { source = 'grant', toTemp = false, fromPool = true, silent = false } = {}) {
const rec = this.gd.chess(chessId);
if (!rec) return null;
// a DIY slot is only ever this player's own 自选 piece: a slot it has not filled has no body (甄选干员) to gain
if (!rec || (rec.isDiy && !rec.diyFor)) return null;
const base = this.gd.baseIdOf(chessId);
const need = rec.isGolden ? this.gd.goldenCopies : 1;
const taken = fromPool ? this.m.pool.take(base, need) : 0;
// the shared pool, or this player's stock of a slotted 自选 piece (player/diy.js poolOf)
const taken = fromPool ? this.poolOf(base).take(base, need) : 0;
const piece = this.newPiece('chess', chessId, { poolCopies: taken });
this.round.gainedChess++;
let owned = piece;
@@ -121,7 +123,7 @@ export class PlayerAcquire {
// equipment, which would be lost in temp — a summon stack removed there comes back at the next round start)
if (where === 'board') this.grantTokensFor(elite);
if (!where) {
this.m.pool.give(baseId, copies);
this.poolOf(baseId).give(baseId, copies);
this.m.toast(this, 'warn', '整备区已满,晋升的精锐干员无法放入');
this.m.log.warn?.(`[match ${this.m.roomCode}] ${this.playerId}: merge result dropped (hand+temp full)`);
this.recompute();
@@ -142,7 +144,7 @@ export class PlayerAcquire {
if (!goldenId) return false;
const base = this.gd.baseIdOf(piece.id);
const extra = Math.max(0, this.gd.goldenCopies - (piece.poolCopies || 0));
piece.poolCopies = (piece.poolCopies || 0) + this.m.pool.take(base, extra);
piece.poolCopies = (piece.poolCopies || 0) + this.poolOf(base).take(base, extra);
piece.id = goldenId;
this.recompute();
return true;
@@ -205,6 +207,8 @@ export class PlayerAcquire {
* copy-weighted roll from the shared pool excluding the ones already drawn; a tier left without another chess tops
* up from the tier below (user playtest #6 item 19: the official promotion reward never offers one operator twice —
* the user's first-hand report; the normal shop's slots may repeat). The offer reserves no copies (the pick takes one).
* The player's slotted 自选 pieces join the draw like in its shop (player/diy.js diyRollEntries: once the 调度中心 is at
* the slot's level) — the reward is a temporary refresh of the shop ("临时刷新3名…干员") [ASSUMED].
*/
pushRewardOffer(source = 'merge', { tier = null, ids = null, label = null } = {}) {
const ro = this.gd.rewardOffer();
@@ -216,7 +220,7 @@ export class PlayerAcquire {
const fresh = (id) => !list.includes(id);
for (let i = 0; i < ro.count; i++) {
let id = null;
for (let tt = t; tt >= 1 && !id; tt--) id = this.m.pool.roll(this.m.rngShop, { tier: tt, filter: fresh });
for (let tt = t; tt >= 1 && !id; tt--) id = this.m.pool.roll(this.m.rngShop, { tier: tt, filter: fresh, extra: this.diyRollEntries() });
if (id) list.push(id);
}
}
+12 -6
View File
@@ -77,13 +77,16 @@ export class PlayerBasics {
/**
* 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.
* player fields as its stand-in (0.2.0 补位) the stand-in's backup selection, whatever the loadout says; for a slotted
* 自选 slot (0.2.0, player/diy.js) its pick's skill and module (the composed record's defaults — the loadout never names
* a DIY slot: shared/protocol.js checkLoadout).
*/
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));
const rec = chessRecord && chessRecord.isDiy && typeof chessRecord.chessId === 'string' ? this.gd.chess(chessRecord.chessId) || chessRecord : chessRecord;
return resolveLoadout(this.loadout, rec, (id) => this.gd.chess(id));
}
/**
@@ -119,13 +122,16 @@ export class PlayerBasics {
/**
* 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.
* (gd.standIn — the chess's identity, the stand-in's body) when the player fields its stand-in; for a DIY slot record
* the player's 自选 record of that slot (this.gd — a caller holding the match's own record of the slot gets the
* operator); 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;
if (rec && this.fieldsStandIn(rec)) return this.gd.standIn(rec.chessId) || rec;
if (rec && rec.isDiy && !rec.diyFor && typeof rec.chessId === 'string') return this.gd.chess(rec.chessId) || rec;
return rec;
}
/**
+211
View File
@@ -0,0 +1,211 @@
// server/match/player/diy.js — PlayerState methods: 自选编队 (0.2.0 DIY; research 0.2.0 §2, the owner's decisions of
// 2026-10-05) — the human's picks for the four DIY slots and what a slotted operator changes in the player's own state.
// * setDiy: the seat's picks when the match started (seats[].diy, server/lobby.js room.diy), re-checked against this
// match's data and kit registry (shared/protocol.js checkDiyPicks, server/sim/content/kits/index.js KITTED_CHARS) and
// fixed for the match — an out-of-match setting: a change during a match applies to the next one. Bots field no 自选
// piece [ASSUMED]. `diy` = { [slotBaseId]: { charId, skillIndex, uniEquipId } } (frozen; uniEquipId null = none).
// * the player's data view (diyGameData): with picks, `ps.gd` is a view of the match's GameData whose chess(id) is the
// composed 自选 record of a slotted slot, both forms (shared/diy.js diyRecord: the slot's identity — tier, price, sell
// price, the 3 → elite merge, status: E2 Lv1 skill rank 4 / E2 Lv60 rank 7 with the module at stage 1 at tier 5, 3 at
// tier 6 — and the operator's body: name, class, position, stats, range, the pick's skill and module, no 特质, the
// bonds derived from its factions; `diyFor` = the slot, `charId` = the operator). Every rule that reads a record
// through ps.gd — bonds (bondsMeta), the 特质 / meta effects (effectsMeta makeCtx), placement and summon ranges, the
// AI 托管 evaluation, names in toasts and tickers — sees the operator. A player without picks keeps the match's
// GameData itself: nothing changes for anyone else. token(id) also finds the 自选 summons (data/backups.json
// `tokens`) and placeableTokens() reads their variant of the owner form (`<charId>@<statusKey>`, shared/diy.js
// diyTokenOwner), so a placeable 自选 summon comes to the hand like any operator's (PRTS 卫戍协议/帮助 §战斗部署).
// * the per-player stock (initDiyStock, called by the match once its bans are drawn): each slotted DIY piece has copies
// of its own — the tier's pool copies, 8 at tier 5 and 5 at tier 6 [ASSUMED: research 0.2.0 §2.5, the excel has no
// stock field] — drawn by this player only; it never enters the shared pool (PR #71's shared shop was the mistake to
// avoid). A piece all of whose bonds are switched off this match (本局禁用: the drawn bans and the mode's static list)
// gets no stock and so leaves the shop, like a preset chess whose bonds are all off (PRTS 卫戍协议:盟约 "禁用盟约有可能
// 影响自选的支援干员"); 协防 (emptyShip) is never banned, so a prototype always stays. poolOf(baseId) routes every copy
// taken or returned for a slotted slot to that stock (buy, reward picks, merges, promotions, sells, temp, elimination).
// * the shop's draws (diyRollEntries): the stock of each slotted slot joins this player's copy-weighted rolls — the
// shop's chess slots and the reward offers' temporary refreshes [ASSUMED] — weighted like any chess of its tier, once
// the 调度中心 has reached the slot's shopLevel (activity_table shopLevelDisplayDataDict lists the tier-5 slots at
// level 5 and the tier-6 slots at level 6; PRTS 帮助 "仅在调度中心等级 ≥ 干员所在等阶"). The price is the slot's (any
// chess of its tier: 4). Effects that grant a random operator from the shared pool never draw it.
// Installed on PlayerState.prototype by server/match/PlayerState.js (a method container: never instantiated; `this` is
// the player state).
import { KITTED_CHARS } from '../../sim/content/kits/index.js';
import { checkDiyPicks } from '../../../shared/protocol.js';
import { diyRecord, diySlot, diyTokenOwner } from '../../../shared/diy.js';
const posIntOr = (v, d) => (Number.isInteger(v) && v > 0 ? v : d);
const isObj = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
/**
* The copies of a player's slotted DIY pieces — the shared pool's copy interface (has / cap / left / take / give) for
* those base ids only. `entries`: baseId → { cap, left, tier, shopLevel }.
*/
export class DiyStock {
constructor() {
/** @type {Map<string, { cap: number, left: number, tier: number, shopLevel: number }>} */
this.entries = new Map();
}
has(baseId) { return this.entries.has(baseId); }
cap(baseId) { return this.entries.get(baseId)?.cap ?? 0; }
left(baseId) { return this.entries.get(baseId)?.left ?? 0; }
/** Take up to n copies; returns the number taken (0 for a base id without stock). */
take(baseId, n = 1) {
const e = this.entries.get(baseId);
if (!e || !(n > 0)) return 0;
const k = Math.min(e.left, Math.floor(n));
e.left -= k;
return k;
}
/** Return n copies (clamped at the cap); returns the number returned. */
give(baseId, n = 1) {
const e = this.entries.get(baseId);
if (!e || !(n > 0)) return 0;
const k = Math.min(e.cap - e.left, Math.floor(n));
e.left += k;
return k;
}
/** { baseId: left } (tests / diagnostics). */
snapshot() {
const o = {};
for (const [id, e] of this.entries) o[id] = e.left;
return o;
}
}
/**
* The player's view of the match's GameData (see the header): `records` maps the ids of the slotted slots (normal and
* elite) to their composed 自选 records; every other lookup is the match's own.
* @param {import('../gamedata.js').GameData} gd
* @param {Map<string, object>} records
* @returns {import('../gamedata.js').GameData}
*/
export function diyGameData(gd, records) {
const view = Object.create(gd);
const backups = isObj(gd.raw && gd.raw.backups) ? gd.raw.backups : null;
const diyTokens = backups && isObj(backups.tokens) ? backups.tokens : {};
const tokenOf = (id) => (typeof id === 'string' && Object.hasOwn(diyTokens, id) && isObj(diyTokens[id]) ? diyTokens[id] : null);
Object.defineProperties(view, {
/** the match's own GameData (the view is per player) */
matchData: { value: gd },
chess: { value: (id) => (typeof id === 'string' && records.has(id) ? records.get(id) : gd.chess(id)) },
token: { value: (id) => gd.token(id) || tokenOf(id) },
/**
* GameData.placeableTokens for a slotted slot: the summons its record lists (the pick's skill and talents) that are
* placeable, by the variant of the owner form (`bySkill[skillIndex]` sources) — the deploy limit as the count.
*/
placeableTokens: {
value: (chessId, loadout = null) => {
const rec = typeof chessId === 'string' && records.has(chessId) ? records.get(chessId) : null;
if (!rec) return gd.placeableTokens(chessId, loadout);
const owner = diyTokenOwner(rec.charId, rec.status);
const out = [];
for (const tid of Array.isArray(rec.tokens) ? rec.tokens : []) {
const t = tokenOf(tid);
if (!t || t.kind !== 'summon' || t.placeable !== true) continue;
const v = isObj(t.variants) ? t.variants[owner] ?? null : null;
if (v) {
const alt = loadout && Number.isInteger(loadout.skillIndex) && v.bySkill ? v.bySkill[loadout.skillIndex] : null;
const src = Array.isArray(alt?.sources) ? alt.sources : Array.isArray(v.sources) ? v.sources : [];
if (!src.includes('talent') && !src.includes('skill')) continue;
}
out.push({ tokenId: tid, count: Math.min(posIntOr(v?.stats?.deployLimit, posIntOr(t.deployLimit, 1)), 9) });
}
return out;
},
},
});
return view;
}
export class PlayerDiy {
/**
* Fix the 自选 picks for the match (see the header): the seat's picks re-checked against this match's data and kits;
* a slot whose record cannot be composed is left empty. With picks, `this.gd` becomes the player's data view.
* @param {any} picks `{ [slotBaseId]: { charId, skillIndex?, uniEquipId? } | null }`
* @param {{ kitted?: Iterable<string> }} [opts] the operators with a kit (default: the registry's KITTED_CHARS; tests may
* widen it to slot an operator whose kit is still being written)
* @returns {boolean} false when the picks are malformed (nothing changes) or the player is a bot
*/
setDiy(picks, { kitted = KITTED_CHARS } = {}) {
if (this.isBot) return false;
const gd = this.m.gd;
const data = gd.raw;
const res = checkDiyPicks(picks, { data, kitted });
if (!res || !('ok' in res)) {
this.m.log?.warn?.(`[match ${this.m.roomCode}] 自选 picks of ${this.playerId} ignored: ${res && res.detail}`);
return false;
}
const kept = {};
const records = new Map();
for (const [slotId, pick] of Object.entries(res.picks)) {
const slot = diySlot(slotId, data);
let normal;
let golden;
try {
normal = diyRecord(slotId, pick, { elite: false, data });
golden = diyRecord(slotId, pick, { elite: true, data });
} catch { normal = null; golden = null; }
if (!slot || !normal || !golden) continue;
records.set(slot.baseId, Object.freeze(normal));
records.set(slot.goldenId, Object.freeze(golden));
kept[slot.baseId] = Object.freeze({ charId: pick.charId, skillIndex: pick.skillIndex, uniEquipId: pick.uniEquipId ?? null });
}
this.diy = Object.freeze(kept);
this._diyRecords = records;
this.gd = records.size ? diyGameData(gd, records) : gd;
return true;
}
/**
* The stock of each slotted DIY piece (see the header), once the match has drawn its bans: `off` = the bonds switched
* off this match (drawn + the mode's static list). A piece all of whose bonds are off gets none (`diyBanned`).
* @param {Set<string>} off
*/
initDiyStock(off = new Set()) {
this.diyStock = new DiyStock();
const banned = [];
const gd = this.m.gd;
for (const slotId of Object.keys(this.diy || {})) {
const rec = this._diyRecords.get(slotId);
const slot = diySlot(slotId, gd.raw);
if (!rec || !slot) continue;
const bonds = Array.isArray(rec.bonds) ? rec.bonds : [];
if (bonds.length > 0 && bonds.every((b) => off.has(b))) { banned.push(slotId); continue; }
const cap = gd.poolCopies(slotId);
if (!(cap > 0)) continue;
this.diyStock.entries.set(slotId, { cap, left: cap, tier: gd.tierOf(slotId), shopLevel: slot.shopLevel });
}
this.diyBanned = Object.freeze(banned);
}
/** The copy accounting of base chess `baseId` for this player: its own stock for a slotted DIY slot, else the shared pool. */
poolOf(baseId) {
return this.diyStock && this.diyStock.has(baseId) ? this.diyStock : this.m.pool;
}
/**
* The stock entries this player's shop draws from now ([baseId, entry] with copies left and the 调度中心 at the slot's
* shopLevel), or null (none — the shared pool's roll alone, exactly as without picks).
* @returns {Array<[string, { cap: number, left: number, tier: number, shopLevel: number }]> | null}
*/
diyRollEntries() {
if (!this.diyStock || !this.diyStock.entries.size) return null;
const out = [];
for (const [id, e] of this.diyStock.entries) if (e.left > 0 && this.shop.level >= e.shopLevel) out.push([id, e]);
return out.length ? out : null;
}
/**
* The 自选 pick a chess id (normal or elite) of this player fields — its slot's — or null (not a slotted slot).
* @param {string} id
* @returns {{ charId: string, skillIndex: number, uniEquipId: string|null } | null}
*/
diyPickOf(id) {
const rec = this._diyRecords && typeof id === 'string' ? this._diyRecords.get(id) : null;
return rec ? this.diy[rec.diyFor] ?? null : null;
}
}
+8 -5
View File
@@ -1,6 +1,6 @@
// server/match/player/economy.js — PlayerState methods: the economy — funds, spending (onSpend), prep-side bond layer
// gains (clamped by layerGainRoom), the shop (onPrice prices, copy-weighted rolls from the shared pool, freeze) and its
// intents: g.buy, g.refresh, g.freeze, g.levelUp, g.sell.
// gains (clamped by layerGainRoom), the shop (onPrice prices, copy-weighted rolls from the shared pool plus the player's
// own 自选 stock — player/diy.js —, freeze) and its intents: g.buy, g.refresh, g.freeze, g.levelUp, g.sell.
// Installed on PlayerState.prototype by server/match/PlayerState.js (a method container: never instantiated; `this` is
// the player state).
@@ -61,7 +61,8 @@ export class PlayerEconomy {
}
_rollChessSlot() {
const id = this.m.pool.roll(this.m.rngShop, { maxTier: this.shop.level });
// the slotted 自选 pieces join the draw once the 调度中心 reaches their slot's level (player/diy.js diyRollEntries)
const id = this.m.pool.roll(this.m.rngShop, { maxTier: this.shop.level, extra: this.diyRollEntries() });
return id ? { kind: 'chess', id, basePrice: this.gd.chessPrice(id), frozen: false, sold: false } : null;
}
@@ -115,10 +116,12 @@ export class PlayerEconomy {
let piece;
if (slot.kind === 'chess') {
const rec = this.gd.chess(slot.id);
if (!rec) return fail(ERR.BAD_TARGET);
// a DIY slot sells only as this player's own 自选 piece (a slotted slot's record carries `diyFor`)
if (!rec || (rec.isDiy && !rec.diyFor)) return fail(ERR.BAD_TARGET);
const base = this.gd.baseIdOf(slot.id);
const need = rec.isGolden ? this.gd.goldenCopies : 1;
if (this.m.pool.has(base) && this.m.pool.left(base) < need) return fail(ERR.SOLD_OUT);
const pool = this.poolOf(base);
if (pool.has(base) && pool.left(base) < need) return fail(ERR.SOLD_OUT);
if (handFull) return fail(ERR.HAND_FULL);
this.spend(price);
slot.sold = true;
+6 -4
View File
@@ -1,7 +1,8 @@
// server/match/player/pieces.js — PlayerState methods: piece bookkeeping — new pieces, lookup by uid (board / hand /
// temp / equipped), the chess locations, detach and stow (hand first, overflow temp; a free hand slot pulls a temp piece
// in, _fillHandFromTemp), the per-piece round counters (拉普兰德), pool copies back to the shared pool, and summon stacks
// (removal, lifting, counts, the stack granted for an owner).
// in, _fillHandFromTemp), the per-piece round counters (拉普兰德), pool copies back to the shared pool (a 自选 piece's to
// the player's own stock), and summon stacks (removal, lifting, counts, the stack granted for an owner — a 自选 owner's
// from its pick, player/diy.js placeableTokens).
// Installed on PlayerState.prototype by server/match/PlayerState.js (a method container: never instantiated; `this` is
// the player state).
@@ -132,10 +133,11 @@ export class PlayerPieces {
return v;
}
/** Return a piece's pool copies (and its equipped items are handled by the caller). */
/** Return a piece's pool copies — to the shared pool, or a 自选 piece's to this player's stock (poolOf). */
returnCopies(piece) {
if (piece && piece.kind === 'chess' && piece.poolCopies > 0) {
this.m.pool.give(this.gd.baseIdOf(piece.id), piece.poolCopies);
const base = this.gd.baseIdOf(piece.id);
this.poolOf(base).give(base, piece.poolCopies);
piece.poolCopies = 0;
}
}
+3 -2
View File
@@ -34,10 +34,11 @@ export class PlayerPrep {
if (handFull) return fail(ERR.HAND_FULL);
} else {
const rec = this.gd.chess(slot.id);
if (!rec) return fail(ERR.BAD_TARGET);
if (!rec || (rec.isDiy && !rec.diyFor)) return fail(ERR.BAD_TARGET);
const base = this.gd.baseIdOf(slot.id);
const need = rec.isGolden ? this.gd.goldenCopies : 1;
if (this.m.pool.has(base) && this.m.pool.left(base) < need) return fail(ERR.SOLD_OUT);
const pool = this.poolOf(base); // the shared pool, or this player's 自选 stock
if (pool.has(base) && pool.left(base) < need) return fail(ERR.SOLD_OUT);
if (handFull) return fail(ERR.HAND_FULL);
}
const price = Number.isFinite(slot.price) && slot.price > 0 ? Math.trunc(slot.price) : 0;
+8 -2
View File
@@ -1,7 +1,8 @@
// 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 or their 补位 mark, carried 联防 state, the reached layers).
// PlayerBattleInput: board units with their loadout, their 补位 mark or their 自选 pick, 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,7 +103,12 @@ 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) };
if (this.fieldsStandIn(piece.id)) {
const pick = this.diyPickOf(piece.id);
if (pick) {
// 0.2.0 自选编队: a slotted DIY slot fights as its pick (sim getChess(id, { diy }): the operator's body, the pick's
// skill and module — no loadout fields, docs/SIM.md §12)
u.diy = { charId: pick.charId, skillIndex: pick.skillIndex, uniEquipId: pick.uniEquipId };
} else 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;
+6
View File
@@ -91,6 +91,12 @@ export class PlayerViews {
// 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,
// 0.2.0 自选编队: this player's 自选 picks in this match ({ [slotBaseId]: { charId, skillIndex, uniEquipId } } — the seat's
// when the match started; {} = none) — the client composes the operator's record for its own cards and pieces
// (shared/diy.js diyRecord); `diyBanned`: the slotted slots out of the shop this match (every bond of the operator
// switched off)
diy: this.diy,
diyBanned: this.diyBanned,
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,
+20 -10
View File
@@ -12,7 +12,9 @@
//
// Rolls: each chess slot draws ONE copy uniformly from all remaining copies of eligible chess with tier ≤ shop level
// ("copy-weighted"; duplicates within a roll allowed). The item slot picks a tier with the same tier shares, then a
// uniform shop-eligible item of that tier (falling back to lower tiers).
// uniform shop-eligible item of that tier (falling back to lower tiers). A roll may add entries outside the pool
// (`extra`, after its own): one player's 自选 stock (0.2.0, player/diy.js diyRollEntries) — weighted by its copies like
// any chess, drawn by that player's shop only.
/**
* Per-match disabled bond set D and banned chess (research 01 A2): D = uniform sample of `core` core bonds and `addon`
@@ -90,22 +92,30 @@ export class SharedPool {
return k;
}
/** Remaining copies of eligible chess (tier ≤ maxTier, or exactly `tier`). */
_eligible({ maxTier = 6, tier = null, filter = null } = {}) {
/**
* Remaining copies of eligible chess (tier ≤ maxTier, or exactly `tier`): the pool's entries, then `extra` ([id, entry]
* pairs of the same shape — a player's 自选 stock) under the same filters.
*/
_eligible({ maxTier = 6, tier = null, filter = null, extra = null } = {}) {
const out = [];
for (const [id, e] of this.entries) {
if (e.left <= 0) continue;
if (tier != null ? e.tier !== tier : e.tier > maxTier) continue;
if (filter && !filter(id, e)) continue;
out.push([id, e.left]);
}
const scan = (list) => {
for (const [id, e] of list) {
if (e.left <= 0) continue;
if (tier != null ? e.tier !== tier : e.tier > maxTier) continue;
if (filter && !filter(id, e)) continue;
out.push([id, e.left]);
}
};
scan(this.entries);
if (extra) scan(extra);
return out;
}
/**
* Copy-weighted roll: one copy uniformly among remaining copies of eligible chess. Returns a base id or null.
* @param {Function} rng
* @param {{ maxTier?: number, tier?: number|null, filter?: (id: string, e: object) => boolean }} [opts]
* @param {{ maxTier?: number, tier?: number|null, filter?: (id: string, e: object) => boolean,
* extra?: Iterable<[string, { left: number, tier: number }]>|null }} [opts]
*/
roll(rng, opts = {}) {
const el = this._eligible(opts);
+7 -3
View File
@@ -95,9 +95,13 @@ export function buildResult(m, outcome) {
const teamRounds = victory ? gd.bossRound + (hiddenCleared ? 1 : 0) : Math.max(0, Math.min(m.round, gd.bossRound) - 1);
const rows = players.map((ps) => {
const roundsPassed = !ps.alive && ps.eliminatedRound != null ? Math.max(0, ps.eliminatedRound - 1) : teamRounds;
const lineup = boardOrder(ps.board).filter((x) => x.piece.kind === 'chess').map(({ r, c, piece }) => ({
id: piece.id, golden: gd.isGolden(piece.id), tier: gd.tierOf(piece.id), row: r, col: c, items: (piece.items || []).map((i) => i.id),
}));
const lineup = boardOrder(ps.board).filter((x) => x.piece.kind === 'chess').map(({ r, c, piece }) => {
const e = { id: piece.id, golden: gd.isGolden(piece.id), tier: gd.tierOf(piece.id), row: r, col: c, items: (piece.items || []).map((i) => i.id) };
// 0.2.0 自选编队: a DIY slot's pick — the result screen names and draws the operator (shared/diy.js diyRecord)
const pick = typeof ps.diyPickOf === 'function' ? ps.diyPickOf(piece.id) : null;
if (pick) e.diy = { charId: pick.charId, skillIndex: pick.skillIndex, uniEquipId: pick.uniEquipId };
return e;
});
// the team's clear counts for the players still in; an eliminated / departed teammate did not pass the boss round
const cleared = victory && ps.alive;
return {
+10 -4
View File
@@ -29,6 +29,8 @@
//
// The handler object (implemented by server/lobby.js) receives:
// onHello(session, { resumed, repeat }) after `welcome` was sent
// welcomeInfo() → object (optional) extra fields of every `welcome` (never one of its own keys): the lobby's
// `diyKitted` (0.2.0 自选编队 — which operators a DIY slot may field)
// onMessage(session, msg) → { ok: true } | { error: ERR code, detail?: string } | undefined
// routeGame(session, msg) → same (optional): client-side combat reports `b.progress` / `b.result` (DESIGN §14) go
// straight to the running match through it; without it they reach onMessage
@@ -61,10 +63,10 @@ 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), room.ownership
* (a ≤ 160-id list, the same way) and room.spectate
* (a ≤ 160-id list, the same way), room.diy (≤ 8 自选 picks checked against the data, 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.ownership', 'room.spectate']);
export const HEAVY_TYPES = new Set(['g.watch', 'room.loadout', 'room.ownership', 'room.diy', 'room.spectate']);
/** Close codes (see header). */
export const CLOSE = Object.freeze({ REPLACED: 4001, HELLO_TIMEOUT: 4002, POLICY: 1008, SHUTDOWN: 1001 });
@@ -109,6 +111,8 @@ export class Session {
this.loadout = null;
/** @type {readonly string[] | null} checked not-owned chess ids (干员持有, lobby-owned, 0.2.0 补位) */
this.notOwned = null;
/** @type {Readonly<Record<string, { charId: string, skillIndex: number, uniEquipId: string|null }>> | null} checked 自选 picks (lobby-owned, 0.2.0 自选编队) */
this.diy = 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 */
@@ -500,7 +504,7 @@ export class Network {
/**
* @param {{
* registry: SessionRegistry,
* handler: { onHello?: Function, onMessage: Function, onDisconnect?: Function, onExpire?: Function },
* handler: { onHello?: Function, onMessage: Function, onDisconnect?: Function, onExpire?: Function, welcomeInfo?: Function },
* log?: { info: Function, warn: Function, error: Function, debug?: Function },
* now?: () => number,
* options?: Partial<typeof NET_DEFAULTS>,
@@ -656,7 +660,9 @@ export class Network {
session.addr = conn.ip;
session.limitKey = conn.key;
const welcome = { t: 'welcome', playerId: session.playerId, token: session.token, name: session.name, serverNow: now, version: PROTOCOL_VERSION, resumed };
let extra = null;
try { extra = this.handler.welcomeInfo?.() ?? null; } catch (e) { this.log.error('[net] welcomeInfo crashed', e); }
const welcome = { ...(extra && typeof extra === 'object' ? extra : null), t: 'welcome', playerId: session.playerId, token: session.token, name: session.name, serverNow: now, version: PROTOCOL_VERSION, resumed };
if (validRid(rid)) welcome.rid = rid;
this.reply(conn, welcome);
try {
+11 -1
View File
@@ -182,7 +182,17 @@ pick chooses any of the three, so write every skill under `skills: { [skillId]:
`talents`, `trait` and `install` apply under every skill. Read every number from `chess` / `bb` — the same file serves
both tiers, both forms and both module stages. A 自选 piece has no 特质 and its bonds come from its factions: neither is
the kit's business. Summons: `battle.tokenDef(tokenId, unit)` / `battle.spawnToken(unit, …)` resolve the variant of the
pick (the hand pieces of 自选 summons come with the per-player shop). Potential is 0 [ASSUMED: no account].
pick. A **placeable** summon (data/backups.json `tokens[id].placeable`, made by the pick's skill or a talent — the
variant's `sources`) is a hand piece like any operator's: when the 自选 piece is deployed in prep its player gets one
stack of the variant's `deployLimit` (server/match/player/diy.js `placeableTokens`, PlayerState `grantTokensFor`), places
it on a legal tile (the token's position / `ownerRange`), loses it with its owner (sold, merged, moved back), and the
match hands every placed piece to the battle as a PlayerBattleInput token unit `{ kind: 'token', tokenId, ownerUid }` —
so a kit finds its pieces before the battle starts as `battle.allyUnits` with `kind === 'token'`, its `defId` and
`ownerUnit === unit`, still undeployed (e.g. `op-bgsnow.js` gives its 打字机 pieces their kit in a talent's `install`
with `battle._setupUnit(t, kit)`; `op-cgbird.js` counts its 幻影). A kit test fields a piece the same way:
`units: [op, { uid, kind: 'token', tokenId, ownerUid, row, col }]` (`test/content/op_bgsnow.test.js`); the match side is
covered by `test/match/diy-shop.test.js` (a DIY 鸿雪's 打字机 from prep to battle). Potential is 0 [ASSUMED: no account]. A new kit makes the operator a legal pick at once:
the server's `welcome.diyKitted` lists `KITTED_CHARS`, so the 自选编队 picker offers it and `room.diy` keeps it.
**The fidelity rule and the checklist** above apply item by item: every skill at rank 4 and 7, every talent, every
module at stages 1 and 3 (trait override, talent changes, stats), the range while a skill runs, the auto-cast trigger
+41 -6
View File
@@ -12,7 +12,10 @@
// - no 特质 (PRTS "甄选加入的干员不会拥有任何特质"); bonds from the operator's factions (`diy.operators[id].bonds`);
// - the normal form is E2 Lv1, skill rank 4, no module; the elite E2 Lv60, rank 7, its module at stage 1 (tier 5) or 3
// (tier 6) — the slot record's `status` (activity_table diyChessDict) picks the unit form;
// - an owned pick chooses its skill (any of 3) and module (any of its modules, or none); a prototype carries the skill
// - an owned pick chooses its skill (any of 3) and module (any of its modules, or none — a roster never carries a
// 集成战略-only one: ISW-A, "在集成战略中…" [ASSUMED: not usable outside 集成战略, the owner's decision of 2026-10-05],
// validateDiyPicks / isDiyModule; the composed record and the sim still field any module, as the kits test them); a
// prototype carries the skill
// and module of its 补位 rows at that tier (`diy.locked`: "技能携带规则与系统补位时一致" — [ASSUMED] that reading, the
// owner's decision of 2026-10-05);
// - potential 0 for everyone [ASSUMED: no account].
@@ -24,6 +27,21 @@ import { composeUnitRecord, unitForm, statusKey } from './standIn.js';
/** The tiers that have 自选 slots. */
export const DIY_TIERS = Object.freeze([5, 6]);
/**
* Module types a player's 自选 roster never carries: the 集成战略 modules (ISW-A — their own effect applies only "在集成
* 战略中") [ASSUMED: not usable outside 集成战略, the owner's decision of 2026-10-05]. A roster rule (validateDiyPicks,
* the client's picker), like the kit list: checkDiyPick / diyRecordOf still compose any module of the form.
*/
export const DIY_EXCLUDED_MODULE_TYPE = /^ISW-/;
/**
* Whether a module of a unit form (`forms[k].modules[]` entry) may be picked for a 自选 piece (DIY_EXCLUDED_MODULE_TYPE).
* @param {any} mod
*/
export function isDiyModule(mod) {
return isObj(mod) && typeof mod.uniEquipId === 'string' && !DIY_EXCLUDED_MODULE_TYPE.test(String(mod.typeName ?? ''));
}
/**
* @typedef {{ charId: string, skillIndex?: number|null, uniEquipId?: string|null }} DiyPick a 自选 pick: the operator,
* its skill index (0-based; a prototype's is its locked one) and module (uniEquipId; null / absent / 'none' = none)
@@ -55,7 +73,9 @@ function diyOf(data) {
}
/**
* The 自选 slot a chess id names: its base (normal) id, its tier and both records. Null for any other id.
* The 自选 slot a chess id names: its base (normal) id, its tier, the 调度中心 level whose shop lists it (`shopLevel`:
* activity_table shopLevelDisplayDataDict — the tier-5 slots at level 5, the tier-6 slots at level 6; the tier when the
* data lacks it) and both records. Null for any other id.
* @param {string} slotId a slot's normal (`_a`) or elite (`_b`) id
* @param {DiyData} data
*/
@@ -65,7 +85,8 @@ export function diySlot(slotId, data) {
const baseId = own(diy.slots, slotId) ? slotId : Object.keys(diy.slots).find((id) => diy.slots[id]?.goldenId === slotId);
const s = baseId ? diy.slots[baseId] : null;
if (!s) return null;
return { baseId, goldenId: s.goldenId, tier: s.tier, elite: slotId !== baseId, normal: chessOf(data, baseId), golden: chessOf(data, s.goldenId) };
const shopLevel = Number.isInteger(s.shopLevel) && s.shopLevel > 0 ? s.shopLevel : s.tier;
return { baseId, goldenId: s.goldenId, tier: s.tier, shopLevel, elite: slotId !== baseId, normal: chessOf(data, baseId), golden: chessOf(data, s.goldenId) };
}
/** The base ids of the 自选 slots (data order: tier 5 then tier 6). @param {DiyData} data @returns {string[]} */
@@ -157,7 +178,8 @@ export function checkDiyPick(slotId, pick, data) {
}
if (!forms.every((f) => (f.skills ?? []).some((s) => s && s.index === skillIndex))) return { error: `${charId}: no skill ${skillIndex}` };
const elite = forms[1];
if (uniEquipId !== null && !(elite.modules ?? []).some((m) => m && m.uniEquipId === uniEquipId)) {
const mod = uniEquipId !== null ? (elite.modules ?? []).find((m) => m && m.uniEquipId === uniEquipId) : null;
if (uniEquipId !== null && !mod) {
return { error: `${charId}: no module ${uniEquipId} at stage ${slot.golden?.status?.equipLevel ?? '?'}` };
}
return { ok: true, pick: { charId, skillIndex, uniEquipId } };
@@ -201,10 +223,21 @@ export function diyRecord(slotId, pick, { elite = false, data } = { data: null }
return diyRecordOf(elite || slot.elite ? slot.golden : slot.normal, pick, data);
}
/**
* The module record `uniEquipId` of `charId`'s elite form at a slot's stage (data/backups.json form `modules[]`), or null.
* @param {string} slotId @param {string} charId @param {string} uniEquipId @param {DiyData} data
*/
export function diyModuleOf(slotId, charId, uniEquipId, data) {
const slot = diySlot(slotId, data);
const form = slot?.golden ? unitForm(backupsOf(data), charId, slot.golden.status) : null;
return (form?.modules ?? []).find((m) => m && m.uniEquipId === uniEquipId) ?? null;
}
/**
* Check a whole 自选 roster: `picks` maps slot base ids to a pick (absent / null = an empty slot). Strict: an unknown
* slot, an illegal pick (checkDiyPick), an operator without a kit (when `kitted` is given), the same operator twice in
* one tier, or an owned operator in two slots rejects the roster; a prototype may fill a tier-5 and a tier-6 slot.
* slot, an illegal pick (checkDiyPick), a 集成战略 module (isDiyModule), an operator without a kit (when `kitted` is
* given), the same operator twice in one tier, or an owned operator in two slots rejects the roster; a prototype may fill
* a tier-5 and a tier-6 slot.
* @param {any} picks
* @param {{ data: DiyData, kitted?: Kitted }} opts
* @returns {{ ok: true, picks: Record<string, DiyLoadout> } | { error: 'BAD_MSG'|'BAD_TARGET', detail: string }}
@@ -225,6 +258,8 @@ export function validateDiyPicks(picks, { data, kitted = null } = { data: null }
if (!('ok' in c)) return { error: 'BAD_TARGET', detail: c.error };
const { charId } = c.pick;
if (!ok(charId)) return { error: 'BAD_TARGET', detail: `${charId} has no kit yet` };
const mod = c.pick.uniEquipId ? diyModuleOf(slotId, charId, c.pick.uniEquipId, data) : null;
if (mod && !isDiyModule(mod)) return { error: 'BAD_TARGET', detail: `${charId}: ${c.pick.uniEquipId} is a 集成战略 module (${mod.typeName})` };
const tier = diy.slots[slotId].tier;
const inTier = byTier.get(tier) ?? new Set();
if (inTier.has(charId)) return { error: 'BAD_TARGET', detail: `${charId} fills two tier-${tier} slots` };
+50
View File
@@ -3,6 +3,7 @@
import { DIFFICULTIES, NAME_MAX_LEN, ROOM_CODE_LEN, MAX_SEATS, EMOTES, GEO } from './constants.js';
import { isDroppableChess } from './standIn.js';
import { diySlotIds, validateDiyPicks } from './diy.js';
// ---- tiny validators -------------------------------------------------------
const isInt = (v, lo = -Infinity, hi = Infinity) => Number.isInteger(v) && v >= lo && v <= hi;
@@ -204,6 +205,52 @@ export function checkNotOwned(list, getChess) {
return { ok: true, notOwned, dropped: list.length - notOwned.length };
}
// ---- 自选编队 (0.2.0 DIY): room.diy { picks } ------------------------------------------------------------------------
/**
* `room.diy { picks }`: the player's 自选编队 — `{ [slotBaseId]: { charId, skillIndex?, uniEquipId? } | null }` for the
* four DIY slots (data/backups.json `diy.slots`: two at tier 5, two at tier 6; shared/diy.js). An out-of-match setting
* like 干员持有: stored per session / seat, a match takes the picks its seat had at its start. Structural limit below
* (room for more slots in a later season); the semantic check (`checkDiyPicks`) is LENIENT, like checkNotOwned: an
* illegal pick — not a pick of the slot's tier, an operator without a kit, a prototype off its locked skill, an unknown
* skill / module, the same operator twice in a tier, an owned operator in a second slot, an unknown slot — is dropped,
* never the whole roster; only malformed input is BAD_MSG.
*/
export const DIY_LIMITS = Object.freeze({ slots: 8 });
const isDiyPickWire = (p) => p === null || (isPlain(p) && isId(p.charId)
&& nullable((v) => isInt(v, 0, 9))(p.skillIndex) && nullable(isId)(p.uniEquipId));
/** Structural check of `room.diy.picks`: a map of ≤ 8 slot ids → a pick `{ charId, skillIndex?, uniEquipId? }` or null. */
export const isDiyPicks = (v) => isMap(v, DIY_LIMITS.slots, isId, isDiyPickWire);
/**
* Semantic check + normalisation of a 自选 roster against the game data (`{ chess, backups }` or a sim DataSource) and
* the kit registry (`kitted`: server/sim/content/kits/index.js KITTED_CHARS — an operator without a kit is never fielded):
* the slots are taken in data order (tier 5, then tier 6), and each pick is kept when the roster so far plus it still
* passes shared/diy.js validateDiyPicks — so the result always passes it, and of two picks that clash (one owned operator
* in two slots, one operator twice in a tier) the first slot keeps it. Kept picks are complete
* (`{ charId, skillIndex, uniEquipId }`: a prototype's locked selection, uniEquipId null = no module).
* @param {any} picks `room.diy.picks`
* @param {{ data: any, kitted?: Iterable<string>|((id: string) => boolean)|null }} opts
* @returns {{ ok: true, picks: Record<string, { charId: string, skillIndex: number, uniEquipId: string|null }>, dropped: number }
* | { error: 'BAD_MSG', detail: string }}
*/
export function checkDiyPicks(picks, { data, kitted = null } = { data: null }) {
if (!isDiyPicks(picks)) return { error: 'BAD_MSG', detail: 'bad 自选 picks' };
const slots = diySlotIds(data);
/** @type {Record<string, { charId: string, skillIndex: number, uniEquipId: string|null }>} */
const kept = {};
let dropped = 0;
for (const id of Object.keys(picks)) if (picks[id] != null && !slots.includes(id)) dropped++;
for (const slotId of slots) {
const pick = Object.hasOwn(picks, slotId) ? picks[slotId] : null;
if (pick == null) continue;
const res = validateDiyPicks({ ...kept, [slotId]: pick }, { data, kitted });
if ('ok' in res) kept[slotId] = res.picks[slotId];
else dropped++;
}
return { ok: true, picks: kept, dropped };
}
// ---- 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);
@@ -289,6 +336,9 @@ export const C2S = {
// 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 },
// 自选编队 (0.2.0 DIY): the player's DIY slot picks; stored per session / seat like room.ownership (a match takes the
// picks its seat had when it started; during a match they are stored for the next one: ROOM_STARTED)
'room.diy': { picks: isDiyPicks },
// 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.
+20 -2
View File
@@ -8,7 +8,7 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { diyPool, diySlot, diySlotIds, checkDiyPick, diyRecord, diyRecordOf, validateDiyPicks, lockedSelection, diyTokenOwner, DIY_TIERS } from '../shared/diy.js';
import { diyPool, diySlot, diySlotIds, checkDiyPick, diyRecord, diyRecordOf, validateDiyPicks, lockedSelection, diyTokenOwner, isDiyModule, DIY_TIERS } from '../shared/diy.js';
import { unitForm, composeUnitRecord } from '../shared/standIn.js';
import { composeStats, composeTalents } from '../shared/loadoutRecord.js';
import { normalizeChess, resolveLoadout, loadoutRecord } from '../server/sim/simdata.js';
@@ -29,7 +29,8 @@ test('slots: two per tier (5, 6), each with its elite twin', () => {
assert.deepEqual(DIY_TIERS, [5, 6]);
assert.deepEqual(diySlotIds(DATA), [T5, T5B, T6, T6B]);
assert.deepEqual(diySlot('chess_char_6_diy2_b', DATA) && { ...diySlot('chess_char_6_diy2_b', DATA), normal: undefined, golden: undefined },
{ baseId: T6B, goldenId: 'chess_char_6_diy2_b', tier: 6, elite: true, normal: undefined, golden: undefined });
{ baseId: T6B, goldenId: 'chess_char_6_diy2_b', tier: 6, shopLevel: 6, elite: true, normal: undefined, golden: undefined });
assert.deepEqual([T5, T5B, T6, T6B].map((id) => diySlot(id, DATA).shopLevel), [5, 5, 6, 6], 'shopLevelDisplayDataDict: the 调度中心 level that lists the slot');
assert.equal(diySlot('chess_char_5_01_a', DATA), null, 'not a slot');
assert.equal(diyTokenOwner(SIEGE, chess.chess_char_6_diy1_b.status), 'char_112_siege@2/60/7/3');
});
@@ -102,6 +103,23 @@ test('checkDiyPick: an owned 6★ chooses any of its 3 skills and any module of
for (const [slot, pick, re] of bad) assert.match(checkDiyPick(slot, pick, DATA).error, re, JSON.stringify(pick));
});
test('validateDiyPicks: a roster never carries a 集成战略 module (ISW-A) [ASSUMED, the owner\'s decision of 2026-10-05]; the record still composes it', () => {
const backups = DATA.backups;
let checked = 0;
for (const charId of backups.diy.ownedPool) {
for (const [slot, key] of [[T5, '2/60/7/1'], [T6, '2/60/7/3']]) {
for (const mod of backups.units[charId].forms[key]?.modules ?? []) {
const pick = { charId, skillIndex: 0, uniEquipId: mod.uniEquipId };
assert.ok(checkDiyPick(slot, pick, DATA).ok, `${charId} ${mod.uniEquipId}: a legal record (the sim and the kits field it)`);
const v = validateDiyPicks({ [slot]: pick }, { data: DATA });
if (/^ISW-/.test(mod.typeName)) { assert.match(v.detail || '', /集成战略 module/, `${charId} ${mod.uniEquipId}`); assert.equal(isDiyModule(mod), false); checked++; }
else assert.ok(v.ok, `${charId} ${mod.uniEquipId}: ${v.detail}`);
}
}
}
assert.ok(checked >= 6, 'the ISW-A modules of the pool (凯尔希, 傀影, 菲亚梅塔, 提丰, 艾丽妮, 霍尔海雅) at both stages');
});
test('diyRecord: the slot\'s identity (tier, price, merge, status), no 特质, derived bonds, the operator\'s body at the slot form; module active on the elite only, stage 1 / 3', () => {
const cases = [[T5, false, '2/1/4/0', 0], [T5, true, '2/60/7/1', 1], [T6, false, '2/1/4/0', 0], [T6, true, '2/60/7/3', 3]];
for (const [slotId, elite, key, stage] of cases) {
+1
View File
@@ -56,6 +56,7 @@ export async function startRealServer(opts = {}) {
if (opts.fast.autoPlace) env.SP_AUTO_PLACE = '1';
if (opts.fast.eliminate?.length) env.SP_ELIMINATE = opts.fast.eliminate.join(',');
if (opts.fast.stage) env.SP_STAGE = String(opts.fast.stage);
if (opts.fast.level) env.SP_START_LEVEL = String(opts.fast.level);
}
const child = spawn(process.execPath, [entry], { cwd: ROOT, env, stdio: ['ignore', 'pipe', 'pipe'] });
const logs = [];
+8
View File
@@ -22,6 +22,8 @@
// eliminated spectator from the first round on (test/ui/watch-bonds.e2e.test.js, the Final Assault).
// SP_STAGE=<stageId>: every match is played on this stage instead of the drawn one (test/ui/feedback1-placement.e2e.test.js:
// 战场#08's pool).
// SP_START_LEVEL=<1..6>: the starter kit also puts every human's 调度中心 at this level (and rerolls its shop there, before
// SP_START_SHOP stocks its first slots) — test/ui/diy.e2e.test.js: a tier-5 自选 piece is sold from level 5.
// Not a test file (node --test runs it as a no-op module when NODE_TEST_CONTEXT is set).
import { startServer } from '../../server/index.js';
@@ -46,6 +48,7 @@ if (!process.env.NODE_TEST_CONTEXT) {
const autoPlace = process.env.SP_AUTO_PLACE === '1';
const eliminate = String(process.env.SP_ELIMINATE || '').split(',').map((x) => x.trim()).filter(Boolean).map(Number);
const forcedStage = String(process.env.SP_STAGE || '').trim();
const startLevel = Math.max(0, Math.min(6, Number(process.env.SP_START_LEVEL) || 0));
class FastMatch extends Match {
constructor(opts) {
@@ -152,6 +155,11 @@ if (!process.env.NODE_TEST_CONTEXT) {
if (slot < 0 || !this.gd.item(itemId)) continue;
try { ps.hand[slot] = ps.newPiece('item', itemId); } catch { /* best effort */ }
}
if (startLevel > 0) {
ps.shop.level = Math.min(startLevel, this.gd.maxShopLevel);
ps.shop.upgradePrice = this.gd.upgradeBase(ps.shop.level) ?? 0;
ps.rollShop({ keepFrozen: false });
}
kitShop.forEach((id, i) => {
if (i < ps.shop.slots.length && this.gd.chess(id)) ps.shop.slots[i] = { kind: 'chess', id, basePrice: this.gd.chessPrice(id), frozen: false, sold: false };
});
+32
View File
@@ -0,0 +1,32 @@
// The 自选 golden match covers what it claims (test/golden/README.md, tools/golden.mjs — 0.2.0 自选编队): reads the stored
// digest only (test/golden.test.js recomputes it). Its human seat (AI 托管) slots 推进之王 and prototypes; its shop draws
// its 自选 pieces (and only from the slots' 调度中心 levels), its AI fields 推进之王 — the operator kit — in battle, the
// stock ends where the held copies leave it, and the match ends without errors.
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 MATCHES = read('./golden/matches.json');
const BACKUPS = read('../data/backups.json');
test('matches: the 自选 match\'s human seat draws its 自选 pieces in its shop and fields 推进之王 in battle', () => {
const [id, dg] = Object.entries(MATCHES.scenarios).find(([k]) => /-diy$/.test(k)) || [];
assert.ok(dg, 'scenario present');
assert.deepEqual(dg.errors, { engine: 0, logged: 0, sim: 0, dispatcher: 0 }, id);
assert.deepEqual(Object.keys(dg.diy.picks).sort(), Object.keys(BACKUPS.diy.slots).sort(), 'all four slots filled');
assert.equal(dg.diy.picks.chess_char_5_diy1_a.charId, 'char_112_siege');
const draws = dg.diy.rounds.flatMap(([, d]) => (d ? d.split(' ') : []));
assert.ok(draws.length >= 2, `shop draws: ${draws.join(', ')}`);
for (const d of draws) assert.ok(Object.hasOwn(BACKUPS.diy.slots, `chess_char_${d}`), d);
const fielded = dg.diy.rounds.filter(([, , f]) => f).flatMap(([r, , f]) => f.split(' ').map((x) => `${r}:${x}`));
assert.ok(fielded.some((x) => x.endsWith('5_diy1_a→char_112_siege')), `fielded: ${fielded.join(', ')}`);
for (const [r, , f] of dg.diy.rounds) {
for (const x of f ? f.split(' ') : []) {
const [slot, charId] = x.split('→');
assert.equal(dg.diy.picks[`chess_char_${slot}`].charId, charId, `round ${r}: ${x} fights as its pick`);
}
}
// the stock: never above its cap (8 at tier 5, 5 at tier 6)
for (const [slot, left] of Object.entries(dg.diy.stock)) assert.ok(left >= 0 && left <= (BACKUPS.diy.slots[slot].tier === 5 ? 8 : 5), slot);
});
+3 -3
View File
@@ -17,7 +17,7 @@ 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` | 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`) |
| `matches` | 18 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), 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`) — and one whose human seat slots 自选 picks (推进之王 and prototypes) with its 调度中心 at level 5 from the first prep: its own shop draws them and its AI fields 推进之王 (its digest lists per round the 自选 shop draws and the fielded pieces, `diy`) |
| `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 |
| `diy` | battles of 自选 pieces (a DIY slot with its `diy` pick, `shared/diy.js`): every owned 6★ with an operator kit (`kits/index.js OPERATOR_KITS`) in each form of tiers 5 and 6 (normal; elite with no module and with each module) under each skill, then every prototype pick with a kit at its locked selection, normal and elite, 12 per battle against the round's real wave; a new operator kit adds its scenarios (the battle count grows with the kits) |
@@ -31,8 +31,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, six matches, the five stand-in
battles of the normal records, the first 自选 battle of the operator kits and of the prototypes — 61 of the 150). `GOLDEN_FULL=1` checks everything.
every stage and every non-leader enemy kind, every bond at its top tier, six fields, seven matches, the five stand-in
battles of the normal records, the first 自选 battle of the operator kits and of the prototypes — 62 of the 151). `GOLDEN_FULL=1` checks everything.
## Workflow
+124
View File
@@ -1611,6 +1611,130 @@
[13,"3_12_a→char_608_acpion 6_05_a→char_611_acnipe 6_17_a→char_608_acpion"]
]
}
},
"coop2-NORMAL-70-diy": {
"about": "coop NORMAL, 1 bot seat(s) + 1 human seat (AI 托管) with 4 自选 picks and its 调度中心 at level 5 from the first prep, seed 70",
"fast": true,
"mode": "coop",
"difficulty": "NORMAL",
"seats": 1,
"seed": 70,
"combat": "client",
"boost": null,
"setup": {"stageId":"act1autochess_m03","bossId":"boss_7","hiddenBossId":"boss_10","factions":["ELEMENT","INVISIBLE","REFLECTION"]},
"end": {"victory":true,"roundsPassed":14,"hiddenReached":false,"hiddenCleared":false,"reason":"victory","teamLp":55,"bossPoolLeft":0,"virtualMs":122900},
"rng": {"setup":25,"shop":461,"waves":0,"draft":34,"bots":16433,"meta":20},
"errors": {"engine":0,"logged":0,"sim":0,"dispatcher":0},
"rounds": [
{"round":1,"h_0":[1,27,4,0,0,5,1,"3e4697a0",0,"",5,5,0,3],"ai_0":[1,28,4,0,0,1,2,"a1734bc6",0,"sargonShip 0/2, soloShip 1/0",5,5,0,4]},
{"round":2,"h_0":[1,27,5,0,0,5,3,"ba528c80",0,"",8,8,0,8],"ai_0":[1,28,5,0,0,1,4,"f66f48b3",0,"sargonShip 0/2, soloShip 1/0",8,8,0,9]},
{"round":3,"h_0":[1,27,6,0,0,5,4,"43d0e2bf",0,"",7,7,0,13],"ai_0":[1,28,6,0,0,1,7,"0d31196a",0,"sargonShip 0/2, soloShip 1/0, yanShip 0/1",7,7,0,15]},
{
"round": 4,
"h_0": [1,27,7,0,0,5,6,"2d3d3501",10,"preciShip 1/2, sargonShip 1/8",20,20,0,20],
"ai_0": [1,28,7,0,0,2,8,"bf509782",0,"sargonShip 0/2, soloShip 1/0, yanShip 0/2",20,20,0,22]
},
{
"round": 5,
"h_0": [1,27,8,0,0,5,8,"1a62176b",31,"indomShip 1/0, preciShip 0/2, sargonShip 1/29, steadShip 1/1, victoriaShip 1/1",27,27,0,28],
"ai_0": [1,28,8,0,1,2,8,"e0bfc937",0,"sargonShip 0/2, skillfulShip 1/0, soloShip 1/0, suntShip 1/0, yanShip 0/6",27,27,0,29]
},
{
"round": 6,
"h_0": [1,27,9,0,1,5,8,"7e9969de",84,"arcaneShip 0/2, indomShip 1/4, preciShip 1/15, sargonShip 1/49, steadShip 1/6, victoriaShip 1/6, visiShip 1/4",22,22,0,38],
"ai_0": [1,28,10,0,1,3,8,"ea720e1b",0,"sargonShip 0/4, skillfulShip 1/0, soloShip 1/0, suntShip 1/0, yanShip 0/6",23,24,1,39]
},
{
"round": 7,
"h_0": [1,27,11,0,1,5,8,"f502b39b",103,"arcaneShip 0/2, indomShip 1/4, preciShip 1/21, sargonShip 1/60, steadShip 1/11, victoriaShip 1/7, visiShip 0/4",26,26,0,50],
"ai_0": [1,28,11,0,1,4,8,"7f20e163",0,"sargonShip 0/4, skillfulShip 1/0, soloShip 1/0, suntShip 1/0, yanShip 0/6",28,28,0,49]
},
{
"round": 8,
"h_0": [1,27,12,0,0,5,8,"b6d1cc82",197,"arcaneShip 0/2, indomShip 1/16, preciShip 2/55, sargonShip 1/94, soloShip 1/0, steadShip 2/32, victoriaShip 0/19, visiShip 0/4",18,18,0,63],
"ai_0": [1,28,12,0,1,4,8,"854ef8a8",16,"miraShip 1/0, sargonShip 0/4, skillfulShip 1/3, soloShip 1/0, steadShip 1/0, suntShip 1/0, yanShip 1/13",17,18,1,61]
},
{
"round": 9,
"h_0": [1,27,12,0,2,5,8,"6405e223",267,"arcaneShip 0/2, indomShip 1/16, preciShip 2/81, sargonShip 1/125, steadShip 1/37, victoriaShip 0/19, visiShip 1/8",23,23,0,77],
"ai_0": [1,28,13,0,2,4,8,"1875f830",30,"sargonShip 0/4, siracusaShip 1/8, skillfulShip 1/3, soloShip 1/0, suntShip 1/0, yanShip 1/19",14,23,9,74]
},
{
"round": 10,
"h_0": [1,27,16,0,2,5,8,"c899fa07",312,"arcaneShip 0/2, indomShip 1/16, preciShip 2/97, sargonShip 1/133, soloShip 1/0, steadShip 2/46, suntShip 1/0, victoriaShip 0/19, visiShip 1/20",31,31,0,91],
"ai_0": [1,28,14,0,0,5,8,"0d283e5f",35,"sargonShip 0/4, siracusaShip 1/8, skillfulShip 1/6, soloShip 1/0, suntShip 2/0, yanShip 1/21",31,31,0,87]
},
{
"round": 11,
"h_0": [1,27,14,0,2,5,8,"7c42dc27",357,"arcaneShip 0/2, indomShip 1/16, preciShip 2/109, sargonShip 1/137, skillfulShip 1/8, steadShip 1/51, suntShip 1/0, victoriaShip 0/19, visiShip 1/36",19,19,0,105],
"ai_0": [1,28,12,0,0,5,8,"fe5ac1a4",53,"sargonShip 0/4, siracusaShip 1/12, skillfulShip 1/12, soloShip 1/0, suntShip 2/0, visiShip 1/0, yanShip 1/29",19,19,0,99]
},
{
"round": 12,
"h_0": [1,27,16,0,2,6,8,"3cdb76eb",388,"arcaneShip 0/2, indomShip 2/16, preciShip 2/141, sargonShip 1/179, skillfulShip 0/8, steadShip 0/51, suntShip 1/0, victoriaShip 0/19, visiShip 1/52",33,33,0,122],
"ai_0": [1,28,12,0,0,6,8,"547fcf92",67,"sargonShip 0/8, siracusaShip 1/16, skillfulShip 1/18, soloShip 1/0, suntShip 2/0, yanShip 1/33",15,33,18,112]
},
{
"round": 13,
"h_0": [1,27,16,0,0,6,8,"d0f5bd71",508,"arcaneShip 0/2, indomShip 1/16, preciShip 2/173, sargonShip 1/239, skillfulShip 0/8, steadShip 0/51, suntShip 1/0, swiftShip 1/12, victoriaShip 0/19, visiShip 1/68",50,50,0,138],
"ai_0": [1,28,12,0,0,6,8,"d2ff3200",90,"sargonShip 0/8, siracusaShip 1/24, skillfulShip 1/24, soloShip 1/0, suntShip 2/0, victoriaShip 0/5, yanShip 1/42",31,50,19,125]
}
],
"players": {
"h_0": {
"alive": true,
"victory": true,
"roundsPassed": 14,
"eliminatedRound": null,
"lp": 27,
"bandId": "band_yu",
"title": {"id":"comment_1","name":"卫戍之星","picId":"comment_icon_1","text":"对敌方领袖造成伤害最高(仅胜利时)"},
"trophies": 4,
"reward": 400,
"stats": {"activatedLayers":534,"bossDamage":572474,"dmgDealt":4854054,"gold":154,"itemsEquipped":23,"kills":351,"leaks":0,"lpLost":0,"merges":4,"perfectRounds":13,"refreshes":23},
"lineup": ["5_21_b@12,8[3_07_e_b+1_05_e_a]","5_22_a@11,7","4_20_b@11,8","5_02_a@10,5[5_01_e_a+6_01_e_a]","6_05_a@10,6[2_01_e_a+4_08_e_a]","6_06_a@10,8[1_01_e_a+3_04_e_a]","4_07_b@9,8[5_03_e_a+4_11_e_b]","4_23_b@9,9[4_04_e_a]"],
"bonds": ["arcaneShip 0/2","indomShip 1/16","preciShip 2/173","sargonShip 1/239","skillfulShip 0/8","steadShip 0/51","suntShip 1/0","swiftShip 1/22","victoriaShip 0/19","visiShip 1/84"]
},
"ai_0": {
"alive": true,
"victory": true,
"roundsPassed": 14,
"eliminatedRound": null,
"lp": 28,
"bandId": "band_mlyss",
"title": {"id":"comment_3","name":"坚若磐石","picId":"comment_icon_3","text":"目标生命值损失最少"},
"trophies": 4,
"reward": 400,
"stats": {"activatedLayers":113,"bossDamage":215026,"dmgDealt":2608447,"gold":137,"itemsEquipped":20,"kills":245,"leaks":48,"lpLost":0,"merges":6,"perfectRounds":8,"refreshes":13},
"lineup": ["6_05_a@12,8[3_07_e_a+6_09_e_a]","2_04_b@11,7","1_07_b@11,8","6_11_a@10,6","3_03_b@10,8[3_11_e_b+2_04_e_a]","5_02_a@9,7[1_02_e_b]","1_08_b@9,8[4_06_e_a+1_05_e_a]","3_02_b@9,9[4_10_e_a+4_11_e_a]"],
"bonds": ["maniShip 1/0","preciShip 1/0","sargonShip 1/8","siracusaShip 1/24","skillfulShip 1/30","soloShip 1/0","suntShip 2/0","victoriaShip 0/10","yanShip 1/51"]
}
},
"diy": {
"picks": {
"chess_char_5_diy1_a": {"charId":"char_112_siege","skillIndex":2,"uniEquipId":"uniequip_002_siege"},
"chess_char_5_diy2_a": {"charId":"char_609_acguad","skillIndex":2,"uniEquipId":"uniequip_002_acguad"},
"chess_char_6_diy1_a": {"charId":"char_609_acguad","skillIndex":2,"uniEquipId":"uniequip_002_acguad"},
"chess_char_6_diy2_a": {"charId":"char_617_sharp2","skillIndex":0,"uniEquipId":"uniequip_002_sharp2"}
},
"banned": [],
"rounds": [
[1,"",""],
[2,"",""],
[3,"5_diy1_a","5_diy1_a→char_112_siege"],
[4,"","5_diy1_a→char_112_siege"],
[5,"","5_diy1_a→char_112_siege"],
[6,"","5_diy1_a→char_112_siege"],
[7,"5_diy2_a","5_diy1_a→char_112_siege"],
[8,"",""],
[9,"",""],
[10,"",""],
[11,"",""],
[12,"",""],
[13,"",""]
],
"stock": {"chess_char_5_diy1_a":8,"chess_char_5_diy2_a":8,"chess_char_6_diy1_a":5,"chess_char_6_diy2_a":5}
}
}
}
}
+1
View File
@@ -117,6 +117,7 @@ test('public/i18n/en.json: valid, English values, placeholders kept, the pilot s
test('every msgid of the pilot screens, the shell and the server messages is in public/i18n/en.json', async () => {
const files = ['public/js/screens/title.js', 'public/js/screens/lobby.js', 'public/js/screens/room.js', 'public/js/ui/settings.js',
'public/js/screens/diy.js',
'public/js/main.js', 'public/js/ui/toasts.js', 'server/match/match/messaging.js', 'server/match/player/placement.js',
'server/match/player/basics.js', 'server/match/player/acquire.js', 'server/match/effectsMeta.js', 'server/match/match/settle.js',
'server/match/match/platform.js', 'server/match/match/unitePhase.js', 'server/match/match/bossRounds.js',
+156
View File
@@ -0,0 +1,156 @@
// test/lobby-diy.test.js — room.diy end to end over WebSocket (0.2.0 自选编队): lenient validation (only malformed picks
// are refused; illegal ones are dropped), storage on the session (follows the player into rooms, survives a resume) and
// on the seat, seats[].diy handed to the Match when it starts (bots: none), never to a running match (ROOM_STARTED:
// stored for the next one — the next match takes it), every `welcome` carrying `diyKitted`, and the real match exposing
// the picks as m.private.diy.
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 { KITTED_CHARS } from '../server/sim/content/kits/index.js';
import { TestClient } from './helpers/wsClient.js';
import { ERR, PHASE } from '../shared/constants.js';
const T5A = 'chess_char_5_diy1_a';
const T6A = 'chess_char_6_diy1_a';
const T6B = 'chess_char_6_diy2_a';
const SIEGE = { charId: 'char_112_siege', skillIndex: 2, uniEquipId: 'uniequip_002_siege' };
const SHARP = { charId: 'char_609_acguad' };
const SHARP_FULL = { charId: 'char_609_acguad', skillIndex: 2, uniEquipId: 'uniequip_002_acguad' };
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;
c.welcome = w;
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.diy (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('welcome lists the operators a 自选 slot may field (the kit registry)', async () => {
const c = await pool.player('Kit');
assert.deepEqual(c.welcome.diyKitted, [...KITTED_CHARS]);
assert.ok(c.welcome.diyKitted.includes(SIEGE.charId));
});
test('lenient: malformed picks are BAD_MSG; illegal ones dropped; stored per session and seat; bots none; the next match takes a change', async () => {
const host = await pool.player('Host');
await err(host, { t: 'room.diy', picks: [SIEGE] }, ERR.BAD_MSG);
await err(host, { t: 'room.diy', picks: { [T5A]: { skillIndex: 1 } } }, ERR.BAD_MSG);
// outside a room: accepted (a prototype off its locked skill, a second slot of the owned operator, junk dropped)
await ok(host, { t: 'room.diy', picks: { [T5A]: SIEGE, [T6A]: { charId: 'char_609_acguad', skillIndex: 0 }, [T6B]: { charId: SIEGE.charId, skillIndex: 1 }, nope: SHARP } });
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.diy', picks: { [T6A]: SHARP } });
await ok(guest, { t: 'room.diy', picks: { [T6A]: SHARP, [T5A]: SHARP } }); // re-sent: the latest wins
await ok(host, { t: 'room.addBot' });
// the guest drops and resumes: the session keeps its picks
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');
let seats = RecordingStub.instances.at(-1).opts.seats;
const by = (id) => seats.find((s) => s.playerId === id);
assert.deepEqual(by(host.id).diy, { [T5A]: SIEGE });
assert.ok(Object.isFrozen(by(host.id).diy) && Object.isFrozen(by(host.id).diy[T5A]));
assert.deepEqual(by(guest.id).diy, { [T5A]: SHARP_FULL, [T6A]: SHARP_FULL }, 'a prototype may fill a tier-5 and a tier-6 slot');
assert.equal(seats.find((s) => s.isBot).diy, null, 'bots field no 自选 piece');
// during the match: refused for this match (ROOM_STARTED) but stored for the next one
const r = await err(host, { t: 'room.diy', picks: { [T6B]: SIEGE } }, ERR.ROOM_STARTED);
assert.match(r.detail || '', /next match/);
// the stub ends once every human confirmed the briefing; the room is back in its lobby — the next match takes it
await ok(host, { t: 'g.infoReady' });
await ok(back, { t: 'g.infoReady' });
await host.waitFor('room.state', (s) => !s.inMatch, 5000);
await ok(back, { t: 'room.ready', ready: true });
await ok(host, { t: 'room.start' });
await host.waitFor('m.public', (p) => p.phase === 'INFO_CHECK');
assert.equal(RecordingStub.instances.length, 2);
seats = RecordingStub.instances.at(-1).opts.seats;
assert.deepEqual(by(host.id).diy, { [T6B]: SIEGE });
await back.terminate();
assert.deepEqual(cap.errors, []);
});
test('a spectator\'s picks stay 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.diy', picks: { [T5A]: SIEGE } });
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].diy, null);
});
});
describe('room.diy (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 picks the seat had at the start are the match\'s m.private.diy; a change during the briefing waits for the next match', async () => {
const a = await pool.player('Solo');
await ok(a, { t: 'room.diy', picks: { [T5A]: SIEGE, [T6A]: SHARP } });
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) => p.diy && typeof p.diy === 'object', 3000);
assert.deepEqual(priv.diy, { [T5A]: SIEGE, [T6A]: SHARP_FULL });
assert.deepEqual(priv.diyBanned, []);
await err(a, { t: 'room.diy', picks: {} }, 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, []);
});
});
+125
View File
@@ -0,0 +1,125 @@
// test/match/diy-bonds.test.js — 0.2.0 自选编队: a 自选 piece is a member of each of its derived bonds (data/backups.json
// diy.operators[charId].bonds — its factions, else 协防; "甄选加入的干员会根据其实际阵营所属分配核心盟约…一个或数个", PRTS 卫戍协议:
// 盟约 下半/PRTS盟约记录) in every place membership is decided: the match's bond counts (bondsMeta through the player's
// data view — BOARD / BOARD_AND_DECK distinct pieces: the normal and elite copies of one slot count once; 煌 counts for
// 炎 and 维多利亚; 绝技 counts a 自选 elite like any elite), the views (m.private / m.public bonds), the battle input's
// bond snapshot, and the sim's bond effects (they read the unit's def.bonds: 叙拉古 on deployment, 协防 ×1.2 / elite ×1.4).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { KITTED_CHARS } from '../../server/sim/content/kits/index.js';
import { hasGeneratedData } from '../../server/sim/simdata.js';
import { makeBattle, checkInvariants as checkBattle } from '../helpers/battleHarness.js';
import { DATA, makeMatch, legalTileFor } from './harness.js';
const REAL = { skip: !hasGeneratedData() };
const T5A = 'chess_char_5_diy1_a';
const T5B = 'chess_char_5_diy2_a';
const T6A = 'chess_char_6_diy1_a';
const HUANG = 'char_017_huang'; // 煌: 炎 + 维多利亚 (both from her 隐藏势力)
const CROSLY = 'char_1502_crosly'; // 弑君者: 叙拉古
const SHARP = 'char_609_acguad'; // a prototype: 协防
const OPS = DATA.backups?.diy?.operators ?? {};
function soloMatch() {
const seats = [{ seat: 0, playerId: 'p_0', name: 'P0', isBot: false, connected: true }];
const h = makeMatch({ mode: 'solo', seats, seed: 21 }).start();
h.toPrep(1);
return h;
}
/** Slot `picks` for p_0 now (kit list widened for operators whose kit is still being written) and give it stock. */
function slot(h, picks) {
const p0 = h.ps('p_0');
assert.equal(p0.setDiy(picks, { kitted: [...KITTED_CHARS, HUANG, CROSLY] }), true);
p0.initDiyStock(new Set());
p0.recompute();
return p0;
}
function deploy(h, ps, id) {
const p = ps.acquireChess(id, { source: 'buy' });
const t = legalTileFor(h.m, ps, p.id);
assert.ok(t, `a tile for ${id}`);
assert.deepEqual(h.m.handle(ps.playerId, { t: 'g.move', uid: p.uid, to: { area: 'board', row: t[0], col: t[1] } }), { ok: true });
return p;
}
test('the derived bonds of the data: each owned pick its core bonds or 协防, 煌 alone two, every prototype 协防 (read from the data)', REAL, () => {
const owned = DATA.backups.diy.ownedPool; // never hard-coded: the pool changes (焰狐龙梓兰 left it as a collab operator)
const core = new Set(Object.entries(DATA.bonds).filter(([, b]) => b && b.isCore).map(([id]) => id));
for (const id of owned) {
const b = OPS[id].bonds;
assert.ok(b.length >= 1, id);
assert.ok((b.length === 1 && b[0] === 'emptyShip') || b.every((x) => core.has(x)), `${id}: ${b.join(', ')}`);
}
assert.deepEqual(OPS[HUANG].bonds, ['yanShip', 'victoriaShip']);
assert.deepEqual(owned.filter((id) => OPS[id].bonds.length > 1), [HUANG], '煌 alone has several');
assert.deepEqual(OPS.char_300_phenxi.bonds, ['lateranoShip']);
assert.deepEqual(OPS.char_4098_vvana.bonds, ['kazimierzShip']);
assert.deepEqual(OPS[CROSLY].bonds, ['siracusaShip']);
for (const id of [...DATA.backups.diy.prototypes[5], ...DATA.backups.diy.prototypes[6]]) assert.deepEqual(OPS[id].bonds, ['emptyShip'], id);
});
test('煌 counts for 炎 and 维多利亚; its normal and elite copies count once; views and the battle input agree', REAL, () => {
const h = soloMatch();
const p0 = slot(h, { [T5A]: { charId: HUANG, skillIndex: 0 } });
const before = { yan: p0.bonds.yanShip?.count ?? 0, vic: p0.bonds.victoriaShip?.count ?? 0 };
deploy(h, p0, T5A);
assert.equal(p0.bonds.yanShip.count, before.yan + 1, '炎');
assert.equal(p0.bonds.victoriaShip.count, before.vic + 1, '维多利亚');
// a second copy in the hand: still one distinct member (BOARD counts the slot once)
p0.acquireChess(T5A, { source: 'buy' });
assert.equal(p0.bonds.yanShip.count, before.yan + 1);
h.m.flush(true);
const priv = h.lastTo('p_0', 'm.private');
assert.equal(priv.bonds.find((b) => b.bondId === 'yanShip').count, before.yan + 1);
assert.equal(priv.bonds.find((b) => b.bondId === 'victoriaShip').count, before.vic + 1);
const pub = h.lastBc('m.public');
assert.equal(pub.players[0].bonds.find((b) => b.bondId === 'victoriaShip').count, before.vic + 1, 'm.public (a teammate\'s strip)');
const input = p0.battleInput();
assert.equal(input.bonds.yanShip.count, before.yan + 1, 'the battle input\'s bond snapshot');
h.invariants();
h.m.dispose();
});
test('协防: two 自选 prototypes (a tier-5 and a tier-6 slot) enable it; a preset 协防 chess with one too; 绝技 counts 自选 elites', REAL, () => {
const h = soloMatch();
const p0 = slot(h, { [T5B]: { charId: SHARP }, [T6A]: { charId: SHARP } });
assert.ok(!p0.bonds.emptyShip?.active);
deploy(h, p0, T5B);
assert.equal(p0.bonds.emptyShip.count, 1);
deploy(h, p0, T6A);
assert.equal(p0.bonds.emptyShip.count, 2, 'two slots = two chess (like any operator who is two chess) [ASSUMED]');
assert.equal(p0.bonds.emptyShip.active, true);
// 绝技 (BOARD_ALL_CHESS): every elite on the board — a 自选 elite included
const sunt0 = p0.bonds.suntShip?.count ?? 0;
deploy(h, p0, 'chess_char_5_diy2_b');
assert.equal(p0.bonds.suntShip.count, sunt0 + 1);
h.invariants();
h.m.dispose();
});
test('sim: a 叙拉古 自选 piece gets 叙拉古\'s deployment buff; a 协防 one the ×1.2 damage (elite ×1.4)', REAL, () => {
const bonds = { siracusaShip: { count: 3, active: true, tier: 1, layers: 10 }, emptyShip: { count: 2, active: true, tier: 1, layers: 0 } };
const h = makeBattle({
units: [
{ diy: { slot: 5, charId: CROSLY, skillIndex: 0 }, row: 10, col: 4 },
{ diy: { slot: T5B, charId: SHARP }, row: 10, col: 6 },
{ diy: { slot: 6, charId: SHARP }, elite: true, row: 11, col: 6 },
],
bonds,
timeLimit: 10,
});
h.run(1);
const crosly = h.allies().find((u) => u.def?.charId === CROSLY);
const [normal, elite] = [h.allies().find((u) => u.def?.charId === SHARP && !u.def.golden), h.allies().find((u) => u.def?.charId === SHARP && u.def.golden)];
assert.ok(crosly && normal && elite, 'the three 自选 pieces are fielded');
assert.deepEqual(crosly.def.bonds, ['siracusaShip']);
assert.ok(crosly.hasBuff('bond:siracusa'), '叙拉古: ASPD on deployment');
assert.ok(!normal.hasBuff('bond:siracusa'), 'not for a non-member');
const scale = (u) => u.buffs.filter((b) => /empty/i.test(b.key)).map((b) => b.mods?.dmgDealtMul).find((x) => x != null);
const bb = DATA.bonds.emptyShip;
assert.ok(bb);
assert.equal(scale(normal), 1.2, '协防 member ×1.2');
assert.equal(scale(elite), 1.4, '协防 elite member ×1.4');
assert.equal(scale(crosly), undefined, '弑君者 is no 协防 member');
checkBattle(h.b);
});
+407
View File
@@ -0,0 +1,407 @@
// test/match/diy-shop.test.js — 0.2.0 自选编队 on the server (research 0.2.0 §2; the owner's decisions of 2026-10-05):
// the picks (room.diy → seats[].diy) are checked leniently, fixed for the match and applied to that player only — the
// player's data view makes a slotted slot the operator (name, class, bonds from its factions, no 特质, the pick's skill
// and module; the slot's tier, price and merge), each slotted piece has its own stock (8 at tier 5, 5 at tier 6), never
// enters the shared pool, joins only its player's shop once the 调度中心 reaches the slot's level, leaves the shop when
// every bond of it is banned, merges 3 → elite, and fields its operator in battle (PlayerBattleInput `diy`), client-run
// and server-run identical; prep scouting and the result name the operator.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { C2S, validateC2S, checkDiyPicks, DIY_LIMITS } from '../../shared/protocol.js';
import { diyRecord } from '../../shared/diy.js';
import { KITTED_CHARS } from '../../server/sim/content/kits/index.js';
import { buildBattleSpec, createBattleFromSpec } from '../../server/sim/spec.js';
import { DataSource, hasGeneratedData } from '../../server/sim/simdata.js';
import { placeClass } from '../../server/match/board.js';
import { buildResult } from '../../server/match/results.js';
import { GEO } from '../../shared/constants.js';
import { DATA, makeMatch, legalTileFor } from './harness.js';
const REAL = { skip: !hasGeneratedData() };
const wire = (msg) => JSON.parse(JSON.stringify(msg));
const T5A = 'chess_char_5_diy1_a';
const T5B = 'chess_char_5_diy2_a';
const T6A = 'chess_char_6_diy1_a';
const T6B = 'chess_char_6_diy2_a';
const SIEGE = 'char_112_siege'; // 推进之王 (owned 6★, the first operator kit; 维多利亚)
const SHARP = 'char_609_acguad'; // Sharp (6★ prototype: S3 + SOL-X at both tiers, 协防)
const CGUARD = 'char_601_cguard'; // 预备干员-近卫 (4★ reserve: tier 5 only)
const SIEGE_PICK = { charId: SIEGE, skillIndex: 2, uniEquipId: 'uniequip_002_siege' };
const PICKS = { [T5A]: SIEGE_PICK, [T5B]: { charId: SHARP }, [T6A]: { charId: SHARP } };
/** An owned 6★ without a kit (not offered while it has none). */
const UNKITTED = (DATA.backups?.diy?.ownedPool || []).find((id) => !KITTED_CHARS.includes(id));
test('room.diy: structural wire check; checkDiyPicks keeps the legal picks, drops the rest (lenient), BAD_MSG only for malformed input', () => {
assert.ok(C2S['room.diy']);
assert.equal(validateC2S({ t: 'room.diy', picks: {} }), null);
assert.equal(validateC2S({ t: 'room.diy', picks: { [T5A]: SIEGE_PICK, [T6A]: null } }), null);
for (const bad of [[], 'x', { [T5A]: { skillIndex: 1 } }, { [T5A]: { charId: 'a b' } }, { [T5A]: { charId: SIEGE, skillIndex: 12 } }, { [T5A]: 3 }]) {
assert.match(validateC2S({ t: 'room.diy', picks: bad }) || '', /bad field picks/, JSON.stringify(bad));
assert.equal(checkDiyPicks(bad, { data: DATA, kitted: KITTED_CHARS }).error, 'BAD_MSG');
}
const many = Object.fromEntries(Array.from({ length: DIY_LIMITS.slots + 1 }, (_, i) => [`slot_${i}`, null]));
assert.match(validateC2S({ t: 'room.diy', picks: many }) || '', /bad field picks/);
const res = checkDiyPicks({
[T5A]: SIEGE_PICK,
[T5B]: { charId: SHARP, skillIndex: 0 }, // a prototype off its locked skill: dropped
[T6A]: { charId: SHARP }, // the same prototype at the other tier: fine
[T6B]: { charId: SIEGE, skillIndex: 0 }, // an owned operator in a second slot: dropped
chess_char_4_99_a: { charId: SIEGE, skillIndex: 0 }, // not a slot: dropped
}, { data: DATA, kitted: KITTED_CHARS });
assert.deepEqual(res, {
ok: true, dropped: 3,
picks: { [T5A]: SIEGE_PICK, [T6A]: { charId: SHARP, skillIndex: 2, uniEquipId: 'uniequip_002_acguad' } },
});
// a 4★ reserve is a tier-5 pick only; an operator without a kit is never offered
const r2 = checkDiyPicks({ [T5A]: { charId: CGUARD }, [T6A]: { charId: CGUARD }, [T6B]: UNKITTED ? { charId: UNKITTED, skillIndex: 0 } : null }, { data: DATA, kitted: KITTED_CHARS });
assert.deepEqual(Object.keys(r2.picks), [T5A]);
assert.equal(r2.picks[T5A].skillIndex, 2, 'the reserve\'s locked S3');
// one operator twice in a tier: the first slot keeps it
const r3 = checkDiyPicks({ [T5B]: { charId: SHARP }, [T5A]: { charId: SHARP } }, { data: DATA, kitted: KITTED_CHARS });
assert.deepEqual(Object.keys(r3.picks), [T5A]);
assert.equal(r3.dropped, 1);
});
/** A co-op match: p_0 slots 推进之王 + Sharp (×2 tiers) + junk, p_1 slots 推进之王 in the same slot, the bot nothing. */
function diyMatch(extra = {}) {
const seats = [
{ seat: 0, playerId: 'p_0', name: 'P0', isBot: false, connected: true, diy: { ...PICKS, [T6B]: { charId: SIEGE, skillIndex: 1 }, junk: null } },
{ seat: 1, playerId: 'p_1', name: 'P1', isBot: false, connected: true, diy: { [T5A]: { charId: SIEGE, skillIndex: 0, uniEquipId: null } } },
{ seat: 2, playerId: 'ai_0', name: 'AI0', isBot: true, connected: true, diy: PICKS },
];
return makeMatch({ mode: 'coop', seats, seed: 11, ...extra });
}
/** Gain a chess the way the shop does (acquireChess: the player's copy accounting, merges). */
const gain = (ps, id) => ps.acquireChess(id, { source: 'buy' });
function clear(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[].diy → PlayerState.diy (re-checked, frozen; bots none); the player\'s data view is the operator, everyone else sees the slot', REAL, () => {
const h = diyMatch().start();
const p0 = h.ps('p_0');
const p1 = h.ps('p_1');
const bot = h.ps('ai_0');
assert.deepEqual(Object.keys(p0.diy), [T5A, T5B, T6A], 'the owned operator\'s second slot dropped');
assert.ok(Object.isFrozen(p0.diy) && Object.isFrozen(p0.diy[T5A]));
assert.deepEqual(bot.diy, {}, 'bots field no 自选 piece');
assert.equal(bot.gd, h.m.gd, 'a player without picks keeps the match\'s data');
for (const [id, elite] of [[T5A, false], ['chess_char_5_diy1_b', true]]) {
const rec = p0.gd.chess(id);
const want = diyRecord(T5A, SIEGE_PICK, { elite, data: DATA });
assert.deepEqual([rec.name, rec.charId, rec.diyFor, rec.bonds, rec.garrisonIds, rec.tier, rec.price, rec.sellPrice], [want.name, SIEGE, T5A, ['victoriaShip'], [], 5, 4, 1]);
assert.equal(rec.skill.skillId, 'skchr_siege_3');
assert.deepEqual(p0.loadoutFor(h.m.gd.chess(id)), { skillIndex: 2, moduleId: elite ? 'uniequip_002_siege' : null }, 'the pick, never the loadout');
assert.equal(h.m.gd.chess(id).name, DATA.chess[id].name, 'the match\'s own record is the slot');
}
const s1 = DATA.backups.units[SIEGE].forms['2/1/4/0'].skills.find((x) => x.index === 0).skillId;
assert.equal(p1.gd.chess(T5A).skill.skillId, s1, 'p_1\'s own pick of the same slot (S1)');
assert.equal(placeClass(p0, h.m.gd.chess(T5A)), 'melee');
assert.equal(p0.gd.chess(T5B).charId, SHARP);
h.m.flush(true);
const priv = h.lastTo('p_0', 'm.private');
assert.deepEqual(priv.diy, wire(p0.diy));
assert.deepEqual(priv.diyBanned, []);
assert.deepEqual(h.lastTo('p_1', 'm.private').diy, { [T5A]: { charId: SIEGE, skillIndex: 0, uniEquipId: null } });
// a malformed list (stale client) changes nothing
assert.equal(p0.setDiy([]), false);
assert.equal(Object.keys(p0.diy).length, 3);
h.m.dispose();
});
test('per-player stock: 8 at tier 5, 5 at tier 6, never in the shared pool; only its player\'s shop, from the slot\'s 调度中心 level', REAL, () => {
const h = diyMatch().start();
const m = h.m;
h.toPrep(1);
const p0 = h.ps('p_0');
const p1 = h.ps('p_1');
assert.deepEqual(p0.diyStock.snapshot(), { [T5A]: 8, [T5B]: 8, [T6A]: 5 });
assert.deepEqual(p1.diyStock.snapshot(), { [T5A]: 8 }, 'a stock of its own for the same slot');
for (const id of [T5A, T5B, T6A, T6B]) assert.ok(!m.pool.has(id), 'never in the shared pool');
const seen = (ps, level, n = 300) => {
ps.shop.level = level;
const ids = new Set();
for (let i = 0; i < n; i++) { ps.rollShop(); for (const s of ps.shop.slots) if (s && s.kind === 'chess' && /_diy/.test(s.id)) ids.add(s.id); }
return [...ids].sort();
};
assert.deepEqual(seen(p0, 4), [], 'level 4: no tier-5 slot yet');
assert.deepEqual(seen(p0, 5), [T5A, T5B], 'level 5: the tier-5 slots');
assert.deepEqual(seen(p0, 6, 600), [T5A, T5B, T6A], 'level 6: both tiers');
assert.deepEqual(seen(h.ps('ai_0'), 6), [], 'the bot\'s shop never sells another player\'s 自选 piece');
// the price of any chess of the tier
const slot = p0.shop.slots.find((s) => s && s.id === T6A) || { kind: 'chess', id: T6A, basePrice: m.gd.chessPrice(T6A), frozen: false, sold: false };
assert.equal(slot.basePrice, 4);
h.invariants();
m.dispose();
});
test('buy → 3 copies merge into the elite (the stock pays the copies); sell / elimination give them back; a reward offer may hold it', REAL, () => {
const h = diyMatch().start();
const m = h.m;
h.toPrep(1);
const p0 = h.ps('p_0');
clear(p0);
p0.shop.level = 5;
p0.funds = 40;
// three purchases through g.buy (a shop slot stocked with the DIY slot)
for (let i = 0; i < 3; i++) {
p0.shop.slots[0] = { kind: 'chess', id: T5A, basePrice: m.gd.chessPrice(T5A), frozen: false, sold: false };
assert.deepEqual(m.handle('p_0', { t: 'g.buy', slot: 0 }), { ok: true });
}
const elite = p0.allChess().find((p) => p.id === 'chess_char_5_diy1_b');
assert.ok(elite, 'the elite');
assert.equal(p0.allChess().filter((p) => p.id === T5A).length, 0);
assert.equal(elite.poolCopies, 3);
assert.equal(p0.diyStock.left(T5A), 5);
assert.ok(p0.offers.length >= 1, 'the merge queued a reward offer');
assert.equal(p0.gd.chess(elite.id).module.id, 'uniequip_002_siege');
// a slot the player has not filled is no piece: another player's slot cannot be bought or granted
p0.shop.slots[1] = { kind: 'chess', id: T6B, basePrice: 4, frozen: false, sold: false };
assert.equal(m.handle('p_0', { t: 'g.buy', slot: 1 }).error, 'BAD_TARGET');
assert.equal(p0.acquireChess(T6B), null);
p0.shop.slots[1] = null;
// sell returns the copies to the player's stock
assert.deepEqual(m.handle('p_0', { t: 'g.sell', uid: elite.uid }), { ok: true });
assert.equal(p0.diyStock.left(T5A), 8);
// the stock runs out like a pool entry
for (let i = 0; i < 8; i++) gain(p0, T5A);
assert.equal(p0.diyStock.left(T5A), 0);
p0.shop.slots[0] = { kind: 'chess', id: T5A, basePrice: 4, frozen: false, sold: false };
assert.equal(m.handle('p_0', { t: 'g.buy', slot: 0 }).error, 'SOLD_OUT');
h.invariants();
// reward offers (the temporary refresh) draw it once the 调度中心 is at the slot's level
clear(p0);
p0.offers = [];
let hit = 0;
p0.shop.level = 4;
for (let i = 0; i < 200; i++) { const o = p0.pushRewardOffer('merge'); if (o && o.slots.some((s) => /_diy/.test(s.id))) hit++; p0.offers = []; }
assert.equal(hit, 0, 'a level-4 promotion reward (tier 5) never holds a tier-5 自选 piece');
p0.shop.level = 6;
for (let i = 0; i < 300; i++) { const o = p0.pushRewardOffer('merge'); if (o && o.slots.some((s) => s.id === T6A)) hit++; p0.offers = []; }
assert.ok(hit > 0, 'a level-6 reward can hold the tier-6 piece');
// elimination returns everything
gain(p0, T6A);
p0.eliminate(m.round);
assert.deepEqual(p0.diyStock.snapshot(), { [T5A]: 8, [T5B]: 8, [T6A]: 5 });
m.dispose();
});
test('bans: a 自选 piece whose every bond is off this match has no stock (out of the shop); 协防 is never banned', REAL, () => {
// a seed whose drawn bans include 维多利亚 (推进之王's only bond)
let h = null;
for (let seed = 1; seed < 400 && !h; seed++) {
const t = diyMatch({ seed });
if (t.m.disabledBonds.includes('victoriaShip')) h = t; else t.m.dispose();
}
assert.ok(h, 'a seed banning 维多利亚');
h.start();
const p0 = h.ps('p_0');
assert.deepEqual([...p0.diyBanned], [T5A]);
assert.ok(!p0.diyStock.has(T5A));
assert.deepEqual(Object.keys(p0.diyStock.snapshot()).sort(), [T5B, T6A], 'the prototypes (协防) stay');
assert.ok(!h.m.disabledBonds.includes('emptyShip'));
h.toPrep(1);
p0.shop.level = 5;
for (let i = 0; i < 300; i++) { p0.rollShop(); assert.ok(!p0.shop.slots.some((s) => s && s.id === T5A)); }
h.m.flush(true);
assert.deepEqual(h.lastTo('p_0', 'm.private').diyBanned, [T5A]);
h.invariants();
h.m.dispose();
});
test('bonds, placement and summons read the operator; the record\'s identity (tier, price, merge) is the slot\'s', REAL, () => {
const h = diyMatch().start();
const m = h.m;
h.toPrep(1);
const p0 = h.ps('p_0');
clear(p0);
const a = gain(p0, T5A);
const tile = legalTileFor(m, p0, T5A);
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: a.uid, to: { area: 'board', row: tile[0], col: tile[1] } }), { ok: true });
assert.ok(p0.bonds.victoriaShip && p0.bonds.victoriaShip.count >= 1, '推进之王 counts for 维多利亚');
const b = gain(p0, T5B);
const t2 = legalTileFor(m, p0, T5B);
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: b.uid, to: { area: 'board', row: t2[0], col: t2[1] } }), { ok: true });
assert.ok(p0.bonds.emptyShip && p0.bonds.emptyShip.count >= 1, 'a prototype counts for 协防');
assert.equal(m.gd.mergeCount(T5A), 3);
assert.deepEqual(p0.gd.placeableTokens(T5A, p0.loadoutFor(p0.gd.chess(T5A))), [], '推进之王 makes no summon');
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: a 自选 piece carries its pick (`diy`, no loadout fields); the sim fields the operator, client-run and server-run alike', REAL, () => {
const h = diyMatch().start();
const m = h.m;
h.toPrep(1);
const p0 = h.ps('p_0');
clear(p0);
const a = gain(p0, T5A);
for (let i = 0; i < 2; i++) gain(p0, T5A); // → the elite
const elite = p0.allChess().find((p) => p.id === 'chess_char_5_diy1_b');
const tile = legalTileFor(m, p0, elite.id);
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: elite.uid, to: { area: 'board', row: tile[0], col: tile[1] } }), { ok: true });
const s = gain(p0, T6A);
const t2 = legalTileFor(m, p0, T6A);
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: s.uid, to: { area: 'board', row: t2[0], col: t2[1] } }), { ok: true });
assert.ok(a);
const input = p0.battleInput();
const by = new Map(input.units.map((u) => [u.uid, u]));
assert.deepEqual(by.get(elite.uid).diy, SIEGE_PICK);
assert.ok(!('skillIndex' in by.get(elite.uid)) && !('moduleId' in by.get(elite.uid)) && !('standIn' in by.get(elite.uid)));
assert.deepEqual(by.get(s.uid).diy, { charId: SHARP, skillIndex: 2, uniEquipId: 'uniequip_002_acguad' });
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((x) => ({ ...x, ownerPlayerId: 'p_0' })),
routes: wave.routes, flags: { layerGainsEnabled: true, ...m.gd.dp }, enemyOverrides: wave.overrides ?? {}, content: 'full',
}));
assert.deepEqual(spec.players[0].units.find((u) => u.uid === elite.uid).diy, SIEGE_PICK);
const run = (ds) => {
const bt = createBattleFromSpec(wire(spec), ds, { quiet: true, recordEvents: false });
const u = bt.allyUnits.find((x) => x.uid === elite.uid);
const def = { charId: u.def.charId, diyFor: u.def.diyFor, skill: u.def.skill?.id, bonds: u.def.bonds, name: u.name, generic: !!u.kit?.generic };
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.deepEqual(server.def, { charId: SIEGE, diyFor: T5A, skill: 'skchr_siege_3', bonds: ['victoriaShip'], name: DATA.backups.units[SIEGE].name, generic: false });
assert.deepEqual(client, server, 'the browser\'s battle equals the server\'s');
m.dispose();
});
test('prep scouting (m.field) and the result name the operator: a teammate\'s 自选 piece carries its pick and the operator\'s art', REAL, () => {
const h = diyMatch().start();
const m = h.m;
h.toPrep(1);
const p0 = h.ps('p_0');
clear(p0);
const a = gain(p0, T5A);
const tile = legalTileFor(m, p0, T5A);
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: a.uid, to: { area: 'board', row: tile[0], col: tile[1] } }), { ok: true });
const held = gain(p0, T6A);
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 u = by.get(a.uid);
// (the pick as slotted: its module applies on the elite only — the normal piece fights without one)
assert.deepEqual([u.defId, u.name, u.spine, u.skillIndex, u.moduleId, u.diy], [T5A, DATA.backups.units[SIEGE].name, SIEGE, 2, undefined, SIEGE_PICK]);
const hu = by.get(held.uid);
assert.deepEqual([hu.defId, hu.name, hu.diy?.charId], [T6A, DATA.backups.units[SHARP].name, SHARP]);
const res = buildResult(m, { victory: false, hiddenReached: false, hiddenCleared: false, reason: 'test' });
const row = res.players.find((p) => p.playerId === 'p_0');
assert.deepEqual(row.lineup.find((x) => x.id === T5A).diy, SIEGE_PICK);
m.dispose();
});
test('a match keeps the picks its seat had at its start (the next match takes a change: test/lobby-diy.test.js)', REAL, () => {
const h = diyMatch().start();
const p1 = h.ps('p_1');
const before = p1.diy;
h.toPrep(1);
assert.equal(p1.diy, before);
assert.equal(typeof h.m.setDiy, 'undefined', 'the match has no way to change them');
h.m.dispose();
// the next match: new seats, new picks
const seats = [{ seat: 0, playerId: 'p_1', name: 'P1', isBot: false, connected: true, diy: { [T6A]: { charId: SIEGE, skillIndex: 1 } } }];
const h2 = makeMatch({ mode: 'solo', seats, seed: 12 }).start();
assert.deepEqual(h2.ps('p_1').diy, { [T6A]: { charId: SIEGE, skillIndex: 1, uniEquipId: null } });
h2.m.dispose();
});
test('a placeable 自选 summon comes to the hand like any operator\'s, places, and fights as its owner\'s variant', REAL, () => {
const h = diyMatch().start();
const m = h.m;
h.toPrep(1);
const p0 = h.ps('p_0');
// 凯尔希 has no kit yet: the kit list is widened for this test (her Mon3tr is a placeable talent summon)
assert.equal(p0.setDiy({ [T6B]: { charId: 'char_003_kalts', skillIndex: 0, uniEquipId: null } }, { kitted: [...KITTED_CHARS, 'char_003_kalts'] }), true);
p0.initDiyStock(new Set());
clear(p0);
const k = gain(p0, T6B);
const tile = legalTileFor(m, p0, T6B);
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: k.uid, to: { area: 'board', row: tile[0], col: tile[1] } }), { ok: true });
const stack = p0.hand.find((p) => p && p.kind === 'token');
assert.ok(stack, 'the Mon3tr stack is in the hand');
assert.equal(stack.id, 'token_10002_kalts_mon3tr');
assert.equal(stack.ownerUid, k.uid);
assert.equal(p0.gd.token(stack.id).name, 'Mon3tr', 'the player\'s data view finds the 自选 summon');
const t2 = legalTileFor(m, p0, T6B, new Set([`${tile[0]},${tile[1]}`]));
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: stack.uid, to: { area: 'board', row: t2[0], col: t2[1] } }), { ok: true });
const input = p0.battleInput();
const tok = input.units.find((u) => u.kind === 'token');
assert.deepEqual([tok.tokenId, tok.ownerUid], ['token_10002_kalts_mon3tr', k.uid]);
const spec = wire(buildBattleSpec({
battleId: 't.2', fieldId: 'n:p_0', kind: 'normal', seed: 5, modeId: m.modeId, round: 1, stageId: m.stageId,
rect: { ...GEO.NORMAL_RECT }, timeLimit: 20, players: [input], spawns: [], routes: m.wave.routes,
flags: { layerGainsEnabled: true, ...m.gd.dp }, enemyOverrides: {}, content: 'full',
}));
const bt = createBattleFromSpec(spec, m.ds, { quiet: true, recordEvents: false });
bt.step();
const mon = bt.allyUnits.find((u) => u.kind === 'token');
assert.ok(mon && mon.def.name === 'Mon3tr', 'the summon is fielded');
assert.equal(mon.ownerUnit?.def?.charId, 'char_003_kalts');
h.invariants();
m.dispose();
});
test('a DIY 鸿雪 (her operator kit): her 打字机 piece comes to the hand in prep, places, fields with its kit; it leaves with her', REAL, () => {
const SNOW = 'char_4055_bgsnow';
const TYPEWRITER = 'token_10026_bgsnow_subbow';
if (!KITTED_CHARS.includes(SNOW)) return; // (her kit is on feedback5 since O6)
const h = diyMatch().start();
const m = h.m;
h.toPrep(1);
const p0 = h.ps('p_0');
assert.equal(p0.setDiy({ [T5B]: { charId: SNOW, skillIndex: 0, uniEquipId: null } }), true, 'a kitted pick: no widened list');
p0.initDiyStock(new Set());
clear(p0);
const snow = gain(p0, T5B);
assert.ok(!p0.hand.some((p) => p && p.kind === 'token'), 'in the hand she brings no summon yet');
const tile = legalTileFor(m, p0, T5B);
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: snow.uid, to: { area: 'board', row: tile[0], col: tile[1] } }), { ok: true });
const stack = p0.hand.find((p) => p && p.kind === 'token');
assert.ok(stack, 'deployed, her 打字机 comes to the hand (PRTS 卫戍协议/帮助 §战斗部署)');
assert.deepEqual([stack.id, stack.ownerUid, stack.count], [TYPEWRITER, snow.uid, DATA.backups.tokens[TYPEWRITER].variants[`${SNOW}@2/1/4/0`].stats.deployLimit]);
const t2 = legalTileFor(m, p0, T5B, new Set([`${tile[0]},${tile[1]}`]));
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: stack.uid, to: { area: 'board', row: t2[0], col: t2[1] } }), { ok: true });
h.invariants();
const input = p0.battleInput();
const tok = input.units.find((u) => u.kind === 'token');
assert.deepEqual([tok.tokenId, tok.ownerUid], [TYPEWRITER, snow.uid], 'the battle input carries the piece with its owner');
const spec = wire(buildBattleSpec({
battleId: 't.3', fieldId: 'n:p_0', kind: 'normal', seed: 9, modeId: m.modeId, round: 1, stageId: m.stageId,
rect: { ...GEO.NORMAL_RECT }, timeLimit: 20, players: [input], spawns: [], routes: m.wave.routes,
flags: { layerGainsEnabled: true, ...m.gd.dp }, enemyOverrides: {}, content: 'full',
}));
for (const ds of [m.ds, browserSource()]) {
const bt = createBattleFromSpec(wire(spec), ds, { quiet: true, recordEvents: false });
bt.step();
const u = bt.allyUnits.find((x) => x.uid === snow.uid);
const t = bt.allyUnits.find((x) => x.kind === 'token' && x.defId === TYPEWRITER);
assert.ok(u && !u.kit.generic, 'her operator kit');
assert.ok(t, 'the 打字机 is fielded');
assert.equal(t.ownerUnit, u, 'owned by her (the kit finds its piece by owner)');
assert.equal(t.def.stats.maxHp, DATA.backups.tokens[TYPEWRITER].variants[`${SNOW}@2/1/4/0`].stats.maxHp, 'the token record of her form');
assert.ok(t.kit && !t.kit.generic, 'the typewriter runs the kit her install gave it');
assert.equal(bt.errors.length, 0);
}
// sold, the piece leaves with her
assert.deepEqual(m.handle('p_0', { t: 'g.sell', uid: snow.uid }), { ok: true });
assert.ok(![...p0.board.values(), ...p0.hand, ...p0.temp].some((p) => p && p.kind === 'token'), 'her summon leaves with her');
h.invariants();
m.dispose();
});
+9 -2
View File
@@ -170,6 +170,7 @@ function legacyInvariants(m) {
assert.ok(ps.hand.every((x) => x == null) && ps.temp.every((x) => x == null), 'eliminated player keeps nothing');
}
const chessUids = new Set();
const diyHeld = new Map();
const all = [...ps.board.values(), ...ps.hand.filter(Boolean), ...ps.temp.filter(Boolean)];
for (const p of all) if (p.kind === 'chess') chessUids.add(p.uid);
let deployed = 0;
@@ -177,7 +178,8 @@ function legacyInvariants(m) {
for (const [k, p] of ps.board) {
const [r, c] = k.split(',').map(Number);
assert.ok(r >= FIELD.r0 && r <= FIELD.r1 && c >= FIELD.c0 && c <= FIELD.c1, `piece outside the board ${k}`);
const rec = p.kind === 'token' ? m.gd.token(p.id) : m.gd.chess(p.id);
const pgd = ps.gd || m.gd; // the player's data view (0.2.0 自选: its summons are data/backups.json tokens)
const rec = p.kind === 'token' ? pgd.token(p.id) : pgd.chess(p.id);
assert.ok(rec, `unknown board piece ${p.id}`);
const cls = p.kind === 'chess' ? placeClass(ps, rec) : positionClass(rec);
assert.ok(canPlace(dmap, cls, r, c), `illegal tile ${p.id} @ ${k}`);
@@ -195,7 +197,9 @@ function legacyInvariants(m) {
for (const it of p.items) { note(it); assert.equal(it.kind, 'item'); assert.ok(m.gd.item(it.id), `unknown item ${it.id}`); }
assert.ok(Number.isInteger(p.poolCopies) && p.poolCopies >= 0);
const base = m.gd.baseIdOf(p.id);
held.set(base, (held.get(base) || 0) + p.poolCopies);
// a 自选 piece's copies are its player's own stock (0.2.0, server/match/player/diy.js)
const tally = ps.diyStock && ps.diyStock.has(base) ? diyHeld : held;
tally.set(base, (tally.get(base) || 0) + p.poolCopies);
} else if (p.kind === 'item') {
assert.ok(m.gd.item(p.id), `unknown item ${p.id}`);
} else if (p.kind === 'token') {
@@ -210,6 +214,9 @@ function legacyInvariants(m) {
const need = m.gd.mergeCount(b);
if (need > 1 && m.gd.goldenIdOf(b)) assert.ok(n < need, `${ps.playerId} owns ${n} copies of ${b} (merge ${need})`);
}
for (const [base, e] of ps.diyStock ? ps.diyStock.entries : []) {
assert.equal(e.left + (diyHeld.get(base) || 0), e.cap, `${ps.playerId} 自选 stock ${base}: left ${e.left} + held ${diyHeld.get(base) || 0} != cap ${e.cap}`);
}
}
for (const [base, e] of pool.entries) {
assert.ok(e.left >= 0 && e.left <= e.cap, `pool ${base} left ${e.left} cap ${e.cap}`);
+340
View File
@@ -0,0 +1,340 @@
// 0.2.0 自选编队 — client side (research 0.2.0 §2; the owner's decisions of 2026-10-05): the 自选编队 tab's model (storage,
// the slots, the picker's operators with the kit list, skill / module choices without 集成战略 modules, export / import)
// and its server sync (room.diy; welcome.diyKitted), the tab's components (four slots, the picker, 确认), and the match
// UI of a 自选 piece — the shop / reward card and the detail card show the operator (name, art, class, derived bonds,
// the pick's skill and module) with a 「自选」 badge, the own pieces get a tag, placement reads the operator's position, and
// the bond popup lists the 自选 member (own and a teammate's).
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/diyModel.js');
const { installDiySync, 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 { BondPopup } = await import('../../public/js/ui/bondStrip.js');
const { ownerBoard } = await import('../../public/js/ui/watchBonds.js');
const { diyPieces } = await import('../../public/js/screens/game/standInTags.js');
const { DiyPanelView, DiyPickerView } = await import('../../public/js/screens/diy.js');
const { data } = await import('../../public/js/data.js');
const { DATA } = await import('../match/harness.js');
const { KITTED_CHARS } = await import('../../server/sim/content/kits/index.js');
await data.loadAll('chess', 'bonds', 'assets', 'garrisons', 'items', 'backups');
const D = { chess: DATA.chess, backups: DATA.backups };
const getChess = (id) => (Object.hasOwn(DATA.chess, id) ? DATA.chess[id] : null);
const T5A = 'chess_char_5_diy1_a';
const T5B = 'chess_char_5_diy2_a';
const T6A = 'chess_char_6_diy1_a';
const T6B = 'chess_char_6_diy2_a';
const SIEGE = 'char_112_siege';
const SHARP = 'char_609_acguad';
const KALTS = 'char_003_kalts'; // 凯尔希: an ISW-A module at both elite stages
const SIEGE_PICK = { charId: SIEGE, skillIndex: 2, uniEquipId: 'uniequip_002_siege' };
const KIT = [...KITTED_CHARS];
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);
/** Render a component tree one level of function components deep at a time (htm vnodes with function types). */
function expand(v, depth = 6) {
if (Array.isArray(v)) return v.map((x) => expand(x, depth));
if (!v || typeof v !== 'object') return v;
if (typeof v.type === 'function' && depth > 0) {
try { return expand(v.type(v.props || {}), depth - 1); } catch { return v; }
}
const children = v.props?.children;
return children === undefined ? v : { ...v, props: { ...v.props, children: expand(children, depth) } };
}
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/diyModel.js)', () => {
test('storage: tolerant parse, junk and hostile keys dropped, round trip', () => {
assert.deepEqual(M.parseStoredDiy(null), {});
const raw = { v: 1, picks: { [T5A]: SIEGE_PICK, [T6A]: { charId: SHARP }, __proto__x: 1, bad: { charId: 'a b' }, [T6B]: { charId: SIEGE, skillIndex: 99 } } };
assert.deepEqual(M.parseStoredDiy(raw), { [T5A]: SIEGE_PICK, [T6A]: { charId: SHARP }, [T6B]: { charId: SIEGE } });
assert.deepEqual(M.toStoredDiy({ [T5A]: SIEGE_PICK }), { v: M.DIY_VERSION, picks: { [T5A]: SIEGE_PICK } });
assert.equal(M.diyCount({ [T5A]: SIEGE_PICK, [T6A]: null }), 1);
assert.deepEqual(M.setPick({ [T5A]: SIEGE_PICK }, T5A, null), {});
assert.deepEqual(M.setPick({}, T6A, { charId: SHARP }), { [T6A]: { charId: SHARP } });
});
test('the four slots by tier, with the 调度中心 level that sells them', () => {
assert.deepEqual(M.diySlotList(D).map((s) => [s.slotId, s.tier, s.shopLevel]), [[T5A, 5, 5], [T5B, 5, 5], [T6A, 6, 6], [T6B, 6, 6]]);
});
test('the picker lists only kitted operators (prototypes first), marks one another slot holds', () => {
const opts = M.pickOptions(T5A, {}, D, KIT);
const ids = opts.map((o) => o.charId);
assert.ok(ids.includes(SIEGE) && ids.includes(SHARP) && ids.includes('char_601_cguard'));
assert.ok(ids.every((id) => KIT.includes(id)), 'no operator without a kit');
assert.ok(ids.indexOf(SHARP) < ids.indexOf(SIEGE), 'prototypes first');
assert.deepEqual(opts.find((o) => o.charId === SIEGE).bonds, ['victoriaShip']);
assert.equal(opts.find((o) => o.charId === SHARP).proto, true);
assert.ok(!M.pickOptions(T6A, {}, D, KIT).some((o) => o.charId === 'char_601_cguard'), 'a 4★ reserve is a tier-5 pick only');
// taken: an owned operator in any other slot; a prototype in the other slot of its tier (not across tiers)
const roster = { [T5B]: { charId: SHARP }, [T6A]: SIEGE_PICK };
const at5 = M.pickOptions(T5A, roster, D, KIT);
assert.equal(at5.find((o) => o.charId === SIEGE).taken, T6A);
assert.equal(at5.find((o) => o.charId === SHARP).taken, T5B);
assert.equal(M.pickOptions(T6B, roster, D, KIT).find((o) => o.charId === SHARP).taken, null, 'a prototype may fill a tier-5 and a tier-6 slot');
assert.deepEqual(M.pickOptions(T5A, {}, D, []), [], 'no kit list (not connected): nothing to pick');
});
test('choices: three skills, the elite form\'s modules without the 集成战略 (ISW-A) ones; a prototype\'s locked selection', () => {
const ch = M.pickChoices(SIEGE, T6A, D);
assert.deepEqual(ch.skills.map((s) => s.index), [0, 1, 2]);
assert.deepEqual(ch.modules.map((x) => x.uniEquipId), ['uniequip_002_siege', 'uniequip_003_siege']);
assert.equal(ch.stage, 3);
assert.equal(M.pickChoices(SIEGE, T5A, D).stage, 1);
const kalts = M.pickChoices(KALTS, T6A, D);
assert.ok(kalts.elite.modules.some((x) => x.typeName === 'ISW-A'), 'the form has one');
assert.ok(!kalts.modules.some((x) => x.rec.typeName === 'ISW-A'), 'never offered');
const proto = M.pickChoices(SHARP, T5A, D);
assert.equal(proto.proto, true);
assert.deepEqual(proto.locked, { skillIndex: 2, uniEquipId: 'uniequip_002_acguad' });
assert.deepEqual(M.defaultPick(SIEGE, T5A, D), { charId: SIEGE, skillIndex: 2, uniEquipId: 'uniequip_002_siege' }, 'S3 and the first module');
assert.deepEqual(M.defaultPick(SHARP, T5A, D), { charId: SHARP });
});
test('sanitize keeps what the server keeps; export / import envelopes', () => {
assert.deepEqual(M.sanitizeDiyPicks({ [T5A]: SIEGE_PICK, [T6A]: { charId: SIEGE, skillIndex: 0 } }, D, KIT), { [T5A]: SIEGE_PICK });
const text = M.serializeDiy({ [T5A]: SIEGE_PICK }, { now: 0 });
const env = JSON.parse(text);
assert.deepEqual([env.kind, env.v, env.count, env.picks], [M.DIY_EXPORT_KIND, 1, 1, { [T5A]: SIEGE_PICK }]);
assert.deepEqual(M.parseDiyImport(text), { ok: true, picks: { [T5A]: SIEGE_PICK } });
assert.deepEqual(M.parseDiyImport({ v: 1, picks: {} }), { ok: true, picks: {} });
for (const bad of ['', '{nope', { v: 9, picks: {} }, { kind: 'stronghold.ownership', v: 1, notOwned: [] }, 'x'.repeat(M.DIY_IMPORT_MAX_BYTES + 1), [1]]) {
assert.equal(M.parseDiyImport(bad).ok, false, JSON.stringify(bad).slice(0, 30));
}
});
});
// ---- 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(([, x]) => x.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('diy sync: welcome brings the kit list and sends room.diy; edits are debounced; in a match stored for the next one (told once)', async () => {
const net = fakeNet();
const T = fakeTimers();
const told = [];
const target = createStore({ diy: { [T5A]: SIEGE_PICK }, diyKitted: null, open: false, diySync: 'idle' });
const s = installDiySync({ net, timers: T, target, notify: (x) => told.push(x) });
net.emit('welcome', { diyKitted: [SIEGE, SHARP, 7] });
assert.deepEqual(target.get().diyKitted, [SIEGE, SHARP]);
await T.advance(100);
assert.deepEqual(net.sent, [{ t: 'room.diy', picks: { [T5A]: SIEGE_PICK } }]);
assert.equal(target.get().diySync, 'synced');
target.set({ diy: { [T5A]: SIEGE_PICK, [T6A]: { charId: SHARP } } });
await T.advance(SYNC_DEBOUNCE_MS + 10);
assert.deepEqual(net.sent[1], { t: 'room.diy', picks: { [T5A]: SIEGE_PICK, [T6A]: { charId: SHARP } } });
net.replies.push({ error: 'ROOM_STARTED' });
target.set({ diy: {} });
await T.advance(SYNC_DEBOUNCE_MS + 10);
assert.equal(target.get().diySync, 'locked');
assert.deepEqual(told, ['自选编队是局外设置,修改将在下一局生效']);
net.emit('room.state', { inMatch: false });
await T.advance(SYNC_DEBOUNCE_MS + 10);
assert.deepEqual(net.sent.at(-1), { t: 'room.diy', picks: {} });
s.dispose();
});
// ---- the tab --------------------------------------------------------------------------------------------------------------
describe('自选编队 tab (screens/diy.js)', () => {
test('four slots by tier: an empty slot to fill, a filled one shows the operator, its tag, skill and module', () => {
const tree = expand(DiyPanelView({ m: data.get('assets'), picks: { [T5A]: SIEGE_PICK, [T6A]: { charId: SHARP } }, legal: { [T5A]: SIEGE_PICK, [T6A]: { charId: SHARP } }, kitted: KIT, onSet() {}, picking: null, onPicking() {} }));
const slots = [...walk(tree)].filter((v) => hasClass(v, 'diy-slot'));
assert.deepEqual(slots.map((v) => v.props['data-slot']), [T5A, T5B, T6A, T6B]);
const siege = slots[0];
assert.equal(siege.props['data-char'], SIEGE);
const txt = textOf(siege);
assert.ok(txt.includes(DATA.backups.units[SIEGE].name) && txt.includes('已持有') && txt.includes('S3') && txt.includes('SOL-X'), txt);
assert.ok(textOf(slots[2]).includes('原型'));
assert.ok(hasClass(slots[1], 'is-empty') && textOf(slots[1]).includes('选择干员'));
});
test('the picker: the slot\'s operators; choosing one and its skill / module confirms the pick', () => {
const done = [];
let draft = { ...SIEGE_PICK };
const view = () => expand(DiyPickerView({ m: null, slot: { slotId: T5A, tier: 5, shopLevel: 5 }, picks: { [T5A]: SIEGE_PICK }, kitted: KIT, onDone: (p) => done.push(p), onClose() {},
filter: 'all', query: '', draft, onFilter() {}, onQuery() {}, onDraft: (d) => { draft = d; } }));
let tree = view();
const opts = [...walk(tree)].filter((v) => hasClass(v, 'diy-opt'));
assert.ok(opts.some((v) => v.props['data-char'] === SIEGE) && opts.some((v) => v.props['data-char'] === SHARP));
// the current pick is shown with its three skills and its modules (none + X + Y)
const skills = [...walk(tree)].filter((v) => v.props?.role === 'radio' && v.props['data-skill'] != null);
assert.deepEqual(skills.map((v) => v.props['data-skill']), [0, 1, 2]);
assert.equal(skills.find((v) => v.props['aria-checked'] === 'true').props['data-skill'], 2);
const mods = [...walk(tree)].filter((v) => v.props?.role === 'radio' && v.props['data-module'] != null);
assert.deepEqual(mods.map((v) => v.props['data-module']), ['none', 'uniequip_002_siege', 'uniequip_003_siege']);
// another skill and no module, then 确认
skills.find((v) => v.props['data-skill'] === 0).props.onClick();
tree = view();
[...walk(tree)].find((v) => v.props?.role === 'radio' && v.props['data-module'] === 'none').props.onClick();
tree = view();
[...walk(tree)].find((v) => v.props?.['data-testid'] === 'diy-confirm').props.onClick();
assert.deepEqual(done, [{ charId: SIEGE, skillIndex: 0, uniEquipId: null }]);
// a prototype: its locked skill only; 确认 sends just the operator (the server completes the locked selection)
[...walk(tree)].find((v) => hasClass(v, 'diy-opt') && v.props['data-char'] === SHARP).props.onClick();
tree = view();
assert.deepEqual([...walk(tree)].filter((v) => v.props?.role === 'radio' && v.props['data-skill'] != null).map((v) => [v.props['data-skill'], v.props.disabled]), [[2, true]]);
[...walk(tree)].find((v) => v.props?.['data-testid'] === 'diy-confirm').props.onClick();
assert.deepEqual(done.at(-1), { charId: SHARP });
});
});
// ---- match UI -------------------------------------------------------------------------------------------------------------
describe('match UI of a 自选 piece', () => {
const priv = { diy: { [T5A]: SIEGE_PICK, [T6A]: { charId: SHARP, skillIndex: 2, uniEquipId: 'uniequip_002_acguad' } }, loadout: {}, standIns: [], board: [], hand: [], temp: [] };
test('gameLogic: the own pick of a slot (normal + elite), the composed record, the own getter', () => {
assert.deepEqual(G.ownDiyPick(priv, getChess(T5A)), SIEGE_PICK);
assert.deepEqual(G.ownDiyPick(priv, getChess('chess_char_5_diy1_b')), SIEGE_PICK, 'the elite too');
assert.equal(G.ownDiyPick(priv, getChess(T5B)), null, 'a slot not filled');
assert.equal(G.ownDiyPick(priv, getChess('chess_char_4_22_a')), null);
const rec = G.ownDiyRecord(getChess(T5A), priv, D);
assert.deepEqual([rec.name, rec.charId, rec.diyFor, rec.bonds, rec.tier, rec.price], [DATA.backups.units[SIEGE].name, SIEGE, T5A, ['victoriaShip'], 5, 4]);
assert.equal(G.ownDiyRecord(getChess(T5A), priv, D), rec, 'cached');
const get = G.diyGetter(getChess, priv, D);
assert.equal(get(T5A), rec);
assert.equal(get('chess_char_4_22_a'), getChess('chess_char_4_22_a'));
assert.equal(G.chessLoadout(get('chess_char_5_diy1_b'), priv.loadout, get).skill.skillId, 'skchr_siege_3', 'the pick\'s skill');
assert.equal(G.deployedModuleId(get('chess_char_5_diy1_b'), priv, get, D.backups), 'uniequip_002_siege', 'the pick\'s module on the elite');
assert.equal(G.cardDiy(getChess(T5A), { unit: { diy: { charId: SHARP, skillIndex: 2, uniEquipId: 'uniequip_002_acguad' } }, data: D }).charId, SHARP, 'a teammate\'s unit says it itself');
});
test('placement reads the operator\'s position (推进之王 melee) through the own getter', () => {
const get = G.diyGetter(getChess, priv, D);
const ctx = G.placementContext({ priv, stage: null, editable: true, getChess: get, backups: D.backups });
assert.equal(G.piecePosition(ctx, { kind: 'chess', id: T5A }), 'MELEE');
});
test('shop / reward card: the operator\'s name and bonds, the 「自选」 badge, the pick\'s skill', () => {
const card = ChessCard({ slot: { kind: 'chess', id: T5A, price: 4, basePrice: 4 }, idx: 0, priv, onBuy() {}, onDetail() {} });
const nodes = [...walk(card)];
const badge = nodes.find((v) => hasClass(v, 'scard__diy'));
assert.ok(badge);
assert.equal(badge.props['data-diy'], SIEGE);
assert.equal(badge.props.children, '自选');
assert.equal(nodes.find((v) => hasClass(v, 'scard__name')).props.children, DATA.backups.units[SIEGE].name);
assert.ok(nodes.some((v) => v.props?.bondId === 'victoriaShip'), 'the derived bond');
assert.equal(nodes.find((v) => v?.type && v.props?.lo).props.lo.skill.skillId, 'skchr_siege_3');
const other = ChessCard({ slot: { kind: 'chess', id: T5A, price: 4, basePrice: 4 }, idx: 0, priv: { diy: {} }, onBuy() {}, onDetail() {} });
assert.equal([...walk(other)].find((v) => hasClass(v, 'scard__diy')), undefined);
});
test('detail card: own piece / card / a unit carrying diy → the operator, the 「自选」 tag, the pick\'s skill and module', () => {
const piece = { uid: 7, kind: 'chess', id: 'chess_char_5_diy1_b', golden: true, items: [] };
const pieces = new Map([[7, { piece, area: 'board', row: 10, col: 4 }]]);
const r = resolveDetail({ kind: 'piece', uid: 7 }, pieces, { priv, backups: D.backups });
assert.equal(r.chess.charId, SIEGE);
assert.deepEqual(r.diy, SIEGE_PICK);
assert.equal(resolveDetail({ kind: 'chess', id: T5A }, new Map(), { priv, backups: D.backups }).chess.charId, SIEGE, 'a shop card');
assert.equal(resolveDetail({ kind: 'chess', id: T5A, foreign: true }, new Map(), { priv, backups: D.backups }).diy, undefined, 'the banned list / another player\'s card');
const u = resolveDetail({ kind: 'unit', unit: { id: 3, uid: 33, side: 'ally', kind: 'op', defId: T6A, diy: { charId: SHARP, skillIndex: 2, uniEquipId: 'uniequip_002_acguad' } } }, new Map(), { priv: null, backups: D.backups });
assert.equal(u.chess.charId, SHARP);
const blocks = ChessDetail({ chess: r.chess, piece, editable: false, bonds: [], loadout: priv.loadout, diy: r.diy });
const head = blocks.find((b) => b.key === 'head');
assert.equal([...walk(head)].find((v) => hasClass(v, 'dtag-diy'))?.props.children, '自选');
assert.equal([...walk(head)].find((v) => hasClass(v, 'dhead__name')).props.children, DATA.backups.units[SIEGE].name);
const skill = blocks.find((b) => b.key === 'skill');
assert.ok(JSON.stringify(skill.props).includes('skchr_siege_3'));
const mod = blocks.find((b) => b.key === 'module');
assert.ok(JSON.stringify(mod.props).includes('uniequip_002_siege'), 'the pick\'s module (elite)');
assert.ok(!blocks.some((b) => b.key === 'garrison'), 'no 特质');
});
test('own prep pieces of a filled slot get the 「自选」 tag', () => {
const p = { ...priv, hand: [{ uid: 1, kind: 'chess', id: T5A }, { uid: 2, kind: 'chess', id: 'chess_char_4_22_a' }], temp: [], board: [{ uid: 4, kind: 'chess', id: 'chess_char_6_diy1_b', row: 10, col: 4 }] };
assert.deepEqual(diyPieces(p, getChess, D).map((x) => [x.uid, x.area, x.label, x.name]), [[1, 'hand', '自选', DATA.backups.units[SIEGE].name], [4, 'board', '自选', DATA.backups.units[SHARP].name]]);
assert.deepEqual(diyPieces({ ...p, diy: {} }, getChess, D), []);
});
test('bonds: the popup lists the own 自选 member (煌-style several bonds count each) and a teammate\'s by its unit\'s pick', () => {
const get = G.diyGetter(getChess, priv, D);
const own = { ...priv, board: [{ uid: 4, kind: 'chess', id: T5A, row: 10, col: 4 }], hand: [{ uid: 5, kind: 'chess', id: T6A }] };
const vic = G.bondMembers(DATA.bonds.victoriaShip, own, [], get);
const row = vic.find((x) => x.diy);
assert.deepEqual([row.id, row.name, row.onBoard, row.owned, row.rec.charId], [T5A, DATA.backups.units[SIEGE].name, true, true, SIEGE]);
const empty = G.bondMembers(DATA.bonds.emptyShip, own, [], get);
assert.deepEqual(empty.filter((x) => x.diy).map((x) => [x.id, x.inHand]), [[T6A, true]], 'the prototype in the hand counts for 协防');
assert.equal(G.memberHeadCount(vic.filter((x) => x.diy)), 1);
// a watched teammate: its board from the field units, their picks carried along (watchBonds ownerBoard)
const field = { units: [{ kind: 'op', ownerId: 'p_2', defId: T5B, diy: { charId: 'char_017_huang', skillIndex: 0, uniEquipId: null } }] };
const mate = ownerBoard(field, 'p_2');
assert.deepEqual(mate.board[0].diy, { charId: 'char_017_huang', skillIndex: 0, uniEquipId: null });
const pieceRecord = (p) => (p.diy ? G.diyRecordFor(getChess(p.id), p.diy, D) : null) || getChess(p.id);
for (const b of ['yanShip', 'victoriaShip']) {
const rows = G.bondMembers(DATA.bonds[b], mate, [], getChess, () => null, pieceRecord);
assert.ok(rows.some((x) => x.diy && x.rec.charId === 'char_017_huang'), `煌 is a ${b} member`);
}
// the popup itself renders the 自选 row with its tag
const pop = expand(BondPopup({ bondId: 'victoriaShip', entry: { count: 1, active: false, tier: 0, layers: 0 }, priv: own, onClose() {} }));
const mrow = [...walk(pop)].find((v) => hasClass(v, 'bpop__member') && textOf(v).includes(DATA.backups.units[SIEGE].name));
assert.ok(mrow, 'the 自选 member row');
assert.ok([...walk(mrow)].some((v) => hasClass(v, 'bpop__diy')), 'tagged 自选');
});
test('the renderer draws the operator (pieceInfo reads m.private.diy; UnitInfo diy is kept)', () => {
const src = readFileSync(path.join(ROOT, 'public/js/render/app.js'), 'utf8');
assert.match(src, /const pick = chess && chess\.isDiy \? diyPicks\[chess\.baseId \|\| chess\.chessId\] : null;/);
assert.match(src, /diyPicks = src\.diy && typeof src\.diy === 'object' \? src\.diy : \{\};/);
assert.match(readFileSync(path.join(ROOT, 'public/js/render/app/info.js'), 'utf8'), /diy: u\.diy && typeof u\.diy === 'object'/);
});
});
+164
View File
@@ -0,0 +1,164 @@
// Real-server browser E2E of 0.2.0 自选编队 end to end (research 0.2.0 §2; the owner's decisions of 2026-10-05): the
// 自选编队 tab of the 干员调配 overlay fills the first tier-5 slot with 推进之王 (real clicks: the slot, the operator, S3, the
// SOL-X module, 确认; persisted in localStorage; room.diy → session → seat → the next match's m.private.diy); in the
// match the 调度中心 is at level 5 and the shop sells the slot as 推进之王 with the 「自选」 badge; bought, the hand piece is
// tagged 「自选」 and draws 推进之王's model; deployed, the detail card shows 推进之王 with the pick's skill; and in the
// browser's own battle (client-side combat) the unit is the operator — the b.start spec carries the pick (`diy`), the
// local sim fields char_112_siege with its operator kit, and the battle view draws its Spine model.
//
// SP_E2E=1 node --test test/ui/diy.e2e.test.js
//
// The server is test/e2e/fastServer.mjs with its starter-kit hooks (no starter operators, the 调度中心 at level 5 —
// SP_START_LEVEL —, the tier-5 DIY slot 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/diy-*.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 SLOT = 'chess_char_5_diy1_a';
const SIEGE = 'char_112_siege';
const PICK = { charId: SIEGE, skillIndex: 2, uniEquipId: 'uniequip_002_siege' };
describe('0.2.0 自选编队 — a slotted operator in the own shop and battle (real server)', { skip: !ENABLED && 'set SP_E2E=1 (Chrome + public/assets)' }, () => {
test('自选编队: slot 推进之王 → level-5 shop card (自选) → buy → deploy → the local battle fields 推进之王', { timeout: 6 * 60 * 1000 }, async () => {
const srv = await startRealServer({ fast: { timerScale: 0.5, combatSpeed: 2, startRound: 1, kit: 0, level: 5, shop: [SLOT] } });
const P = (await import('puppeteer-core')).default;
const c = new Client(P, srv.base, 'diy', { prefix: 'diy' });
try {
await c.open();
await c.enter('自选');
// 1) 干员调配 → 自选编队: fill the first tier-5 slot (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="diy"]', { visible: true, timeout: 15000 });
await c.click('.lo .lo-tab[data-tab="diy"]');
await c.page.waitForSelector(`.diy-slot[data-slot="${SLOT}"] .diy-slot__fill`, { visible: true, timeout: 15000 });
const slots = await c.page.evaluate(() => [...document.querySelectorAll('.diy-slot')].map((el) => el.dataset.slot));
assert.deepEqual(slots, ['chess_char_5_diy1_a', 'chess_char_5_diy2_a', 'chess_char_6_diy1_a', 'chess_char_6_diy2_a']);
await c.shot('tab');
await c.click(`.diy-slot[data-slot="${SLOT}"] .diy-slot__fill`);
await c.page.waitForSelector(`[data-testid="diy-picker"] .diy-opt[data-char="${SIEGE}"]`, { visible: true, timeout: 8000 });
const listed = await c.page.evaluate(() => [...document.querySelectorAll('[data-testid="diy-picker"] .diy-opt')].map((el) => el.dataset.char));
assert.ok(listed.includes('char_609_acguad') && listed.includes('char_601_cguard'), 'the tier-5 prototypes are listed');
await c.click(`[data-testid="diy-picker"] .diy-opt[data-char="${SIEGE}"]`);
await c.page.waitForSelector('[data-testid="diy-picker"] .diy-choice[data-skill="2"]', { visible: true, timeout: 5000 });
await c.click('[data-testid="diy-picker"] .diy-choice[data-skill="2"]');
await c.click('[data-testid="diy-picker"] .diy-choice[data-module="uniequip_002_siege"]');
await c.shot('picker');
await c.click('[data-testid="diy-confirm"]');
await c.page.waitForFunction((slot, id) => document.querySelector(`.diy-slot[data-slot="${slot}"]`)?.dataset.char === id, { timeout: 4000 }, SLOT, SIEGE);
await c.page.waitForFunction(() => /已同步/.test(document.querySelector('[data-testid="diy-sync"]')?.textContent || ''), { timeout: 8000 });
const card = await c.page.evaluate((slot) => document.querySelector(`.diy-slot[data-slot="${slot}"]`)?.textContent || '', SLOT);
assert.ok(card.includes('推进之王') && card.includes('已持有') && card.includes('SOL-X'), card);
const stored = await c.page.evaluate(() => JSON.parse(localStorage.getItem('sp.pref.diy') || 'null'));
assert.deepEqual(stored, { v: 1, picks: { [SLOT]: PICK } });
await c.shot('slotted');
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 picks
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 diy = await c.page.evaluate(() => globalThis.__SP__.store.get().match.private?.diy ?? null);
assert.deepEqual(diy, { [SLOT]: PICK }, 'the match received the picks');
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 >= 4 && s.level >= 5, 'prep at 调度中心 level 5', 60000);
await sleep(1800); // camera flight
// 3) the shop card at level 5: 推进之王 with 「自选」 (only this player's shop sells it); two taps buy it
await c.page.waitForSelector('.scard .scard__diy', { visible: true, timeout: 10000 });
const shopCard = await c.page.evaluate(() => {
const el = document.querySelector('.scard .scard__diy')?.closest('.scard');
return el ? { name: el.querySelector('.scard__name')?.textContent, badge: el.querySelector('.scard__diy')?.textContent, diy: el.querySelector('.scard__diy')?.dataset.diy, bonds: el.querySelector('.scard__bonds')?.textContent } : null;
});
assert.deepEqual([shopCard?.name, shopCard?.badge, shopCard?.diy], ['推进之王', '自选', SIEGE]);
assert.ok(shopCard.bonds.includes('维多利亚'), `derived bond: ${shopCard.bonds}`);
const level = await c.page.evaluate(() => globalThis.__SP__.store.get().match.private?.shop?.level);
assert.ok(level >= 5, 'sold from 调度中心 level 5');
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 === SLOT);
assert.ok(piece, 'the slot\'s piece is in the hand');
await c.page.waitForFunction((uid) => document.querySelector(`.sitag[data-uid="${uid}"]`)?.textContent === '自选', { 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.equal(handModel, SIEGE, 'the hand draws the operator');
await c.shot('hand');
// 4) deploy it (drag → direction wheel); the detail card shows 推进之王 with the pick's skill and the 「自选」 tag
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 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-diy', { timeout: 2500 }).then(() => c.page.evaluate(() => ({
tag: document.querySelector('.dpanel .dtag-diy')?.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: '自选', name: '推进之王', skill: 'skchr_siege_3' });
await c.shot('board');
await c.page.keyboard.press('Escape');
// 5) ready → the local battle fields 推进之王 (the spec's diy pick; the sim's def and kit; 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, siege) => {
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, diy: entry.diy ?? null, skillIndex: entry.skillIndex ?? null },
charId: u.def?.charId, diyFor: u.def?.diyFor, skill: u.skill?.id ?? null, bonds: u.def?.bonds, generic: !!u.kit?.generic,
viewSpine: v.info.spine, viewDiy: v.info.diy?.charId ?? null, model: String(v._actorEntry?.skel || ''), siege,
};
}, { timeout: 30000, polling: 150 }, piece.uid, SIEGE).then((h) => h.jsonValue());
assert.equal(got.authoritative, true, 'the own normal battle is simulated by this browser');
assert.deepEqual(got.spec, { chessId: SLOT, diy: PICK, skillIndex: null }, 'b.start spec carries the pick');
assert.equal(got.charId, SIEGE, 'the local sim fields 推进之王');
assert.equal(got.diyFor, SLOT);
assert.equal(got.skill, 'skchr_siege_3', 'with the picked S3');
assert.deepEqual(got.bonds, ['victoriaShip']);
assert.equal(got.generic, false, 'its operator kit');
assert.equal(got.viewSpine, SIEGE);
assert.equal(got.viewDiy, SIEGE, 'the view knows the pick');
assert.match(got.model, /char_112_siege/, 'the battle view draws 推进之王\'s Spine model');
await sleep(800);
await c.shot('combat');
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();
}
});
});
+70 -9
View File
@@ -25,10 +25,12 @@
// 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 17 matches run to the end in virtual time with the match's default bot rehearsal: 16 bot-only (solo 标准 /
// matches 18 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)
// first prep so they reach the Hidden Core), 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) — and
// one whose human seat slots 自选 picks (推进之王 and prototypes; its 调度中心 at level 5 from the first prep):
// its shop sells them and its AI fields them (0.2.0 自选编队, the digest's `diy`)
// 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
@@ -713,9 +715,29 @@ export function matchScenarios() {
out.push({ id: 'coop2-ABYSS-10-boosted', mode: 'coop', difficulty: 'ABYSS', bots: 2, seed: 10, boost: { lp: 400, layers: 300 } });
// 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` : ''}` }));
// 0.2.0 自选编队: a human seat (AI 托管, offline) that slots 推进之王 and prototypes into the four DIY slots
// (its 调度中心 starts at level 5, like the boosted runs' raised LP: the 自选 pieces are sold from level 5 / 6, which the
// AI otherwise reaches only with a full board, where a single new operator rarely improves its lineup)
out.push({ id: `coop2-NORMAL-${MATCH_DIY_SEED}-diy`, mode: 'coop', difficulty: 'NORMAL', bots: 1, seed: MATCH_DIY_SEED, human: { diy: MATCH_DIY, shopLevel: 5 } });
const humanAbout = (h) => (h.notOwned ? ` + 1 human seat (AI 托管) without ${h.notOwned.length} operators (补位 stand-ins)`
: ` + 1 human seat (AI 托管) with ${Object.keys(h.diy || {}).length} 自选 picks${h.shopLevel ? ` and its 调度中心 at level ${h.shopLevel} from the first prep` : ''}`);
return out.map((m) => ({ ...m, family: 'matches', kind: 'match', about: `${m.mode} ${m.difficulty}, ${m.bots} bot seat(s)${m.human ? humanAbout(m.human) : ''}, 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 自选 match's human seat (0.2.0 自选编队, shared/diy.js): 推进之王 (an owned 6★ with its operator kit; S3 and SOL-X) in a
* tier-5 slot, Sharp (a prototype: its locked S3 + SOL-X) in the other tier-5 slot and a tier-6 one, 领主·Sharp in the last
* — its shop sells them from 调度中心 level 5 / 6 and its AI fields them (the digest's `diy` lists the shop draws and the
* fielded pieces per round).
*/
const MATCH_DIY = Object.freeze({
chess_char_5_diy1_a: { charId: 'char_112_siege', skillIndex: 2, uniEquipId: 'uniequip_002_siege' },
chess_char_5_diy2_a: { charId: 'char_609_acguad' },
chess_char_6_diy1_a: { charId: 'char_609_acguad' },
chess_char_6_diy2_a: { charId: 'char_617_sharp2' },
});
const MATCH_DIY_SEED = 70;
/**
* 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).
@@ -737,8 +759,14 @@ export function runMatch(cfg) {
const sched = new VirtualScheduler();
const seats = [];
// 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] });
// fight as their 补位 stand-ins, its 自选 picks join its shop
if (cfg.human) {
seats.push({
seat: 0, playerId: 'h_0', name: 'H-1', isBot: false, connected: false,
...(cfg.human.notOwned ? { notOwned: [...cfg.human.notOwned] } : null),
...(cfg.human.diy ? { diy: JSON.parse(JSON.stringify(cfg.human.diy)) } : null),
});
}
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 = [];
@@ -752,10 +780,24 @@ export function runMatch(cfg) {
const rounds = [];
// 补位: per round at SETTLE, the human's board pieces that fought as stand-ins (chess id → stand-in charId)
const standIns = [];
// 自选编队: per round, the human's shop draws of its 自选 pieces and, at SETTLE, its board pieces that fought as their
// operators (slot id → charId) — an instance wrapper that only observes (as server/match/audit.js does)
const diyRounds = [];
const diyRolls = new Map();
if (cfg.human?.diy) {
const h = m.players.get('h_0');
const roll = h._rollChessSlot.bind(h);
h._rollChessSlot = () => {
const slot = roll();
if (slot && h.diyPickOf(slot.id)) diyRolls.set(m.round, [...(diyRolls.get(m.round) || []), slot.id.replace(/^chess_char_/, '')]);
return slot;
};
}
let last = '';
m.start();
const setup = { stageId: m.stageId, bossId: m.bossId, hiddenBossId: m.hiddenBossId, factions: [...(m.factions || [])] };
let boosted = !cfg.boost;
let levelled = !cfg.human?.shopLevel;
const prepFunds = {};
sched.runUntil(() => {
const key = `${m.phase}:${m.round}`;
@@ -769,6 +811,14 @@ export function runMatch(cfg) {
ps.recompute();
}
}
// the human's 调度中心 raised at the first prep (its shop rerolled there; the next level's price follows)
if (!levelled && m.phase === 'PREP') {
levelled = true;
const h = m.players.get('h_0');
h.shop.level = cfg.human.shopLevel;
h.shop.upgradePrice = m.gd.upgradeBase(h.shop.level) ?? 0;
h.rollShop({ keepFrozen: false });
}
// funds when the prep opens (income in, nothing bought yet: the bots play their prep in later callbacks)
if (m.phase === 'PREP') for (const ps of m.order) prepFunds[ps.playerId] = ps.funds;
if (m.phase === 'SETTLE') {
@@ -782,11 +832,16 @@ export function runMatch(cfg) {
];
}
rounds.push(row);
if (cfg.human) {
if (cfg.human?.notOwned) {
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(' ')]);
}
if (cfg.human?.diy) {
const h = m.players.get('h_0');
const fielded = [...h.board.values()].filter((p) => p.kind === 'chess' && h.diyPickOf(p.id)).map((p) => `${p.id.replace(/^chess_char_/, '')}→${h.diyPickOf(p.id).charId}`).sort(byId);
diyRounds.push([m.round, (diyRolls.get(m.round) || []).sort(byId).join(' '), fielded.join(' ')]);
}
}
}
return summary != null;
@@ -817,7 +872,13 @@ export function runMatch(cfg) {
rounds,
players,
};
if (cfg.human) digest.standIns = { notOwned: [...m.players.get('h_0').standIns], rounds: standIns };
if (cfg.human?.notOwned) digest.standIns = { notOwned: [...m.players.get('h_0').standIns], rounds: standIns };
if (cfg.human?.diy) {
const h = m.players.get('h_0');
// [round, this round's shop draws of its 自选 pieces (slot ids), the 自选 pieces fielded at SETTLE (slot → operator)];
// the stock left at the end
digest.diy = { picks: JSON.parse(JSON.stringify(h.diy)), banned: [...h.diyBanned], rounds: diyRounds, stock: h.diyStock.snapshot() };
}
m.dispose();
return digest;
}
@@ -961,7 +1022,7 @@ const ABOUT = {
* 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', 'coop2-NORMAL-14-standins']);
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', `coop2-NORMAL-${MATCH_DIY_SEED}-diy`]);
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);