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`, 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 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 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 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. 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 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 (`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 `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, 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 `shared/diy.js` adds the 自选 rules on top. The sim fields both (docs/SIM.md §12). Played since 0.2.0: the player fills
come later. The rules in the data (activity_table act2autochess `charShopChessDatas`; PRTS 卫戍协议, 卫戍协议:盟约 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盟约记录): 下半/PRTS盟约记录):
- PRESET (74, 特许干员) always fields the real operator (`backup.charId` = itself); NORMAL (55) names one of 17 stand-ins — - 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`: 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 `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 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 `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 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 `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 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 `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 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.) 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 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. 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 Not in this data: a player's ownership roster and 自选 picks (browser settings sent with `room.ownership` / `room.diy`),
owned-operator training bonus (`prepareStateDict`), potential (0 for every 自选 pick, [ASSUMED]: no account). 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. - 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. - 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`). 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 ### 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}` 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. **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) 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) 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) 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 } } 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 */ } 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`). **导出 / 导入 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 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: PlayerState.js per-player economy / shop / hand / board / items / bonds / LP + every prep intent handler — the class:
constructor + method install constructor + method install
player/ PlayerState's methods by concern: basics, pieces, acquire (gains, merges), economy (funds, layers, 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 gamedata.js typed, defaulted view of data/*.json (config tunables with research defaults) + the balance layer
(data/tuning.json, §3.1) (data/tuning.json, §3.1)
pool.js SharedPool (copies per base chess, across players), per-match bans, copy-weighted rolls 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). 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; * **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). 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); * **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 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 卫戍协议/帮助 §手牌区 "例如招募/购入等通常情况下会增加手牌的操作"; GitHub #82) — and withdrawals unless the withdrawn
+1
View File
@@ -60,6 +60,7 @@
- **晋升精锐**:3 名同名干员(无论是否部署)自动合成**精锐**(等级、技能、模组大幅提升;风丸只需 2 名),精锐不会再合成。如果被合成的干员里有**已经部署在战场上**的,精锐会直接出现在**那名干员的位置**(保持原来的朝向,不多占部署位;有两三名都在场上时,取最先部署的那名——最左一列优先,同一列从上到下);否则精锐进入整备区(满了进入临时整备区)。原先配发的装备都退回整备区,需要重新配发;被合成干员的召唤物随之移除,场上的精锐会把自己的召唤物送进整备区。点选一张能合成的商店卡片时,精锐将出现的那一格会亮起金色边框,同时打开的详情卡里也会写明去向(「可晋升:精锐干员将出现在作战区原位置 / 将进入整备区」;详情卡会自动停靠在不遮挡这一格的一侧)。在回合开始或结算时因策略、奖励等完成的合成,精锐出现时同样会播放金色的晋升特效。 - **晋升精锐**:3 名同名干员(无论是否部署)自动合成**精锐**(等级、技能、模组大幅提升;风丸只需 2 名),精锐不会再合成。如果被合成的干员里有**已经部署在战场上**的,精锐会直接出现在**那名干员的位置**(保持原来的朝向,不多占部署位;有两三名都在场上时,取最先部署的那名——最左一列优先,同一列从上到下);否则精锐进入整备区(满了进入临时整备区)。原先配发的装备都退回整备区,需要重新配发;被合成干员的召唤物随之移除,场上的精锐会把自己的召唤物送进整备区。点选一张能合成的商店卡片时,精锐将出现的那一格会亮起金色边框,同时打开的详情卡里也会写明去向(「可晋升:精锐干员将出现在作战区原位置 / 将进入整备区」;详情卡会自动停靠在不遮挡这一格的一侧)。在回合开始或结算时因策略、奖励等完成的合成,精锐出现时同样会播放金色的晋升特效。
- **晋级奖励**:每次晋升后获得一次特殊招募:从 3 名 **高一阶**(最高 VI)、**互不相同**的随机干员中**免费**挑 1 名(该阶剩余干员不足 3 名时由低一阶补足)。它不能刷新、不能冻结、本回合结束失效。 - **晋级奖励**:每次晋升后获得一次特殊招募:从 3 名 **高一阶**(最高 VI)、**互不相同**的随机干员中**免费**挑 1 名(该阶剩余干员不足 3 名时由低一阶补足)。它不能刷新、不能冻结、本回合结束失效。
- **干员持有(补位)**:大厅、房间和「确认本局信息」里的「干员调配」按钮打开的界面有两个标签页:「干员调配」选择每名干员携带的技能和精锐的模组;「**干员持有**」可以把你没有的干员**下掉**(点一下卡片切换「持有 / 未持有」)。和官方一样只有 55 张「普通」棋子能下掉(界面按阶级列出本赛季商店里的 53 名,每张写着由谁替补、用几技能);74 张「预设」棋子(多是 1–2 阶的常见干员)无论是否持有都由本人上场,不在其中。默认全部持有。下掉的干员照常出现在商店和整备区,**名字、盟约、特质、阶级、价格、合成都按原干员**,卡片和整备区里的单位下方标着「替补:Sharp」这样的标记;部署到战场上后换成**替补干员**的模型,作战时的属性、技能、天赋、模组、攻击范围、近战位 / 远程位都是替补干员的(详情卡显示替补的技能和天赋,例如银灰 → Sharp 的 2 技能「亮剑」)。替补的技能和模组由棋子固定,「干员调配」对它不生效;替补干员没有召唤物(下掉缪尔赛思就没有流形)。这是**局外设置**:下一局开始时生效,对局中修改只保存到下一局;同盟模拟中只影响你自己的棋子,队友的棋子按队友的设置。没有官方的好友借用:想用本人上场,改回「持有」即可。「全部持有」一键恢复,「导出 / 导入」可以把设置带到别的浏览器;大厅的「干员调配」按钮上会显示「替补 N」提醒你有几名干员被下掉。 - **干员持有(补位)**:大厅、房间和「确认本局信息」里的「干员调配」按钮打开的界面有两个标签页:「干员调配」选择每名干员携带的技能和精锐的模组;「**干员持有**」可以把你没有的干员**下掉**(点一下卡片切换「持有 / 未持有」)。和官方一样只有 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; `['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`), `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]`. `['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) (`dir` = the unit direction, allies meaningful, enemies 'RIGHT'; `facing` = its horizontal sign for sprite flipping)
(`spine`/`avatar` are asset ids from data). (`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); - 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-elite { background: var(--gold); color: #1d1400; }
.dtag-token { background: #a9c3d8; color: #0b1520; } .dtag-token { background: #a9c3d8; color: #0b1520; }
/* 0.2.0 补位: the card of a chess fielded as its stand-in (ui/detailPanel.js ChessDetail) */ /* 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-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; } .dtag-kind { border: 1px solid var(--line-2); color: var(--text-lo); font-weight: 500; }
.drank { font-size: .13rem; font-weight: 700; padding: .01rem .08rem; } .drank { 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; .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; } 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--board { background: #c2e3ff; }
.sitag--diy { background: var(--mint-500); color: var(--text-on-mint); }
.gm.is-dragging .sitags { display: none; } .gm.is-dragging .sitags { display: none; }
/* ---- underframe: the selection diamond of a tapped own piece (ui/underframe.js): 撤退 / 出售 +N / 销毁 ---- /* ---- 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) */ /* 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; .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); } 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):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:not(.is-disabled):not(.scard--sold):active { transform: translateY(-.04rem); }
.scard.is-disabled { cursor: not-allowed; } .scard.is-disabled { cursor: not-allowed; }
+98
View File
@@ -385,6 +385,104 @@
.lo-tab { padding: .02rem .08rem; letter-spacing: .04em; } .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) --------------------------------------------------------------------------- */ /* ---- entry button (lobby / room / briefing) --------------------------------------------------------------------------- */
.lo-entry { position: relative; gap: .08rem; } .lo-entry { position: relative; gap: .08rem; }
.lo-entry__icon { width: 1.25em; height: 1.25em; object-fit: contain; flex: none; } .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", "【奇迹】下次刷新不消耗资金": "[Marvel] Your next refresh is free",
"【维多利亚】获得{n}件维式重锤": "[Victoria] Obtained {n} {n|Victorian Hammer|Victorian Hammers}", "【维多利亚】获得{n}件维式重锤": "[Victoria] Obtained {n} {n|Victorian Hammer|Victorian Hammers}",
"【远见】所有干员购买价格永久降低": "[Foresight] Every Operator's purchase price is permanently reduced", "【远见】所有干员购买价格永久降低": "[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. // 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 // 干员调配 (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 // 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 // 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. // 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). // 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 { GuideHost } from './ui/guide.js';
import { installDeviceSupport } from './ui/device.js'; import { installDeviceSupport } from './ui/device.js';
import { LoadoutHost } from './screens/loadout.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 { startBuildGuard } from './ui/buildGuard.js';
import { initLang, useLang, tickerText } from './ui/lang.js'; import { initLang, useLang, tickerText } from './ui/lang.js';
import { t, N_, translateWire } from '../../shared/i18n.js'; import { t, N_, translateWire } from '../../shared/i18n.js';
@@ -340,6 +341,7 @@ async function boot() {
wireNet(); wireNet();
installLoadoutSync({ net }); installLoadoutSync({ net });
installOwnershipSync({ net }); installOwnershipSync({ net });
installDiySync({ net });
net.attachBrowserHooks(); net.attachBrowserHooks();
// Audio: unlock on first gesture, BGM follows the route / match phase (js/audio.js). // 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() }); 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 penList = null;
let ownPen = null; // the own m.private.nextEnemies (fallback composition of a scouted teammate's pen) 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 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 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 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) 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), timeScale: () => (mode === 'battle' ? interp.rate : 1),
// (a chess fighting as its 补位 stand-in — `standInFor` — reads the stand-in's record: its attack interval) // (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) 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, crowded: () => views.size > 90,
renderer: app.renderer, renderer: app.renderer,
frameNo: () => frameNo, frameNo: () => frameNo,
@@ -304,7 +305,7 @@ export async function createFieldView(host, options = {}) {
timeScale: () => ctx.timeScale(), timeScale: () => ctx.timeScale(),
loadLevel: () => loadLevel, loadLevel: () => loadLevel,
fieldRect: () => (mode === 'battle' && battleMeta ? battleMeta.rect : null), 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, view: (id) => views.get(id) || null,
screenSize: size, screenSize: size,
fieldTop: () => { 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 — // 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) // 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 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 { return {
kind: 'op', side: 'ally', defId: piece.id, kind: 'op', side: 'ally', defId: piece.id,
spine: rec?.assets?.spine || rec?.charId || null, avatar: rec?.assets?.avatar || rec?.charId || null, 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, tier: chess?.tier || piece.tier || 1, golden: !!(piece.golden || chess?.isGolden), dir,
...(si ? { standInFor: si.standInFor } : null), ...(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 : {}; const src = ps && typeof ps === 'object' ? ps : {};
standInList = Array.isArray(src.standIns) ? src.standIns.filter((x) => typeof x === 'string') : []; 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; ownPen = Array.isArray(src.nextEnemies) ? src.nextEnemies : null;
setPenList(ownPen); setPenList(ownPen);
addList(src.hand, 'hand'); addList(src.hand, 'hand');
+20 -2
View File
@@ -2,6 +2,7 @@
import { createAssets, assets as defaultAssets } from '../../assets.js'; import { createAssets, assets as defaultAssets } from '../../assets.js';
import { standInRecord } from '../../../../shared/standIn.js'; import { standInRecord } from '../../../../shared/standIn.js';
import { diyRecordOf } from '../../../../shared/diy.js';
function withTimeout(p, ms) { function withTimeout(p, ms) {
return Promise.race([p, new Promise((resolve) => setTimeout(resolve, ms))]); return Promise.race([p, new Promise((resolve) => setTimeout(resolve, ms))]);
@@ -40,10 +41,27 @@ function makeData(src) {
standIns.set(c, rec); standIns.set(c, rec);
return 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 { 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), 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 / // 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 // 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, 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, snapHud, activeBubbles, shortcutFor, shortcutBlocked, closesOnFieldPress, phaseTotalSeconds, homeFieldId, ownFieldId, normalizeSp, sortedPlayers,
countdownState, shopBlockReason, stageOverrides, effectiveStage, watchTarget, dropFailureReason, countdownState, shopBlockReason, stageOverrides, effectiveStage, watchTarget, dropFailureReason,
previewEnemyKey, prepCamera, prepCameraFor, foldCamera, deployFieldOf, panelSide, panelSlots, bondPopupPlace, unitLoadout, deployedRecord, previewEnemyKey, prepCamera, prepCameraFor, foldCamera, deployFieldOf, panelSide, panelSlots, bondPopupPlace, unitLoadout, deployedRecord,
mergeTarget, modeOffBonds, readyFundsPrompt, ownerBandId, mergeTarget, modeOffBonds, readyFundsPrompt, ownerBandId, ownDiyRecord,
} from '../ui/gameLogic.js'; } from '../ui/gameLogic.js';
import { toast } from '../ui/toasts.js'; import { toast } from '../ui/toasts.js';
import { BriefingScreen } from './briefing.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 // 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) // prep (user playtest #5 item 7: legality and the legal-tile highlights read THOSE tiles, like the server)
const deployField = deployFieldOf(pub, myId); 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({ const placeCtx = useMemo(() => placementContext({
priv, stage: gd.stage(pub?.stageId), editable, field: deployField, 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]); }), [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 }; 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 — // 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) // 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 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 runIntent = async (intent) => {
const L = live.current; const L = live.current;
if (intent.confirmReplace) { if (intent.confirmReplace) {
// both slots used: the player picks the equipped item to destroy (cancel ⇒ nothing is sent) // 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) { if (request) {
const uid = await openReplaceRef.current(request); const uid = await openReplaceRef.current(request);
if (!Number.isInteger(uid)) { audio.sfx('back', { volume: 0.5 }); return; } 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). */ /** Open the direction wheel for a legal board drop (the piece stays on the tile meanwhile). */
const openFacing = (entry, t) => { const openFacing = (entry, t) => {
const piece = entry.piece; 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 }); holdPiece(view, piece.uid, { row: t.row, col: t.col });
setSel(null); setSel(null);
setFacing({ uid: piece.uid, piece, row: t.row, col: t.col, grid: previewGrid(lookups, piece), name: rec?.name || '' }); 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 ------------------------------------ // ---- 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 // 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) // 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, const lookups = useMemo(() => ({ getChess: ownChess, getToken: gd.token, getItem: gd.item,
chessRecord: (rec) => deployedRecord(rec, live.current.priv, gd.chess, gd.backups) }), [gd.ready]); 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 heldRef = useRef(new Map()); // uid → { row, col, t } committed placements awaiting m.private
const releaseHold = useCallback((uid) => { const releaseHold = useCallback((uid) => {
heldRef.current.delete(uid); heldRef.current.delete(uid);
@@ -1200,7 +1204,7 @@ function MatchScreen() {
<div class="gm__vignette" aria-hidden="true"></div> <div class="gm__vignette" aria-hidden="true"></div>
${tempNotice ? html`<${TempRowNotice} view=${view} count=${temp.count} items=${temp.items} label=${!drag && !facing} ${tempNotice ? html`<${TempRowNotice} view=${view} count=${temp.count} items=${temp.items} label=${!drag && !facing}
ready=${phase === PHASE.PREP && !!priv?.ready} />` : null} 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}> <div class="gm__hud" ref=${hudElRef}>
<${TopBar} pub=${pub} priv=${priv} conn=${conn} hud=${hud} total=${total} drawer=${drawer} <${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} ${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} 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} onRetreat=${retreatSel} onSell=${sellSel} onDestroy=${sellSel} />` : null}
</div> </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, // 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 // 临时整备区, 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 // 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 // chess it is either way. 0.2.0 自选编队: the 「自选」 tag under the player's own pieces of a DIY slot it filled
// direction wheel follows its tile); never takes the pointer, so the pieces stay draggable. // (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 { useEffect, useRef, useState } from '../../../vendor/hooks.module.js';
import { html } from '../../ui/components.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; const rawOf = (view) => (view && view.raw) || view || null;
@@ -30,9 +33,27 @@ export function standInPieces(priv, getChess, backups) {
return out; return out;
} }
/** @param {{ view: any, priv: any, getChess: (id: string) => any, backups: any }} props */ /**
export function StandInTags({ view, priv, getChess, backups }) { * The player's own prep pieces of a DIY slot it filled (0.2.0 自选编队): [{ uid, label, name, area, diy: true }].
const items = standInPieces(priv, getChess, backups); * @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 [pos, setPos] = useState({});
const last = useRef(''); const last = useRef('');
const key = items.map((it) => it.uid).join(','); 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"> return html`<div class="sitags" aria-hidden="true" data-testid="standin-tags">
${items.map((it) => { ${items.map((it) => {
const p = pos[it.uid]; 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; style=${`left:${p.x.toFixed(1)}px;top:${p.y.toFixed(1)}px`}>${it.label}</span>` : null;
})} })}
</div>`; </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 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 // 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 导出 / 导入 / 全部持有. // 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). // Keyboard: Esc closes, ←/→ move through the (filtered) roster when focus is not in the search field (干员调配 tab).
import { useEffect, useMemo, useRef, useState } from '../../vendor/hooks.module.js'; import { 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, MODULE_NONE, PROF_ORDER, PROF_NAME, rosterOf, filterRoster, recordsOf, chessOptions, effectiveChoice, setChoice, resetChoice,
changedCount, skillLabel, moduleBadge, attrRows, skillTags, serializeExport, parseImport, LOADOUT_IMPORT_MAX_BYTES, changedCount, skillLabel, moduleBadge, attrRows, skillTags, serializeExport, parseImport, LOADOUT_IMPORT_MAX_BYTES,
} from '../ui/loadoutModel.js'; } 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 { setOwned, notOwnedCount, serializeOwnership, parseOwnershipImport, OWNERSHIP_IMPORT_MAX_BYTES } from '../ui/ownershipModel.js';
import { OwnershipPanel, useOwnershipRoster } from './ownership.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 { copyText } from '../ui/clipboard.js';
import { toast } from '../ui/toasts.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') { function exportFilename(now = new Date(), what = 'loadout') {
const p = (n) => String(n).padStart(2, '0'); 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`; 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. */ /** The 干员持有 tab's status line: the setting never applies to a running match. */
const OWN_SYNC_TEXT = { ...SYNC_TEXT, locked: ['下一局生效', 'is-warn'] }; 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. */ /** The overlay screen. */
function LoadoutScreen({ st }) { 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 phase = useStore((s) => s.match?.public?.phase || null);
const inMatch = useStore((s) => !!s.room?.inMatch); 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 // 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 [narrowDetail, setNarrowDetail] = useState(false); // phones: the detail slides over the roster
const [io, setIo] = useState(null); // 导出 / 导入 dialog: { mode, text } | null 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 ownRoster = useOwnershipRoster(ready);
const nNotOwned = notOwnedCount(st.notOwned, ownRoster); 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 setTab = (t) => loadoutStore.set({ tab: t });
const toggleOwned = (id, owned) => setNotOwned(setOwned(loadoutStore.get().notOwned, id, owned)); const toggleOwned = (id, owned) => setNotOwned(setOwned(loadoutStore.get().notOwned, id, owned));
const ownAll = async () => { const ownAll = async () => {
@@ -434,27 +450,38 @@ function LoadoutScreen({ st }) {
// the clipboard, or the textarea); `io.kind` says which // the clipboard, or the textarea); `io.kind` says which
const ioText = io?.text ?? ''; const ioText = io?.text ?? '';
const ioOwn = io?.kind === 'ownership'; const ioOwn = io?.kind === 'ownership';
const ioDiy = io?.kind === 'diy';
const openExport = () => setIo(tab === 'ownership' const openExport = () => setIo(tab === 'ownership'
? { mode: 'export', kind: 'ownership', text: serializeOwnership(loadoutStore.get().notOwned) } ? { 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 openImport = () => setIo({ mode: 'import', kind: tab, text: '' });
const ioCopy = async () => { const ioCopy = async () => {
const ok = await copyText(ioText); const ok = await copyText(ioText);
toast(ok ? '已复制到剪贴板' : '复制失败,请在文本框中手动全选复制', ok ? 'success' : 'warn'); 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 ioPick = () => fileRef.current?.click();
const ioFile = async (e) => { const ioFile = async (e) => {
const f = e.currentTarget.files && e.currentTarget.files[0]; const f = e.currentTarget.files && e.currentTarget.files[0];
e.currentTarget.value = ''; // picking the same file twice must fire again e.currentTarget.value = ''; // picking the same file twice must fire again
if (!f) return; if (!f) return;
// refuse a huge pick before reading it into memory (a real payload is a few KB) // 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; } 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'); } try { setIo({ ...io, mode: 'import', text: await readFileText(f) }); } catch { toast('读取文件失败', 'error'); }
}; };
const ioApply = () => { const ioApply = () => {
// an import before chess.json is loaded would sanitise every entry away — refuse instead of wiping the loadout // an import before chess.json is loaded would sanitise every entry away — refuse instead of wiping the loadout
if (!ready) { toast('干员数据仍在载入,请稍候再导入', 'warn'); return; } 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) { if (ioOwn) {
const r = parseOwnershipImport(ioText); const r = parseOwnershipImport(ioText);
if (!r.ok) { toast(`导入失败:${r.error}`, 'error'); return; } 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); const typing = e.target && /^(INPUT|SELECT|TEXTAREA)$/.test(e.target.tagName);
if (e.key === 'Escape') { e.preventDefault(); e.stopImmediatePropagation(); closeLoadout(); return; } if (e.key === 'Escape') { e.preventDefault(); e.stopImmediatePropagation(); closeLoadout(); return; }
if (typing) 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); const ids = filterRoster(rosterOf(data.list('chess')), loadoutStore.get().filters, loadoutStore.get().entries, getChess, getBond).map((c) => c.chessId);
if (!ids.length) return; if (!ids.length) return;
const cur = Math.max(0, ids.indexOf(loadoutStore.get().sel)); 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' }); if (el && typeof el.scrollIntoView === 'function') el.scrollIntoView({ block: 'nearest' });
}, [selId]); }, [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' ? '确认本局信息阶段结束前可调整本局配置' : '开始模拟前可调整干员携带的技能与模组,干员等级不可调整'; const fromText = st.from === 'briefing' ? '确认本局信息阶段结束前可调整本局配置' : '开始模拟前可调整干员携带的技能与模组,干员等级不可调整';
// 干员持有 is out of match: a running match keeps the list its seat had at its start // 干员持有 is out of match: a running match keeps the list its seat had at its start
const ownLocked = inMatch && !!phase && phase !== PHASE.LOBBY; const ownLocked = inMatch && !!phase && phase !== PHASE.LOBBY;
const ownText = ownLocked ? '干员持有是局外设置:本局按开局时的设置进行,修改将在下一局生效' const ownText = ownLocked ? '干员持有是局外设置:本局按开局时的设置进行,修改将在下一局生效'
: '局外设置,下一局生效 · 联机时只影响你自己的棋子 · 默认全部持有'; : '局外设置,下一局生效 · 联机时只影响你自己的棋子 · 默认全部持有';
const diyText = ownLocked ? t('自选编队是局外设置:本局按开局时的设置进行,修改将在下一局生效')
: t('局外设置,下一局生效 · 联机时只进入你自己的商店');
return html`<${Fragment}> return html`<${Fragment}>
<div class="lo" role="dialog" aria-modal="true" aria-label="干员调配"> <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)">返回<//> <${Button} variant="ghost" size="md" icon="chevronLeft" class="lo-back" onClick=${closeLoadout} aria-label="返回" title="返回 (Esc)">返回<//>
</div> </div>
<div class="lo-top__center"> <div class="lo-top__center">
<${MicroLabel} tone="mint">${tab === 'ownership' ? 'OPERATOR ROSTER' : 'OPERATOR LOADOUT'}<//> <${MicroLabel} tone="mint">${tab === 'ownership' ? 'OPERATOR ROSTER' : tab === 'diy' ? 'SELF-SELECT SQUAD' : 'OPERATOR LOADOUT'}<//>
<div class="lo-tabs" role="tablist" aria-label="干员调配 / 干员持有"> <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')}> <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> <${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')} <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> 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>
</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} ${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> <span class="lo-count">未持有 <b class="num">${nNotOwned}</b><span class="num t-dim">/${ownRoster.length}</span></span>
<${Button} variant="ghost" size="sm" data-testid="ownership-export" disabled=${!nNotOwned} onClick=${openExport} title="导出干员持有(可复制或下载)">导出<//> <${Button} variant="ghost" size="sm" data-testid="ownership-export" disabled=${!nNotOwned} onClick=${openExport} title="导出干员持有(可复制或下载)">导出<//>
@@ -541,10 +579,14 @@ function LoadoutScreen({ st }) {
<${Button} variant="secondary" size="sm" icon="refresh" disabled=${!nChanged} onClick=${resetAll}>全部恢复默认<//> <${Button} variant="secondary" size="sm" icon="refresh" disabled=${!nChanged} onClick=${resetAll}>全部恢复默认<//>
</div>`} </div>`}
</header> </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', ownLocked && 'is-locked')}><${Icon} name="info" />${ownText}</p>`
: html`<p class=${cx('lo-note', locked && 'is-locked')}><${Icon} name="info" />${locked ? '本局的调配已锁定(确认本局信息后无法修改),修改将在下一局生效' : fromText}</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`<${OwnershipPanel} m=${m} roster=${ownRoster} notOwned=${st.notOwned} onToggle=${toggleOwned} />`
: html`<main class=${cx('lo-body', narrowDetail && 'is-detail')}> : html`<main class=${cx('lo-body', narrowDetail && 'is-detail')}>
<section class="lo-roster"> <section class="lo-roster">
@@ -562,7 +604,8 @@ function LoadoutScreen({ st }) {
</main>`} </main>`}
</div> </div>
${io ? html`<${Modal} open=${true} onClose=${() => setIo(null)} ${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' actions=${io.mode === 'export'
? html`<${Button} variant="ghost" onClick=${() => setIo(null)}>关闭<//> ? html`<${Button} variant="ghost" onClick=${() => setIo(null)}>关闭<//>
<${Button} variant="secondary" icon="copy" data-testid="loadout-io-copy" onClick=${ioCopy}>复制<//> <${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)}>取消<//> : html`<${Button} variant="ghost" onClick=${() => setIo(null)}>取消<//>
<${Button} variant="secondary" data-testid="loadout-io-pick" onClick=${ioPick}>选择文件<//> <${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}>导入<//>`}> <${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' ? (io.mode === 'export'
? html`共 <b class="num">${nNotOwned}</b> 名干员未持有。复制或下载这份数据,即可在别的设备或浏览器上导入。` ? html`共 <b class="num">${nNotOwned}</b> 名干员未持有。复制或下载这份数据,即可在别的设备或浏览器上导入。`
: html`把导出的干员持有数据粘贴到下方,或点「选择文件」。导入会<strong>覆盖</strong>当前的干员持有设置。`) : html`把导出的干员持有数据粘贴到下方,或点「选择文件」。导入会<strong>覆盖</strong>当前的干员持有设置。`)
@@ -578,7 +625,7 @@ function LoadoutScreen({ st }) {
? html`共 <b class="num">${nChanged}</b> 名干员已调整。复制或下载这份数据,即可在别的设备或浏览器上导入。` ? html`共 <b class="num">${nChanged}</b> 名干员已调整。复制或下载这份数据,即可在别的设备或浏览器上导入。`
: html`把导出的内容粘贴到下方,或点「选择文件」。${nChanged ? html`导入会<strong>覆盖</strong>当前的 ${nChanged} 名干员调配。` : null}`}</p> : 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} <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> 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} /> <input type="file" accept=".json,application/json,text/plain" class="lo-io__file" ref=${fileRef} onChange=${ioFile} />
<//>` : null} <//>` : 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) useData('local'); // the official preset icon (re-render once the local-art manifest arrives)
const entries = useStore((s) => s.entries, Object.is, loadoutStore); const entries = useStore((s) => s.entries, Object.is, loadoutStore);
const notOwned = useStore((s) => s.notOwned, 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); 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 // 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; 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" return html`<button type="button" class=${cx('btn', `btn--${variant}`, `btn--${size}`, 'lo-entry', cls)} data-testid="loadout-open"
onClick=${() => openLoadout(from)} title="调整干员携带的技能与模组 · 干员持有"> onClick=${() => openLoadout(from)} title="调整干员携带的技能与模组 · 干员持有">
<${Img} src=${localAsset('ui/outer', 'operator_preset')} class="lo-entry__icon" fallback=${html`<${Icon} name="edit" class="btn__icon" />`} /> <${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> <span class="btn__label">${label}</span>
${n ? html`<span class="lo-entry__n num" aria-label=${`${n} 名干员已调整`}>${n}</span>` : null} ${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} ${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>`; </button>`;
} }
+7 -3
View File
@@ -6,7 +6,9 @@
// { victory, roundsPassed, lastRound?, hiddenCleared?, bossId?, hiddenBossId?, difficulty?, modeId?, durationMs?, // { victory, roundsPassed, lastRound?, hiddenCleared?, bossId?, hiddenBossId?, difficulty?, modeId?, durationMs?,
// players: [{ playerId, seat, name, isBot, alive, lp, bandId, roundsPassed?, // players: [{ playerId, seat, name, isBot, alive, lp, bandId, roundsPassed?,
// title: 'comment_1' | { id, name? } | null, // 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 }], // bonds?: [{ bondId, layers, active }],
// stats: { dmgDealt, kills, leaks, gold /* funds SPENT */, refreshes, merges, bossDamage?, itemsEquipped?, // stats: { dmgDealt, kills, leaks, gold /* funds SPENT */, refreshes, merges, bossDamage?, itemsEquipped?,
// activatedLayers?, lpLost?, perfectRounds? } }] } // activatedLayers?, lpLost?, perfectRounds? } }] }
@@ -14,7 +16,8 @@
import { useEffect } from '../../vendor/hooks.module.js'; import { useEffect } from '../../vendor/hooks.module.js';
import { html, Button, Icon, MicroLabel, DifficultyTag } from '../ui/components.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 { 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 { enemyIconUrl, titleIconUrl, uiUrl } from '../ui/assetUrls.js';
import { store, useStore, emptyMatch } from '../store.js'; import { store, useStore, emptyMatch } from '../store.js';
import { audio } from '../audio.js'; import { audio } from '../audio.js';
@@ -46,7 +49,8 @@ function PlayerCard({ p, myId, titles, best, solo = false }) {
</div> </div>
<div class="rcard__mid"> <div class="rcard__mid">
<div class="rcard__lineup"> <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>`} : html`<span class="rcard__noinfo">${p.alive === false ? '阵容已撤离' : 'NO INFO'}</span>`}
</div> </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}> ${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 { html, BondDisc, Icon, MicroLabel, Tooltip } from './components.js';
import { RichText, UnitThumb, BondGlyph, GIcon } from './gameComponents.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 { formatBondEffect } from './richText.js';
import { bondIconUrl } from './assetUrls.js'; import { bondIconUrl } from './assetUrls.js';
import { data } from '../data.js'; import { data } from '../data.js';
import { t } from '../../../shared/i18n.js';
const cx = (...p) => p.flat().filter(Boolean).join(' '); 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 th = Array.isArray(entry?.thresholds) && entry.thresholds.length ? entry.thresholds : b.thresholds || [];
const tier = off ? 0 : entry?.tier ?? bondTier(count, th, b.maxCount); const tier = off ? 0 : entry?.tier ?? bondTier(count, th, b.maxCount);
const active = !off && (entry ? !!entry.active : tier > 0); const active = !off && (entry ? !!entry.active : tier > 0);
const getChess = (id) => data.lookup('chess', id); // (自选 pieces are their operators: the own ones by m.private.diy, a watched teammate's by the pick its unit carries)
const members = bondMembers(b, priv, banned, getChess, (id) => data.lookup('items', id)); 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 countsHand = entry?.countsHand ?? b.countsHand;
const next = nextThreshold(count, th); const next = nextThreshold(count, th);
const hasNow = !off && !!(b.effectDescRaw || b.effectDesc); 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')} ${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} 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 ? '(整备区)' : ''}`}> 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> <span class="bpop__mname">${mb.name}</span>
${mb.banned ? html`<span class="bpop__ban"><${Icon} name="close" /></span>` : null} ${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.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>`)} </button>`)}
</div> </div>
</section> </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). // 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 { html, Icon, TierChip, MicroLabel, Button, confirmDialog, useTicker } from './components.js';
import { Img, RichText, UnitThumb, BondGlyph, GIcon } from './gameComponents.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 } from './gameLogic.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 { chessPortraitUrl, skillIconUrl, skillRecordIconUrl, profIconUrl, subProfIconUrl, itemIconUrl, enemyIconUrl, tokenAvatarUrl, factionIconUrl, uiUrl, moduleTypeIconUrl } from './assetUrls.js';
import { abilityRows } from './abilityLines.js'; import { abilityRows } from './abilityLines.js';
import { data } from '../data.js'; import { data } from '../data.js';
import { attackRangeGrid } from '../../../shared/loadoutRecord.js'; import { attackRangeGrid } from '../../../shared/loadoutRecord.js';
import { SKILL_SUMMON_START_DEPLOY } from '../../../shared/constants.js'; import { SKILL_SUMMON_START_DEPLOY } from '../../../shared/constants.js';
import { moduleBadge } from './loadoutModel.js'; import { moduleBadge } from './loadoutModel.js';
import { t } from '../../../shared/i18n.js';
const cx = (...p) => p.flat().filter(Boolean).join(' '); const cx = (...p) => p.flat().filter(Boolean).join(' ');
@@ -354,10 +355,12 @@ export function chessStatsBlock({ rec, chess, live = null }) {
</div>`; </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 m = data.get('assets');
const hp = hpOf(live, snapHp); 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 // 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) // stand-in's body — portrait, class, 特性, stats, range, skill, talents, module (its backup selection, no loadout)
const si = standIn && standIn.standInFor ? standIn : null; 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} ${golden ? html`<span class="dtag-elite">精锐</span>` : null}
${piece?.kind === 'token' ? html`<span class="dtag-token">召唤物</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} ${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> </div>
<h3 class="dhead__name">${c.name}</h3> <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> <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 {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 * @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 * 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') } = {}) { export function resolveDetail(target, pieces, { priv = null, backups = data.get('backups') } = {}) {
if (!target) return null; if (!target) return null;
const ownStandIn = (c) => (fieldsStandIn(priv, c) ? standInOf(c, backups) : 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') { if (target.kind === 'piece') {
const e = pieces?.get(target.uid); const e = pieces?.get(target.uid);
if (!e) return null; if (!e) return null;
const p = e.piece; 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 === '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 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; return c ? { type: 'chess', chess: c, piece: p, standIn: ownStandIn(c) } : null;
} }
if (target.kind === 'chess') { 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`) // (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) // 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 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; 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 === '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 === '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') { if (target.kind === 'unit') {
const u = target.unit || {}; const u = target.unit || {};
const own = Number.isInteger(u.uid) ? pieces?.get(u.uid) : null; 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; let si = null;
if (c && typeof u.standInFor === 'string' && u.standInFor) si = standInOf(c, backups); if (c && typeof u.standInFor === 'string' && u.standInFor) si = standInOf(c, backups);
else if (c && own?.piece) si = ownStandIn(c); 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 }; 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) }; if (t) return { type: 'token', token: t, unitId: u.id, ownerId: tokenOwnerId(own?.piece, pieces) };
const en = data.lookup('enemies', u.defId); const en = data.lookup('enemies', u.defId);
return en ? { type: 'enemy', enemy: en, unitId: u.id } : null; 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"> <div class="dpanel__scroll">
${detail.type === 'chess' ? html`<${ChessDetail} chess=${detail.chess} piece=${detail.piece} snapHp=${snapHp} editable=${editable} onSell=${sellIt} ${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} 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 === '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 === '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} ${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 { html, TierChip } from './components.js';
import { GEO } from '../../../shared/constants.js'; import { GEO } from '../../../shared/constants.js';
import { chessAvatarUrl, itemIconUrl, tokenAvatarUrl, enemyIconUrl } from './assetUrls.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 DRAG_PX = 6;
const DMG_TTL = 900; const DMG_TTL = 900;
@@ -126,12 +126,17 @@ export function createFallbackView(host, opts = {}) {
const chess = lookup('chess', p.id); const chess = lookup('chess', p.id);
// 0.2.0 补位: on the board a chess the player does not own is its stand-in (render/app.js pieceInfo) // 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; 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) { function pieceName(p) {
if (p.kind === 'item') return lookup('items', p.id)?.name || '道具'; if (p.kind === 'item') return lookup('items', p.id)?.name || '道具';
if (p.kind === 'token') return lookup('tokens', 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 }) { function Piece({ p, x, y, L, area }) {
+12 -4
View File
@@ -26,6 +26,12 @@ export function useGameData() {
return useMemo(() => makeLookups(ready), [ready]); 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). */ /** Non-hook lookups (for event handlers). */
export function makeLookups(ready = true) { export function makeLookups(ready = true) {
return { return {
@@ -39,7 +45,8 @@ export function makeLookups(ready = true) {
enemy: (k) => data.lookup('enemies', k), enemy: (k) => data.lookup('enemies', k),
boss: (id) => data.lookup('bosses', id), boss: (id) => data.lookup('bosses', id),
stage: (id) => data.lookup('stages', 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), effect: (id) => data.lookup('effects', id),
garrison: (id) => data.lookup('garrisons', id), garrison: (id) => data.lookup('garrisons', id),
factions: data.get('factions'), 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. * 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, * @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'); const m = data.get('assets');
let src = null; let src = null;
let name = ''; let name = '';
let t = tier; let t = tier;
if (kind === 'chess') { if (kind === 'chess') {
const c = data.lookup('chess', id); const c = rec || data.lookup('chess', id);
src = chessAvatarUrl(m, c); src = chessAvatarUrl(m, c);
name = c?.name || ''; name = c?.name || '';
t = t ?? c?.tier; 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 { normalizeResult } from './gameLogic/result.js';
export { chessLoadout, unitLoadout } from './gameLogic/loadout.js'; export { chessLoadout, unitLoadout } from './gameLogic/loadout.js';
export { standInIds, fieldsStandIn, standInOf, cardStandIn, standInGetter, standInLoadout, deployedRecord, deployedModuleId, standInLabel } from './gameLogic/standIn.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'; 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 * 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, * 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). * 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. * `inHand`: in the 整备区 (hand), not the 5 temporary slots — what BOARD_AND_DECK bonds (投资人 远见 奇迹) count.
* @param {any} bond bonds.json record * @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 {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 {Set<string>|string[]} [banned] banned base chess ids
* @param {(id:string)=>any} [getChess] * @param {(id:string)=>any} [getChess]
* @param {(id:string)=>any} [getItem] items.json lookup (the 变形同构体 pairings) * @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 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 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); 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 变形同构体 */ /** 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 granted = new Map();
const grants = (p) => typeof bond?.bondId === 'string' && grantedBonds(p.items, getItem).includes(bond.bondId); 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'); 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 : []) { for (const p of Array.isArray(priv?.board) ? priv.board : []) {
if (p?.kind !== 'chess') continue; if (p?.kind !== 'chess') continue;
const base = baseOf(p.id); const base = baseOf(p.id);
onBoard.add(base); owned.add(base); 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) }); 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]]) { 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); const base = baseOf(p.id);
owned.add(base); owned.add(base);
if (hand) inHand.add(base); if (hand) inHand.add(base);
if (diyOf(p, false, hand)) continue;
if (memberSet.has(base) || !grants(p)) continue; if (memberSet.has(base) || !grants(p)) continue;
const g = granted.get(base); const g = granted.get(base);
if (!g) granted.set(base, { on: false, hand, items: itemIds(p) }); 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); 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 }); 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)); 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 // `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 / // per-browser not-owned list of the 干员持有 tab (`notOwned`, likewise), the 自选编队 picks (`diy`, likewise; with
// tab / selection / filters). `installLoadoutSync()` / `installOwnershipSync()` (called once by main.js) keep the // `diyKitted` = the operators the server lets a DIY slot field, from every `welcome`) and the overlay's screen state
// server's copies current — one sync engine (installPrefSync), two settings: after every `welcome` (new or resumed // (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 // 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 }` // 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 // (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 whole loadout), `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 // the rest, so no data is needed) and `room.diy { picks }` (sent as stored, structurally clean: the server keeps the
// what it took (the loadout locks when INFO_CHECK ends; the ownership never changes during a match) — stored for the // legal picks). Replies: RATE → retried later; WRONG_PHASE / ROOM_STARTED → the running match keeps what it took (the
// next match, not an error for the player; anything else is logged. `sync` / `ownSync` ∈ 'idle' | 'pending' | // loadout locks when INFO_CHECK ends; the ownership and the 自选 picks never change during a match) — stored for the
// 'sending' | 'synced' | 'locked' | 'error' are mirrored into the store for the screen's status line. // 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 { createStore, loadPref, savePref } from '../store.js';
import { data } from '../data.js'; import { data } from '../data.js';
import { LOADOUT_PREF, parseStored, toStored, sanitizeEntries } from './loadoutModel.js'; import { LOADOUT_PREF, parseStored, toStored, sanitizeEntries } from './loadoutModel.js';
import { OWNERSHIP_PREF, parseStoredOwnership, toStoredOwnership, cleanIds, sanitizeNotOwned } from './ownershipModel.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 { toast } from './toasts.js';
import { t } from '../../../shared/i18n.js';
export const SYNC_DEBOUNCE_MS = 500; export const SYNC_DEBOUNCE_MS = 500;
export const RETRY_MS = 1500; export const RETRY_MS = 1500;
@@ -28,18 +33,24 @@ function readStored() {
function readStoredOwnership() { function readStoredOwnership() {
try { return parseStoredOwnership(loadPref(OWNERSHIP_PREF, null)); } catch { return []; } 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). */ /** Loadout + ownership + screen state (separate from the app store: it must survive room / match resets). */
export const loadoutStore = createStore({ export const loadoutStore = createStore({
entries: readStored(), entries: readStored(),
notOwned: readStoredOwnership(), // 干员持有: base chess ids marked 未持有 (sorted; [] = every operator owned) 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, open: false,
from: null, // 'lobby' | 'room' | 'briefing' from: null, // 'lobby' | 'room' | 'briefing'
tab: 'loadout', // 'loadout' (干员调配) | 'ownership' (干员持有) tab: 'loadout', // 'loadout' (干员调配) | 'ownership' (干员持有) | 'diy' (自选编队)
sel: null, // selected base chess id sel: null, // selected base chess id
filters: { tier: null, prof: null, bond: null, query: '', changedOnly: false }, filters: { tier: null, prof: null, bond: null, query: '', changedOnly: false },
sync: 'idle', sync: 'idle',
ownSync: 'idle', ownSync: 'idle',
diySync: 'idle',
}); });
/** Replace the stored entries (persisted at once; the sync picks the change up). */ /** 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) }; 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] * 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) { export function openLoadout(from = 'lobby', sel = null, tab = null) {
data.load('chess'); data.load('chess');
@@ -96,7 +128,7 @@ export function openLoadout(from = 'lobby', sel = null, tab = null) {
data.load('assets'); data.load('assets');
data.load('local'); data.load('local');
data.load('backups'); 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 }); 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 }) { 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 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 timer = null;
let seq = 0; // requests sent (the reply of an older one never overrides a newer one's state) let seq = 0; // requests sent (the reply of an older one never overrides a newer one's state)
let pendingJson = null; // JSON of the newest request still awaiting its reply let 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), 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 // §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 // 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, // 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 { useEffect, useState } from '../../vendor/hooks.module.js';
import { html, Icon, HexBadge, TierChip, Tooltip, MicroLabel } from './components.js'; import { html, Icon, HexBadge, TierChip, Tooltip, MicroLabel } from './components.js';
import { Img, BondGlyph, CoinGlyph, GIcon, RichText } from './gameComponents.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 { chessPortraitUrl, itemIconUrl, profIconUrl, uiUrl, skillIconUrl, skillRecordIconUrl, moduleTypeIconUrl } from './assetUrls.js';
import { data } from '../data.js'; import { data } from '../data.js';
import { t } from '../../../shared/i18n.js';
const cx = (...p) => p.flat().filter(Boolean).join(' '); 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 * 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 }) { 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 m = data.get('assets');
const tier = c?.tier ?? 1; const tier = c?.tier ?? 1;
const prog = mergeProgress(priv, slot.id, (id) => data.lookup('chess', id)); 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; const disabled = !!reason;
// 0.2.0 补位: the player's not-owned chess is deployed as its stand-in — badge + the stand-in's skill // 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 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 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')} 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); }} 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>` : null}
</span> </span>
${si ? html`<span class="scard__standin" data-standin=${si.charId} title=${`未持有:由替补干员 ${si.name} 上场(盟约、阶级与价格不变)`}>${standInLabel(si)}</span>` : null} ${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"> <span class="scard__body">
${lo?.skill ? html`<${SkillBadge} chess=${si || c} lo=${lo} standIn=${!!si} />` : null} ${lo?.skill ? html`<${SkillBadge} chess=${si || c} lo=${lo} standIn=${!!si} />` : null}
<span class="scard__name">${c?.name || slot.id}</span> <span class="scard__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; if (!fromField && !fromBattle) return null;
const board = [...(fromField || []), ...(fromBattle || [])] const board = [...(fromField || []), ...(fromBattle || [])]
.filter((u) => isObj(u) && u.ownerId === ownerId && u.kind === 'op' && typeof u.defId === 'string') .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: [] }; 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 // 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', // 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. // 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): // * 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 // 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 // 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 // {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 / // 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 // 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 // only g.watch (the heavy bucket, like every watcher), g.leave / room.leave, and room.loadout / room.ownership /
// for its session, never handed to the match); anything else → SPECTATOR (▸ emotes too). Host: room.removeSpectator { playerId } any // 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 // 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 // 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). // a player seat (the seat is kept and given back on resume).
import { randomBytes, randomInt } from 'node:crypto'; import { randomBytes, randomInt } from 'node:crypto';
import { ERR, MAX_SEATS, MAX_SPECTATORS, ROOM_CODE_LEN, modeIdFor } from '../shared/constants.js'; 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 { encode, isDroppable, isErrCode, sendRaw, sendSession } from './net.js';
import { getData as defaultGetData, lookup } from './data.js'; import { getData as defaultGetData, lookup } from './data.js';
import { Match as DefaultMatch } from './match/Match.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). */ /** Room code alphabet: uppercase letters without I and O (and no digits, so no 0/1). */
export const CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ'; 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, * @typedef {{ seat: number, playerId: string, name: string, isBot: boolean, ready: boolean,
* connected: boolean, left: boolean, loadout?: Record<string, { skill: number, module: string|null }> | null, * 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). */ /** 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); 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. */ /** One room: 4 seat slots, host, difficulty, optional running match. */
export class Room { export class Room {
/** @param {string} code @param {'solo'|'coop'} mode @param {string} difficulty @param {number} now */ /** @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.start': return this.start(session);
case 'room.loadout': return this.loadout(session, msg); case 'room.loadout': return this.loadout(session, msg);
case 'room.ownership': return this.ownership(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.spectate': return this.spectate(session, msg);
case 'room.removeSpectator': return this.removeSpectator(session, msg); case 'room.removeSpectator': return this.removeSpectator(session, msg);
default: default:
@@ -602,6 +622,29 @@ export class Lobby {
return OK; 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 // Match wiring
// --------------------------------------------------------------------------------------------------- // ---------------------------------------------------------------------------------------------------
@@ -616,6 +659,8 @@ export class Lobby {
loadout: s.isBot ? null : s.loadout || null, loadout: s.isBot ? null : s.loadout || null,
// 0.2.0 补位: the chess the human marked as not owned (bots own every operator) // 0.2.0 补位: the chess the human marked as not owned (bots own every operator)
notOwned: s.isBot ? null : s.notOwned || null, 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. // 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() }; 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, seat: idx, playerId: session.playerId, name: session.name, isBot: false, ready: false, connected: session.connected, left: false,
loadout: session.loadout || null, loadout: session.loadout || null,
notOwned: session.notOwned || 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.modeId string modeIdFor(mode, difficulty), e.g. 'mode_multi_hard'
// opts.seats Array<{ seat: 0..3, playerId: string, name: string, isBot: boolean, connected: boolean, // opts.seats Array<{ seat: 0..3, playerId: string, name: string, isBot: boolean, connected: boolean,
// loadout?: { [baseChessId]: { skill: index, module: uniEquipId|'none'|null } } | null, // loadout?: { [baseChessId]: { skill: index, module: uniEquipId|'none'|null } } | null,
// notOwned?: string[] | null }> // notOwned?: string[] | null,
// diy?: { [slotBaseId]: { charId, skillIndex, uniEquipId } } | null }>
// sorted by seat, 1–4 entries, ≥ 1 human; solo ⇒ exactly 1 human and no bots. // 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). // 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 // `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. // (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 // `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). // 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; // opts.spectators string[] (optional) the room's spectator seats (remake feature, community report #26;
// server/lobby.js): never players — see addSpectator below // server/lobby.js): never players — see addSpectator below
// opts.seed uint32 master seed for all match randomness // opts.seed uint32 master seed for all match randomness
@@ -316,6 +319,10 @@ export class Match {
this.staticInactiveBonds = bans.staticOff; this.staticInactiveBonds = bans.staticOff;
this.bannedChess = bans.banned; this.bannedChess = bans.banned;
this.pool = new SharedPool(this.gd, { banned: 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.phase = PHASE.LOBBY;
this.round = 0; this.round = 0;
+25 -2
View File
@@ -81,6 +81,14 @@
// loadout never applies to it, "对于补位干员其技能不可更改"): battleInput marks it `standIn: true`, placeClass / // 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), // 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`. // 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 // 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, // 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 // round.js the round lifecycle the match calls (startRound, endPrep, eliminate, recompute, bond views) and
// battleInput // battleInput
// views.js pieceView, effectsView, m.private (privateView) // 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 // common.js HAND_SIZE, TEMP_SIZE, MAX_OFFER_SLOTS, OK, fail
import { FIELD } from './board.js'; import { FIELD } from './board.js';
@@ -113,11 +123,12 @@ import { PlayerItems } from './player/items.js';
import { PlayerPrep } from './player/prep.js'; import { PlayerPrep } from './player/prep.js';
import { PlayerRound } from './player/round.js'; import { PlayerRound } from './player/round.js';
import { PlayerViews } from './player/views.js'; import { PlayerViews } from './player/views.js';
import { PlayerDiy, DiyStock } from './player/diy.js';
export class PlayerState { export class PlayerState {
/** /**
* @param {import('./Match.js').Match} m owning match * @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) { constructor(m, seat) {
this.m = m; this.m = m;
@@ -148,6 +159,18 @@ export class PlayerState {
/** @type {Set<string>} lookup set of `standIns` */ /** @type {Set<string>} lookup set of `standIns` */
this._standInSet = new Set(); this._standInSet = new Set();
if (!this.isBot && seat.notOwned) this.setNotOwned(seat.notOwned); 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 }; 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 }] } */ /** reward offers queue (merge rewards, special refreshes): { tier, source, label, slots: [{ kind, id, price, sold }] } */
this.offers = []; 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) // 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)) { for (const key of Reflect.ownKeys(part.prototype)) {
if (key === 'constructor') continue; if (key === 'constructor') continue;
if (Object.prototype.hasOwnProperty.call(PlayerState.prototype, key)) throw new Error(`PlayerState.${String(key)} is defined twice`); 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', () => { check('shop roll', () => {
const base = gd.baseIdOf(s.id); const base = gd.baseIdOf(s.id);
if (t > ps.shop.level) fail(`${ps.playerId}: rolled tier ${t} at shop level ${ps.shop.level}`); 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)}`); 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}`); 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 // 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 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)); const want = mergeTile([...tiles.keys()].map((key) => ({ key })), (r, c) => canPlace(dmap, pos, r, c));
if (want) { 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}`); 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. * without inactive bonds keep exactly the earlier picks.
*/ */
export function botPickBand(m, ps) { export function botPickBand(m, ps) {
const gd = m.gd; const gd = ps?.gd || m.gd;
const ids = gd.bandIds(); const ids = gd.bandIds();
if (!ids.length) return gd.defaultBandId; if (!ids.length) return gd.defaultBandId;
const lateFunds = (id) => /暂存/.test(String(gd.band(id)?.desc || '')); 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. * first blocker's hold on the ground. 0.5 without data.
*/ */
export function bountyKillChance(m, ps, card) { export function bountyKillChance(m, ps, card) {
const gd = m.gd; const gd = ps?.gd || m.gd;
const e = card && gd.enemy(card.enemyKey); const e = card && gd.enemy(card.enemyKey);
if (!e || !e.stats) return 0.5; if (!e || !e.stats) return 0.5;
const model = fieldModel(m, ps); const model = fieldModel(m, ps);
@@ -274,7 +274,8 @@ function tacticScore(m, ps, card) {
// --------------------------------------------------------------------------------------------------- // ---------------------------------------------------------------------------------------------------
// evaluation helpers // 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'); 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) // 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'; 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); const base = m.gd.baseIdOf(p.id);
if (seen.has(base)) continue; if (seen.has(base)) continue;
seen.add(base); 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); for (const b of (c && c.bonds) || []) counts.set(b, (counts.get(b) || 0) + 1);
} }
return { counts, bases: seen }; return { counts, bases: seen };
@@ -393,7 +394,7 @@ function keeperBases(m, ps, plan) {
const out = new Set(); 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.board.values()) if (p.kind === 'chess') out.add(m.gd.baseIdOf(p.id));
for (const p of ps.allChess()) { for (const p of ps.allChess()) {
const c = chessRec(m, p.id); const c = chessRec(m, p.id, ps);
if (!c) continue; if (!c) continue;
const bonds = c.bonds || []; 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)); 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) { function roles(m, ps) {
const r = { blockers: 0, antiAir: 0, healers: 0, total: 0 }; const r = { blockers: 0, antiAir: 0, healers: 0, total: 0 };
for (const p of ps.allChess()) { for (const p of ps.allChess()) {
const c = chessRec(m, p.id); const c = chessRec(m, p.id, ps);
if (!c) continue; if (!c) continue;
r.total++; r.total++;
if (isBlocker(c)) r.blockers++; 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. */ /** Stand-alone value of an owned piece (no bond context): power, items, role fit, layer engines. */
function unitBase(m, piece, ctx) { function unitBase(m, piece, ctx) {
const c = chessRec(m, piece.id); const c = chessRec(m, piece.id, ctx.ps);
if (!c) return 0; if (!c) return 0;
let v = power(c) + (piece.items ? piece.items.length * 5 : 0); let v = power(c) + (piece.items ? piece.items.length * 5 : 0);
if (m.round <= 11) v += traitsOf(m, c).recurring * 4; 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). */ /** Value of an owned piece for bench / sell decisions (its bonds counted against the other owned chess). */
function pieceValue(m, ps, piece, ctx) { function pieceValue(m, ps, piece, ctx) {
const c = chessRec(m, piece.id); const c = chessRec(m, piece.id, ps);
if (!c) return 0; if (!c) return 0;
return unitBase(m, piece, ctx) + bondValue(m, c, ownedBonds(m, ps, piece.uid), ctx.focus, ctx.second) * 0.8; 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). * members still count for BOARD_AND_DECK bonds) + composition (blockers, anti-air when the wave flies, ≤ 2 healers).
*/ */
function lineupScore(m, ps, set, ctx) { function lineupScore(m, ps, set, ctx) {
const gd = m.gd; const gd = ps?.gd || m.gd;
const board = new Map(); const board = new Map();
set.forEach((p, i) => board.set(`x${i}`, p)); set.forEach((p, i) => board.set(`x${i}`, p));
const inSet = new Set(set.map((p) => p.uid)); const inSet = new Set(set.map((p) => p.uid));
@@ -518,7 +519,7 @@ function lineupScore(m, ps, set, ctx) {
let blockers = 0; let blockers = 0;
let air = 0; let air = 0;
let healers = 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; v -= Math.max(0, Math.min(2, ctx.roles.blockers) - blockers) * 15;
if (ctx.fly > 0 && ctx.roles.antiAir > 0 && air === 0) v -= 12; if (ctx.fly > 0 && ctx.roles.antiAir > 0 && air === 0) v -= 12;
v -= Math.max(0, healers - 2) * 10; 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). */ /** Shop / reward score of acquiring one copy of chess `id` (0 = not worth it). */
function buyScore(m, ps, id, ctx) { function buyScore(m, ps, id, ctx) {
const gd = m.gd; const gd = ps?.gd || m.gd;
const c = chessRec(m, id); const c = chessRec(m, id, ps);
if (!c) return 0; if (!c) return 0;
let s = power(c) * 0.6; let s = power(c) * 0.6;
const tr = traitsOf(m, c); const tr = traitsOf(m, c);
@@ -639,7 +640,7 @@ export function fieldModel(m, ps = null) {
const wave = m.wave || (boss && boss.wave); const wave = m.wave || (boss && boss.wave);
const cacheKey = `${m.round}|${m.stageId}|${wave ? wave.templateId : 'boss'}|${boss ? boss.side : ''}`; const cacheKey = `${m.round}|${m.stageId}|${wave ? wave.templateId : 'boss'}|${boss ? boss.side : ''}`;
if (m._botPath && m._botPath.key === cacheKey) return m._botPath; 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 st = m.stage;
const gpaths = (st && (st.groundPathsWithDevices || st.groundPaths)) || {}; const gpaths = (st && (st.groundPathsWithDevices || st.groundPaths)) || {};
const routesOut = []; const routesOut = [];
@@ -835,7 +836,8 @@ export function rangeRec(ps, rec) {
export function effDps(model, rec) { export function effDps(model, rec) {
if (!rec) return 0; if (!rec) return 0;
if (!model || !Array.isArray(model.armor) || !model.armor.length) return dpsOf(rec); 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; const cache = model.dps instanceof Map ? model.dps : null;
if (cache && cache.has(id)) return cache.get(id); if (cache && cache.has(id)) return cache.get(id);
let v = 0; 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 } = {}) { export function* planLayoutSteps(m, ps, pieces, params = LAYOUT_PARAMS, { occupied = new Set(), recOf = null } = {}) {
const model = fieldModel(m, ps); const model = fieldModel(m, ps);
const map = ps.deployMap(); 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 layout = new Layout(model, params);
const rank = (p) => { const r = rec(p); return isBlocker(r) ? 0 : isHealer(r) ? 2 : 1; }; 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); 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; 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 高台, // 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 高台). // 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'; const preferHigh = cls === 'all' && basePositionClass(r0) === 'melee';
let bestHigh = null; let bestHigh = null;
let bestHighV = -Infinity; let bestHighV = -Infinity;
@@ -1161,7 +1164,7 @@ function context(m, ps) {
const copies = copyCounts(m, ps); const copies = copyCounts(m, ps);
let pairs = 0; let pairs = 0;
for (const [b, k] of copies) if (k + 1 >= mergeNeed(m, b)) pairs++; 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). */ /** Normal copies owned per base (board, hand, temp). */
@@ -1254,7 +1257,7 @@ function sellJunk(m, ps) {
const keep = []; const keep = [];
const junk = []; const junk = [];
for (const p of bench) { for (const p of bench) {
const c = chessRec(m, p.id); const c = chessRec(m, p.id, ps);
if (!c) continue; if (!c) continue;
const base = m.gd.baseIdOf(p.id); const base = m.gd.baseIdOf(p.id);
const copies = c.isGolden ? 0 : ctx.copies.get(base) || 0; 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). */ /** Take the queued pick-one offers (merge rewards, special refreshes — free; they expire at prep end). */
function takeOffers(m, ps) { function takeOffers(m, ps) {
const gd = m.gd; const gd = ps?.gd || m.gd;
for (let guard = 0; guard < 4 && ps.offers.length; guard++) { for (let guard = 0; guard < 4 && ps.offers.length; guard++) {
const offer = ps.offers[0]; const offer = ps.offers[0];
const ctx = context(m, ps); const ctx = context(m, ps);
@@ -1378,7 +1381,7 @@ function maybeFreeze(m, ps) {
} }
function levelUp(m, ps, { spare = false } = {}) { function levelUp(m, ps, { spare = false } = {}) {
const gd = m.gd; const gd = ps?.gd || m.gd;
for (let guard = 0; guard < 6; guard++) { for (let guard = 0; guard < 6; guard++) {
if (ps.shop.level >= gd.maxShopLevel) return; if (ps.shop.level >= gd.maxShopLevel) return;
const price = Math.max(0, ps.shop.upgradePrice); 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). */ /** Buying / rerolling toward the lineup; a step generator (yields after each purchase or reroll). */
function* buyLoopSteps(m, ps, { fillOnly = false, maxRefreshes = 0 } = {}) { function* buyLoopSteps(m, ps, { fillOnly = false, maxRefreshes = 0 } = {}) {
const gd = m.gd; const gd = ps?.gd || m.gd;
let refreshes = 0; let refreshes = 0;
const minPrice = 2; const minPrice = 2;
const reserve = fundsReserve(m, ps); 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); const gain = lineupGain(m, ps, s.id, ctx, cur);
if (gain <= 2) return; if (gain <= 2) return;
sc += Math.min(15, gain * 0.5); 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 // a second copy of a non-keeper with no bench room for another pair is clutter, sold later at a loss
return; 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. * first — they take the hits —, then DPS), pointing at it; null when none is free.
*/ */
function supportSpot(m, ps, p) { 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; if (!rec) return null;
const map = ps.deployMap(); const map = ps.deployMap();
const pos = positionClass(rec); const pos = positionClass(rec);
@@ -1618,7 +1621,7 @@ function supportSpot(m, ps, p) {
const [dr, dc] = rotateOffset(0, 1, pieceDir(q)); const [dr, dc] = rotateOffset(0, 1, pieceDir(q));
faced.add(tileKey(r + dr, c + dc)); 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)); .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) { for (const [k] of ops) {
const [r, c] = parseKey(k); 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. * 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)) { export function cellTarget(m, ps, ctx = context(m, ps)) {
const gd = m.gd; const gd = ps?.gd || m.gd;
let best = null; let best = null;
let bestV = Infinity; let bestV = Infinity;
for (const p of ps.allChess()) { 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). * 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)) { 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); const rec = gd.item(item.id);
if (!rec) return null; if (!rec) return null;
const key = itemEffect(rec); const key = itemEffect(rec);
@@ -1717,19 +1720,19 @@ export function itemTarget(m, ps, item, ctx = context(m, ps)) {
const owned = ps.allChess(); const owned = ps.allChess();
const normal = (p) => !gd.isGolden(p.id); 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 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) { switch (key) {
case 'use_equip_recruit_new_char_and_give_char_to_player_most_bond': { 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 // 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 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) 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; return bench[0] || byVal(deployed.filter(normal), 1)[0] || null;
} }
case 'use_equip_reward_char_chess': { case 'use_equip_reward_char_chess': {
const counts = copyCounts(m, ps); 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))) 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; 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; // 博士投影: 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; if (consume) return list[0] || owned[0] || null;
const free = (p) => (p.items || []).length < gd.equipPerChess; const free = (p) => (p.items || []).length < gd.equipPerChess;
if (rec.requiresBondId) { 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; if (member) return member;
} }
// damage dealers carry equipment first (healers / non-attackers last); survival items on blockers first // 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 dealer = (p) => { const c = chessRec(m, p.id, ps); return c && !isHealer(c) && c.attackKind !== 'none'; };
const blocker = (p) => isBlocker(chessRec(m, p.id)); const blocker = (p) => isBlocker(chessRec(m, p.id, ps));
const order = rec.category === 'SURVIVAL' const order = rec.category === 'SURVIVAL'
? [...list.filter(blocker), ...list.filter((p) => !blocker(p) && dealer(p)), ...list.filter((p) => !blocker(p) && !dealer(p))] ? [...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))]; : [...list.filter(dealer), ...list.filter((p) => !dealer(p))];
@@ -1794,7 +1797,7 @@ function useArt(m, ps, item, ctx) {
} }
function equipItems(m, ps) { function equipItems(m, ps) {
const gd = m.gd; const gd = ps?.gd || m.gd;
const tried = new Set(); const tried = new Set();
for (let guard = 0; guard < 12; guard++) { 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)); 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)); const room = () => freeSlot(ps.hand) >= 0 || (piece != null && !ps.temp.includes(piece));
if (room()) return true; if (room()) return true;
if (sellWeakestHand(m, ps, { handOnly: true }) && 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)) 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; .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; 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 (id) ids.push(id);
} }
if (ids.length) ctx.offerChess(ids, { source: 'item', tier }); 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 // co-op: next prep, send the original chess to the teammate with the most members of its bonds — except a 自选
const to = mostBondMate(ctx, bonds); // 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 } }); 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) { _garrisons(ps, hook, ev) {
const gd = this.m.gd; const gd = ps.gd || this.m.gd;
const run = (piece, where) => { const run = (piece, where) => {
const rec = gd.chess(piece.id); const rec = gd.chess(piece.id);
if (!rec || !Array.isArray(rec.garrisonIds)) return; 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. * SERVER_GAIN. SERVER_PRICE cannot be triggered. Returns the number of garrison handlers run.
*/ */
triggerGarrisons(ps, piece, eventType, { asPiece = null, triggeredBy = null } = {}) { 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 hook = eventType === 'SERVER_PRICE' ? null : GARRISON_HOOK[eventType];
const rec = piece && piece.kind === 'chess' ? gd.chess(piece.id) : null; const rec = piece && piece.kind === 'chess' ? gd.chess(piece.id) : null;
if (!hook || !rec || !Array.isArray(rec.garrisonIds) || this.depth >= MAX_DEPTH) return 0; 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) * @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) { 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 view = (p) => (p ? ps.pieceView(p) : null);
const ctx = { const ctx = {
hook, hook,
@@ -516,8 +518,10 @@ export function makeCtx(m, ps, source, hook, ev = null) {
if (opts.golden) id = gd.goldenIdOf(chessId) || chessId; if (opts.golden) id = gd.goldenIdOf(chessId) || chessId;
if (!gd.chess(id)) return null; if (!gd.chess(id)) return null;
const base = gd.baseIdOf(id); 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 // "some effects fail when the cap is hit" (research 06 §7): by default a chess of the pool needs a free copy (a
if (opts.requirePool !== false && m.pool.has(base) && m.pool.left(base) < 1) return null; // 自选 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 }); 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). // 「歌蕾蒂娅:获得斯卡蒂」 — 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. // 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 { layerGainRoom } from '../../shared/constants.js';
import { uniteLeft } from '../sim/spec.js'; import { uniteLeft } from '../sim/spec.js';
import { GRANTED_CAP_OVERRIDE } from '../sim/content/garrisons/battle.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 MAX_TICKS_PER_INTERVAL = 8;
export const INTERVAL_MS = 1000 / 30; 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); 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]; 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). */ /** Summons no chess owns (bond / band units every player may field: 炎佑, 预备干员-医疗, Touch). */
function ownerlessSummons(gd) { return summonIndex(gd).ownerless; } function ownerlessSummons(gd) { return summonIndex(gd).ownerless; }
@@ -674,6 +686,7 @@ export function specBounds(spec, gd = null) {
if (u.kind !== 'token') { if (u.kind !== 'token') {
chess.set(u.uid, defId); chess.set(u.uid, defId);
if (gd) for (const t of summonsOf(gd, defId)) defIds.add(t); 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 // 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. // (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): // 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 // 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 // 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 // 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()) { for (const ps of m.players.values()) {
const id = ps.playerId; 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.funds) || ps.funds < 0) fail(`${id}: funds ${ps.funds}`);
if (!Number.isInteger(ps.pendingFunds) || ps.pendingFunds < 0) fail(`${id}: pendingFunds ${ps.pendingFunds}`); if (!Number.isInteger(ps.pendingFunds) || ps.pendingFunds < 0) fail(`${id}: pendingFunds ${ps.pendingFunds}`);
if (!Number.isFinite(ps.lp)) fail(`${id}: lp ${ps.lp}`); 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) { for (const p of all) {
note(ps, p); note(ps, p);
if (p.kind === 'chess') { 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) { 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`); 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 || []) { for (const it of p.items || []) {
note(ps, it); note(ps, it);
@@ -93,12 +99,13 @@ export function collectViolations(m, { limit = 25 } = {}) {
const maxCopies = rec.isGolden ? gd.goldenCopies : 1; 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`); 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); 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') { } else if (p.kind === 'item') {
if (!gd.item(p.id)) fail(`${id}: unknown item ${p.id}`); if (!gd.item(p.id)) fail(`${id}: unknown item ${p.id}`);
countItem(p); countItem(p);
} else if (p.kind === 'token') { } 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}`); 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) // 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})`); 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); 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 (!(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; } 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); 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}`); 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) // 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}`); if (deployed > ps.deployCap) fail(`${id}: ${deployed} chess deployed > cap ${ps.deployCap}`);
// merges are immediate // merges are immediate
const copies = new Map(); 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) { for (const [b, n] of copies) {
const need = gd.mergeCount(b); const need = gd.mergeCount(b);
if (need > 1 && gd.goldenIdOf(b) && n >= need) fail(`${id}: owns ${n} normal copies of ${b} (merges at ${need})`); 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 // bonds are up to date
try { 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)`); if (JSON.stringify(fresh) !== JSON.stringify(ps.bonds)) fail(`${id}: stale bonds (missing recompute)`);
} catch (e) { } catch (e) {
fail(`${id}: computeBonds threw ${e && e.message}`); 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 (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 (!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' && 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 o of ps.offers) {
for (const s of o.slots || []) { 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' ? !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' && 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`); 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 // 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 // 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 // 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) // (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), fieldId: this.fieldOf(ps),
status: this.statusOf(ps), status: this.statusOf(ps),
autoplay: ps.autoplay, autoplay: ps.autoplay,
@@ -186,11 +186,18 @@ export class MatchViews {
* hand row (row 7) — the scout renders like the own prep bench. */ * hand row (row 7) — the scout renders like the own prep bench. */
prepFieldMeta(ps) { prepFieldMeta(ps) {
const units = []; 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)) { 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, // 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) // 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) || {}; 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 // 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 // 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 // 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, 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, 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 // 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, // (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) // the approved 0.2.0 补位 plan — and carries `standInFor`: its card shows the stand-in it deploys as)
const benchUnit = (piece, i, y) => { 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 assets = (rec && rec.assets) || {};
const lo = piece.kind === 'chess' && rec ? ps.loadoutFor(rec) : null; 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; 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, 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, 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, standInFor: standIn && standIn.standInFor ? standIn.standInFor : undefined,
diy: diyOf(piece),
}); });
}; };
for (let i = 0; i < ps.hand.length; i++) { 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 } = {}) { acquireChess(chessId, { source = 'grant', toTemp = false, fromPool = true, silent = false } = {}) {
const rec = this.gd.chess(chessId); 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 base = this.gd.baseIdOf(chessId);
const need = rec.isGolden ? this.gd.goldenCopies : 1; 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 }); const piece = this.newPiece('chess', chessId, { poolCopies: taken });
this.round.gainedChess++; this.round.gainedChess++;
let owned = piece; 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) // 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 === 'board') this.grantTokensFor(elite);
if (!where) { if (!where) {
this.m.pool.give(baseId, copies); this.poolOf(baseId).give(baseId, copies);
this.m.toast(this, 'warn', '整备区已满,晋升的精锐干员无法放入'); this.m.toast(this, 'warn', '整备区已满,晋升的精锐干员无法放入');
this.m.log.warn?.(`[match ${this.m.roomCode}] ${this.playerId}: merge result dropped (hand+temp full)`); this.m.log.warn?.(`[match ${this.m.roomCode}] ${this.playerId}: merge result dropped (hand+temp full)`);
this.recompute(); this.recompute();
@@ -142,7 +144,7 @@ export class PlayerAcquire {
if (!goldenId) return false; if (!goldenId) return false;
const base = this.gd.baseIdOf(piece.id); const base = this.gd.baseIdOf(piece.id);
const extra = Math.max(0, this.gd.goldenCopies - (piece.poolCopies || 0)); 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; piece.id = goldenId;
this.recompute(); this.recompute();
return true; 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 * 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 — * 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 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 } = {}) { pushRewardOffer(source = 'merge', { tier = null, ids = null, label = null } = {}) {
const ro = this.gd.rewardOffer(); const ro = this.gd.rewardOffer();
@@ -216,7 +220,7 @@ export class PlayerAcquire {
const fresh = (id) => !list.includes(id); const fresh = (id) => !list.includes(id);
for (let i = 0; i < ro.count; i++) { for (let i = 0; i < ro.count; i++) {
let id = null; 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); 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 * 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) { loadoutFor(chessRecord) {
if (this.fieldsStandIn(chessRecord)) { if (this.fieldsStandIn(chessRecord)) {
return resolveLoadout(null, this.gd.standIn(chessRecord.chessId), (id) => this.gd.standIn(id) || this.gd.chess(id)); 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 * 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. * (gd.standIn — the chess's identity, the stand-in's body) when the player fields its stand-in; for a DIY slot record
* Rules about the unit's body read it (placement class, summon / bot ranges, the scouting art); rules about the chess * the player's 自选 record of that slot (this.gd — a caller holding the match's own record of the slot gets the
* (price, bonds, 特质, merges, pools) keep reading gd.chess. * 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 * @param {object|null} rec
*/ */
fieldRecord(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 // 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 // gains (clamped by layerGainRoom), the shop (onPrice prices, copy-weighted rolls from the shared pool plus the player's
// intents: g.buy, g.refresh, g.freeze, g.levelUp, g.sell. // 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 // Installed on PlayerState.prototype by server/match/PlayerState.js (a method container: never instantiated; `this` is
// the player state). // the player state).
@@ -61,7 +61,8 @@ export class PlayerEconomy {
} }
_rollChessSlot() { _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; return id ? { kind: 'chess', id, basePrice: this.gd.chessPrice(id), frozen: false, sold: false } : null;
} }
@@ -115,10 +116,12 @@ export class PlayerEconomy {
let piece; let piece;
if (slot.kind === 'chess') { if (slot.kind === 'chess') {
const rec = this.gd.chess(slot.id); 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 base = this.gd.baseIdOf(slot.id);
const need = rec.isGolden ? this.gd.goldenCopies : 1; 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); if (handFull) return fail(ERR.HAND_FULL);
this.spend(price); this.spend(price);
slot.sold = true; 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 / // 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 // 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 // in, _fillHandFromTemp), the per-piece round counters (拉普兰德), pool copies back to the shared pool (a 自选 piece's to
// (removal, lifting, counts, the stack granted for an owner). // 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 // Installed on PlayerState.prototype by server/match/PlayerState.js (a method container: never instantiated; `this` is
// the player state). // the player state).
@@ -132,10 +133,11 @@ export class PlayerPieces {
return v; 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) { returnCopies(piece) {
if (piece && piece.kind === 'chess' && piece.poolCopies > 0) { 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; piece.poolCopies = 0;
} }
} }
+3 -2
View File
@@ -34,10 +34,11 @@ export class PlayerPrep {
if (handFull) return fail(ERR.HAND_FULL); if (handFull) return fail(ERR.HAND_FULL);
} else { } else {
const rec = this.gd.chess(slot.id); 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 base = this.gd.baseIdOf(slot.id);
const need = rec.isGolden ? this.gd.goldenCopies : 1; 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); if (handFull) return fail(ERR.HAND_FULL);
} }
const price = Number.isFinite(slot.price) && slot.price > 0 ? Math.trunc(slot.price) : 0; 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 // 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, // 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 // 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 // Installed on PlayerState.prototype by server/match/PlayerState.js (a method container: never instantiated; `this` is
// the player state). // the player state).
@@ -102,7 +103,12 @@ export class PlayerRound {
for (const { r, c, piece } of boardOrder(this.board)) { for (const { r, c, piece } of boardOrder(this.board)) {
if (piece.kind === 'chess') { 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) }; 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 // 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) // backup skill / module — no loadout fields, docs/SIM.md §12)
u.standIn = true; 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 // 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 // at the match start; [] = every operator owned) — the client's 「替补:X」 badges, the stand-in's art and card
standIns: this.standIns, 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: { stats: {
dmgDealt: Math.round(this.stats.dmgDealt), kills: this.stats.kills, leaks: this.stats.leaks, gold: this.stats.gold, 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, 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 // 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 // ("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` * 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; 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 = []; const out = [];
for (const [id, e] of this.entries) { const scan = (list) => {
if (e.left <= 0) continue; for (const [id, e] of list) {
if (tier != null ? e.tier !== tier : e.tier > maxTier) continue; if (e.left <= 0) continue;
if (filter && !filter(id, e)) continue; if (tier != null ? e.tier !== tier : e.tier > maxTier) continue;
out.push([id, e.left]); if (filter && !filter(id, e)) continue;
} out.push([id, e.left]);
}
};
scan(this.entries);
if (extra) scan(extra);
return out; return out;
} }
/** /**
* Copy-weighted roll: one copy uniformly among remaining copies of eligible chess. Returns a base id or null. * Copy-weighted roll: one copy uniformly among remaining copies of eligible chess. Returns a base id or null.
* @param {Function} rng * @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 = {}) { roll(rng, opts = {}) {
const el = this._eligible(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 teamRounds = victory ? gd.bossRound + (hiddenCleared ? 1 : 0) : Math.max(0, Math.min(m.round, gd.bossRound) - 1);
const rows = players.map((ps) => { const rows = players.map((ps) => {
const roundsPassed = !ps.alive && ps.eliminatedRound != null ? Math.max(0, ps.eliminatedRound - 1) : teamRounds; 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 }) => ({ 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 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 // 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; const cleared = victory && ps.alive;
return { return {
+10 -4
View File
@@ -29,6 +29,8 @@
// //
// The handler object (implemented by server/lobby.js) receives: // The handler object (implemented by server/lobby.js) receives:
// onHello(session, { resumed, repeat }) after `welcome` was sent // 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 // 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 // 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 // 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), * 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 * 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). * (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). */ /** Close codes (see header). */
export const CLOSE = Object.freeze({ REPLACED: 4001, HELLO_TIMEOUT: 4002, POLICY: 1008, SHUTDOWN: 1001 }); export const CLOSE = Object.freeze({ REPLACED: 4001, HELLO_TIMEOUT: 4002, POLICY: 1008, SHUTDOWN: 1001 });
@@ -109,6 +111,8 @@ export class Session {
this.loadout = null; this.loadout = null;
/** @type {readonly string[] | null} checked not-owned chess ids (干员持有, lobby-owned, 0.2.0 补位) */ /** @type {readonly string[] | null} checked not-owned chess ids (干员持有, lobby-owned, 0.2.0 补位) */
this.notOwned = null; 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) */ /** @type {string} client address of the latest connection (logging) */
this.addr = '?'; this.addr = '?';
/** @type {string | null} per-network limit key of the latest connection (null = not limited), see clientAddress */ /** @type {string | null} per-network limit key of the latest connection (null = not limited), see clientAddress */
@@ -500,7 +504,7 @@ export class Network {
/** /**
* @param {{ * @param {{
* registry: SessionRegistry, * 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 }, * log?: { info: Function, warn: Function, error: Function, debug?: Function },
* now?: () => number, * now?: () => number,
* options?: Partial<typeof NET_DEFAULTS>, * options?: Partial<typeof NET_DEFAULTS>,
@@ -656,7 +660,9 @@ export class Network {
session.addr = conn.ip; session.addr = conn.ip;
session.limitKey = conn.key; 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; if (validRid(rid)) welcome.rid = rid;
this.reply(conn, welcome); this.reply(conn, welcome);
try { 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 `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 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 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 **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 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`); // - 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 // - 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; // (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 // and module of its 补位 rows at that tier (`diy.locked`: "技能携带规则与系统补位时一致" — [ASSUMED] that reading, the
// owner's decision of 2026-10-05); // owner's decision of 2026-10-05);
// - potential 0 for everyone [ASSUMED: no account]. // - potential 0 for everyone [ASSUMED: no account].
@@ -24,6 +27,21 @@ import { composeUnitRecord, unitForm, statusKey } from './standIn.js';
/** The tiers that have 自选 slots. */ /** The tiers that have 自选 slots. */
export const DIY_TIERS = Object.freeze([5, 6]); 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, * @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) * 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 {string} slotId a slot's normal (`_a`) or elite (`_b`) id
* @param {DiyData} data * @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 baseId = own(diy.slots, slotId) ? slotId : Object.keys(diy.slots).find((id) => diy.slots[id]?.goldenId === slotId);
const s = baseId ? diy.slots[baseId] : null; const s = baseId ? diy.slots[baseId] : null;
if (!s) return 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[]} */ /** 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}` }; if (!forms.every((f) => (f.skills ?? []).some((s) => s && s.index === skillIndex))) return { error: `${charId}: no skill ${skillIndex}` };
const elite = forms[1]; 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 { error: `${charId}: no module ${uniEquipId} at stage ${slot.golden?.status?.equipLevel ?? '?'}` };
} }
return { ok: true, pick: { charId, skillIndex, uniEquipId } }; 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); 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 * 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 * slot, an illegal pick (checkDiyPick), a 集成战略 module (isDiyModule), an operator without a kit (when `kitted` is
* one tier, or an owned operator in two slots rejects the roster; a prototype may fill a tier-5 and a tier-6 slot. * 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 {any} picks
* @param {{ data: DiyData, kitted?: Kitted }} opts * @param {{ data: DiyData, kitted?: Kitted }} opts
* @returns {{ ok: true, picks: Record<string, DiyLoadout> } | { error: 'BAD_MSG'|'BAD_TARGET', detail: string }} * @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 }; if (!('ok' in c)) return { error: 'BAD_TARGET', detail: c.error };
const { charId } = c.pick; const { charId } = c.pick;
if (!ok(charId)) return { error: 'BAD_TARGET', detail: `${charId} has no kit yet` }; 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 tier = diy.slots[slotId].tier;
const inTier = byTier.get(tier) ?? new Set(); const inTier = byTier.get(tier) ?? new Set();
if (inTier.has(charId)) return { error: 'BAD_TARGET', detail: `${charId} fills two tier-${tier} slots` }; 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 { DIFFICULTIES, NAME_MAX_LEN, ROOM_CODE_LEN, MAX_SEATS, EMOTES, GEO } from './constants.js';
import { isDroppableChess } from './standIn.js'; import { isDroppableChess } from './standIn.js';
import { diySlotIds, validateDiyPicks } from './diy.js';
// ---- tiny validators ------------------------------------------------------- // ---- tiny validators -------------------------------------------------------
const isInt = (v, lo = -Infinity, hi = Infinity) => Number.isInteger(v) && v >= lo && v <= hi; 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 }; 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 --------------------- // ---- 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); 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 // 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) // started (an out-of-match setting — during a match it is stored for the next one: ROOM_STARTED)
'room.ownership': { notOwned: isNotOwnedList }, '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 — // 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 // 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. // 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 { test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs'; 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 { unitForm, composeUnitRecord } from '../shared/standIn.js';
import { composeStats, composeTalents } from '../shared/loadoutRecord.js'; import { composeStats, composeTalents } from '../shared/loadoutRecord.js';
import { normalizeChess, resolveLoadout, loadoutRecord } from '../server/sim/simdata.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(DIY_TIERS, [5, 6]);
assert.deepEqual(diySlotIds(DATA), [T5, T5B, T6, T6B]); 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 }, 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(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'); 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)); 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', () => { 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]]; 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) { 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.autoPlace) env.SP_AUTO_PLACE = '1';
if (opts.fast.eliminate?.length) env.SP_ELIMINATE = opts.fast.eliminate.join(','); 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.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 child = spawn(process.execPath, [entry], { cwd: ROOT, env, stdio: ['ignore', 'pipe', 'pipe'] });
const logs = []; 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). // 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: // 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). // 战场#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). // Not a test file (node --test runs it as a no-op module when NODE_TEST_CONTEXT is set).
import { startServer } from '../../server/index.js'; import { startServer } from '../../server/index.js';
@@ -46,6 +48,7 @@ if (!process.env.NODE_TEST_CONTEXT) {
const autoPlace = process.env.SP_AUTO_PLACE === '1'; 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 eliminate = String(process.env.SP_ELIMINATE || '').split(',').map((x) => x.trim()).filter(Boolean).map(Number);
const forcedStage = String(process.env.SP_STAGE || '').trim(); 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 { class FastMatch extends Match {
constructor(opts) { constructor(opts) {
@@ -152,6 +155,11 @@ if (!process.env.NODE_TEST_CONTEXT) {
if (slot < 0 || !this.gd.item(itemId)) continue; if (slot < 0 || !this.gd.item(itemId)) continue;
try { ps.hand[slot] = ps.newPiece('item', itemId); } catch { /* best effort */ } 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) => { 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 }; 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 | | `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) | | `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) | | `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 | | `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) | | `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. to 2 decimals.
`npm test` runs the fast subset (the scenarios marked `"fast": true`: every chess record with its default loadout, `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 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 — 61 of the 150). `GOLDEN_FULL=1` checks everything. 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 ## 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"] [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 () => { 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', 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', '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/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', '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'); assert.ok(ps.hand.every((x) => x == null) && ps.temp.every((x) => x == null), 'eliminated player keeps nothing');
} }
const chessUids = new Set(); const chessUids = new Set();
const diyHeld = new Map();
const all = [...ps.board.values(), ...ps.hand.filter(Boolean), ...ps.temp.filter(Boolean)]; 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); for (const p of all) if (p.kind === 'chess') chessUids.add(p.uid);
let deployed = 0; let deployed = 0;
@@ -177,7 +178,8 @@ function legacyInvariants(m) {
for (const [k, p] of ps.board) { for (const [k, p] of ps.board) {
const [r, c] = k.split(',').map(Number); 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}`); 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}`); assert.ok(rec, `unknown board piece ${p.id}`);
const cls = p.kind === 'chess' ? placeClass(ps, rec) : positionClass(rec); const cls = p.kind === 'chess' ? placeClass(ps, rec) : positionClass(rec);
assert.ok(canPlace(dmap, cls, r, c), `illegal tile ${p.id} @ ${k}`); 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}`); } 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); assert.ok(Number.isInteger(p.poolCopies) && p.poolCopies >= 0);
const base = m.gd.baseIdOf(p.id); 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') { } else if (p.kind === 'item') {
assert.ok(m.gd.item(p.id), `unknown item ${p.id}`); assert.ok(m.gd.item(p.id), `unknown item ${p.id}`);
} else if (p.kind === 'token') { } else if (p.kind === 'token') {
@@ -210,6 +214,9 @@ function legacyInvariants(m) {
const need = m.gd.mergeCount(b); 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})`); 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) { for (const [base, e] of pool.entries) {
assert.ok(e.left >= 0 && e.left <= e.cap, `pool ${base} left ${e.left} cap ${e.cap}`); 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 // 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 // battle stage —, carried HP / SP, a knocked-out operator, two leakers' enemies with a summoned-only kind and
// a bounty) // 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 // 险境 / 绝境 / 终极 ×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 // 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) // 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 // 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 // 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 // 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 } }); 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 // 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 } }); 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 * 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). * 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 sched = new VirtualScheduler();
const seats = []; const seats = [];
// a human seat (cfg.human): offline (its battles run on the server) and on AI 托管 from the start; its not-owned chess // 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 // 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, notOwned: [...cfg.human.notOwned] }); 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 }); 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; let summary = null;
const logged = []; const logged = [];
@@ -752,10 +780,24 @@ export function runMatch(cfg) {
const rounds = []; const rounds = [];
// 补位: per round at SETTLE, the human's board pieces that fought as stand-ins (chess id → stand-in charId) // 补位: per round at SETTLE, the human's board pieces that fought as stand-ins (chess id → stand-in charId)
const standIns = []; 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 = ''; let last = '';
m.start(); m.start();
const setup = { stageId: m.stageId, bossId: m.bossId, hiddenBossId: m.hiddenBossId, factions: [...(m.factions || [])] }; const setup = { stageId: m.stageId, bossId: m.bossId, hiddenBossId: m.hiddenBossId, factions: [...(m.factions || [])] };
let boosted = !cfg.boost; let boosted = !cfg.boost;
let levelled = !cfg.human?.shopLevel;
const prepFunds = {}; const prepFunds = {};
sched.runUntil(() => { sched.runUntil(() => {
const key = `${m.phase}:${m.round}`; const key = `${m.phase}:${m.round}`;
@@ -769,6 +811,14 @@ export function runMatch(cfg) {
ps.recompute(); 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) // 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 === 'PREP') for (const ps of m.order) prepFunds[ps.playerId] = ps.funds;
if (m.phase === 'SETTLE') { if (m.phase === 'SETTLE') {
@@ -782,11 +832,16 @@ export function runMatch(cfg) {
]; ];
} }
rounds.push(row); rounds.push(row);
if (cfg.human) { if (cfg.human?.notOwned) {
const h = m.players.get('h_0'); 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); 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(' ')]); 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; return summary != null;
@@ -817,7 +872,13 @@ export function runMatch(cfg) {
rounds, rounds,
players, 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(); m.dispose();
return digest; 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 补位 * 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. * 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']); 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) { export function isFast(sc) {
if (sc.family === 'matches') return FAST_MATCHES.has(sc.id); if (sc.family === 'matches') return FAST_MATCHES.has(sc.id);