From 4bee542e61a99a6770324d5df5f98193c4d79f07 Mon Sep 17 00:00:00 2001 From: sganggs Date: Mon, 5 Oct 2026 23:35:01 +0800 Subject: [PATCH 1/9] =?UTF-8?q?feedback5=20WK:=20=E8=87=AA=E9=80=89?= =?UTF-8?q?=E7=BC=96=E9=98=9F=20on=20the=20server=20=E2=80=94=20room.diy?= =?UTF-8?q?=20{=20picks=20}=20(lenient:=20illegal=20picks=20dropped,=20onl?= =?UTF-8?q?y=20malformed=20input=20is=20BAD=5FMSG;=20stored=20per=20sessio?= =?UTF-8?q?n=20/=20seat;=20a=20match=20takes=20the=20picks=20its=20seat=20?= =?UTF-8?q?had=20at=20the=20start,=20a=20change=20during=20a=20match=20is?= =?UTF-8?q?=20for=20the=20next=20one;=20welcome=20lists=20diyKitted),=20Pl?= =?UTF-8?q?ayerState.diy=20with=20the=20player's=20data=20view=20(a=20slot?= =?UTF-8?q?ted=20DIY=20slot=20is=20its=20operator=20for=20every=20rule:=20?= =?UTF-8?q?bonds=20from=20its=20factions,=20no=20=E7=89=B9=E8=B4=A8,=20the?= =?UTF-8?q?=20pick's=20skill=20and=20module,=20the=20slot's=20tier=20/=20p?= =?UTF-8?q?rice=20/=20merge),=20a=20per-player=20stock=20per=20slotted=20p?= =?UTF-8?q?iece=20(8=20at=20tier=205,=205=20at=20tier=206=20[ASSUMED];=20n?= =?UTF-8?q?ever=20in=20the=20shared=20pool;=20none=20when=20every=20bond?= =?UTF-8?q?=20of=20it=20is=20banned),=20DIY=20pieces=20in=20that=20player'?= =?UTF-8?q?s=20shop=20rolls=20and=20reward=20offers=20once=20the=20?= =?UTF-8?q?=E8=B0=83=E5=BA=A6=E4=B8=AD=E5=BF=83=20reaches=20the=20slot's?= =?UTF-8?q?=20level,=20battleInput=20diy,=20m.private.diy=20/=20diyBanned,?= =?UTF-8?q?=20prep=20scouting=20and=20results=20name=20the=20operator,=20?= =?UTF-8?q?=E4=BF=A1=E6=A0=87=20never=20sends=20a=20=E8=87=AA=E9=80=89=20p?= =?UTF-8?q?iece=20to=20a=20teammate=20[ASSUMED]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The owner's decisions of 2026-10-05 (picks, slots, shop per player, bans); research 0.2.0 §2. Golden unchanged (no scenario slots a DIY pick). Tests: test/match/diy-shop.test.js (9), test/lobby-diy.test.js (4). --- server/lobby.js | 54 +++++- server/match/Match.js | 9 +- server/match/PlayerState.js | 27 ++- server/match/audit.js | 7 +- server/match/bot.js | 71 ++++---- server/match/builtinMeta.js | 6 +- server/match/effectsMeta.js | 14 +- server/match/fields.js | 13 ++ server/match/invariants.js | 30 ++- server/match/match/views.js | 17 +- server/match/player/acquire.js | 14 +- server/match/player/basics.js | 18 +- server/match/player/diy.js | 209 +++++++++++++++++++++ server/match/player/economy.js | 13 +- server/match/player/pieces.js | 10 +- server/match/player/prep.js | 5 +- server/match/player/round.js | 10 +- server/match/player/views.js | 6 + server/match/pool.js | 30 ++- server/match/results.js | 10 +- server/net.js | 14 +- shared/diy.js | 7 +- shared/protocol.js | 50 +++++ test/lobby-diy.test.js | 156 ++++++++++++++++ test/match/diy-shop.test.js | 323 +++++++++++++++++++++++++++++++++ test/match/harness.js | 8 +- 26 files changed, 1026 insertions(+), 105 deletions(-) create mode 100644 server/match/player/diy.js create mode 100644 test/lobby-diy.test.js create mode 100644 test/match/diy-shop.test.js diff --git a/server/lobby.js b/server/lobby.js index ad5c9be7..b527bb2c 100644 --- a/server/lobby.js +++ b/server/lobby.js @@ -71,6 +71,16 @@ // every operator) and keeps it for its whole length: the setting is out of match ("局外设置,下一局生效"), so while // the room's match runs a new list is only stored for the next match (ROOM_STARTED 'stored for the next match', // never handed to the match). A spectator's list stays on its session. +// * 自选编队 (0.2.0 DIY, the owner's decisions of 2026-10-05): room.diy { picks } — the player's picks for the four DIY +// slots ({ [slotBaseId]: { charId, skillIndex?, uniEquipId? } | null }) — is checked leniently (shared/protocol.js +// checkDiyPicks against the game data and the kit registry, server/sim/content/kits/index.js KITTED_CHARS: an +// illegal pick — an operator without a kit, another tier's prototype, a prototype off its locked skill, a second slot +// of one owned operator, the same operator twice in a tier, an unknown slot / skill / module — is dropped, never the +// whole roster; only malformed picks are BAD_MSG) and stored on the session and the seat exactly like the +// not-owned list: the match receives seats[].diy when it starts (bots: none — they field no 自选 piece [ASSUMED]), +// and a change while it runs is stored for the next match (ROOM_STARTED 'stored for the next match'). Every +// `welcome` carries `diyKitted` (welcomeInfo): the operators a DIY slot may field, so the client's picker offers +// exactly what the server accepts. // * Spectator seats (community report #26, owner's decision 2026-10-04 — a remake feature, the official room has none): // room.spectate { code } takes one of a co-op room's MAX_SPECTATORS (2) spectator seats, in its lobby or while its // match runs (▸ solo rooms: ROOM_FULL). A spectator is not a player: never in `seats`, never counted for the 1–4 players @@ -78,18 +88,19 @@ // {empty} for its spectators). It receives room.state (`spectators: [{ playerId, name, connected }]`) and every match // broadcast (m.public, m.ticker, m.emote, b.pool — public data); the match registers it (opts.spectators / // addSpectator) and shows it fields like an eliminated player (b.start watch / m.field), never an m.private. It may -// only g.watch (the heavy bucket, like every watcher), g.leave / room.leave, and room.loadout / room.ownership (stored -// for its session, never handed to the match); anything else → SPECTATOR (▸ emotes too). Host: room.removeSpectator { playerId } any +// only g.watch (the heavy bucket, like every watcher), g.leave / room.leave, and room.loadout / room.ownership / +// room.diy (stored for its session, never handed to the match); anything else → SPECTATOR (▸ emotes too). Host: room.removeSpectator { playerId } any // time → room.closed {kicked} to it. A spectator in a LOBBY room may take a free player seat with room.join of the same // code; a player never switches to spectating in place (ALREADY). Disconnect / grace / reconnect / expiry work as for // a player seat (the seat is kept and given back on resume). import { randomBytes, randomInt } from 'node:crypto'; import { ERR, MAX_SEATS, MAX_SPECTATORS, ROOM_CODE_LEN, modeIdFor } from '../shared/constants.js'; -import { checkLoadout, checkNotOwned } from '../shared/protocol.js'; +import { checkLoadout, checkNotOwned, checkDiyPicks } from '../shared/protocol.js'; import { encode, isDroppable, isErrCode, sendRaw, sendSession } from './net.js'; import { getData as defaultGetData, lookup } from './data.js'; import { Match as DefaultMatch } from './match/Match.js'; +import { KITTED_CHARS } from './sim/content/kits/index.js'; /** Room code alphabet: uppercase letters without I and O (and no digits, so no 0/1). */ export const CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ'; @@ -117,7 +128,8 @@ const noopLog = { info() {}, warn() {}, error() {}, debug() {} }; /** * @typedef {{ seat: number, playerId: string, name: string, isBot: boolean, ready: boolean, * connected: boolean, left: boolean, loadout?: Record | null, - * notOwned?: readonly string[] | null }} Seat + * notOwned?: readonly string[] | null, diy?: Readonly> | null }} Seat + * @typedef {{ charId: string, skillIndex: number, uniEquipId: string|null }} DiyLoadout */ /** Deep-frozen copy of a checked loadout (shared by the session, the seat and the match's PlayerState). */ @@ -127,6 +139,13 @@ function freezeLoadout(loadout) { return Object.freeze(out); } +/** Deep-frozen copy of checked 自选 picks (shared by the session, the seat and the match's PlayerState). */ +function freezeDiy(picks) { + const out = {}; + for (const [id, p] of Object.entries(picks || {})) out[id] = Object.freeze({ charId: p.charId, skillIndex: p.skillIndex, uniEquipId: p.uniEquipId ?? null }); + return Object.freeze(out); +} + /** One room: 4 seat slots, host, difficulty, optional running match. */ export class Room { /** @param {string} code @param {'solo'|'coop'} mode @param {string} difficulty @param {number} now */ @@ -298,6 +317,7 @@ export class Lobby { case 'room.start': return this.start(session); case 'room.loadout': return this.loadout(session, msg); case 'room.ownership': return this.ownership(session, msg); + case 'room.diy': return this.diy(session, msg); case 'room.spectate': return this.spectate(session, msg); case 'room.removeSpectator': return this.removeSpectator(session, msg); default: @@ -602,6 +622,29 @@ export class Lobby { return OK; } + /** + * room.diy (0.2.0 自选编队): keep the legal picks (checkDiyPicks against the data and KITTED_CHARS), store them on the + * session and the seat (see the header). A running match never takes them: it keeps the picks its seat had at its + * start. + */ + diy(session, { picks }) { + const res = checkDiyPicks(picks, { data: this.safeData(), kitted: KITTED_CHARS }); + if (!res || !('ok' in res)) return fail(ERR.BAD_MSG, res && res.detail); + const kept = freezeDiy(res.picks); + session.diy = kept; + const room = this.roomOf(session); + if (!room) return OK; + const seat = room.seatOf(session.playerId); + if (seat) seat.diy = kept; + if (room.match && seat) return fail(ERR.ROOM_STARTED, 'stored for the next match'); + return OK; + } + + /** Extra fields of every `welcome` (net.js): the operators a 自选 slot may field (shared/diy.js `kitted`). */ + welcomeInfo() { + return { diyKitted: KITTED_CHARS }; + } + // --------------------------------------------------------------------------------------------------- // Match wiring // --------------------------------------------------------------------------------------------------- @@ -616,6 +659,8 @@ export class Lobby { loadout: s.isBot ? null : s.loadout || null, // 0.2.0 补位: the chess the human marked as not owned (bots own every operator) notOwned: s.isBot ? null : s.notOwned || null, + // 0.2.0 自选编队: the human's checked DIY picks (bots field no 自选 piece [ASSUMED]) + diy: s.isBot ? null : s.diy || null, })); // lastPublic / results: the latest m.public broadcast and the m.result frames (encoded), kept for the replay. const ctx = { live: true, ended: false, disposed: false, match: null, lastPublic: null, sharedResult: null, results: new Map() }; @@ -875,6 +920,7 @@ export class Lobby { seat: idx, playerId: session.playerId, name: session.name, isBot: false, ready: false, connected: session.connected, left: false, loadout: session.loadout || null, notOwned: session.notOwned || null, + diy: session.diy || null, }; } diff --git a/server/match/Match.js b/server/match/Match.js index 215e2954..22a1c08c 100644 --- a/server/match/Match.js +++ b/server/match/Match.js @@ -14,13 +14,16 @@ // opts.modeId string modeIdFor(mode, difficulty), e.g. 'mode_multi_hard' // opts.seats Array<{ seat: 0..3, playerId: string, name: string, isBot: boolean, connected: boolean, // loadout?: { [baseChessId]: { skill: index, module: uniEquipId|'none'|null } } | null, -// notOwned?: string[] | null }> +// notOwned?: string[] | null, +// diy?: { [slotBaseId]: { charId, skillIndex, uniEquipId } } | null }> // sorted by seat, 1–4 entries, ≥ 1 human; solo ⇒ exactly 1 human and no bots. // Bot playerIds start with 'ai_'. Seat indexes may have gaps (e.g. seats 0 and 2). // `loadout` (DESIGN §16, optional): the human's operator loadout, already checked by the lobby // (shared/protocol.js checkLoadout); PlayerState re-checks it against opts.data and ignores it for bots. // `notOwned` (0.2.0 补位, optional): the base chess ids the human marked as not owned (干员持有) — fixed // for the match; those chess fight as their stand-ins (PlayerState setNotOwned; bots own everything). +// `diy` (0.2.0 自选编队, optional): the human's 自选 picks (shared/protocol.js checkDiyPicks) — fixed for +// the match; the slotted operators join the human's own shop (PlayerState setDiy / initDiyStock). // opts.spectators string[] (optional) the room's spectator seats (remake feature, community report #26; // server/lobby.js): never players — see addSpectator below // opts.seed uint32 master seed for all match randomness @@ -316,6 +319,10 @@ export class Match { this.staticInactiveBonds = bans.staticOff; this.bannedChess = bans.banned; this.pool = new SharedPool(this.gd, { banned: bans.banned }); + // 自选编队 (0.2.0): each human's slotted DIY pieces get their own stock — none for one whose bonds are all off this + // match (player/diy.js initDiyStock); no randomness is drawn here + const off = new Set([...bans.drawn, ...bans.staticOff]); + for (const ps of this.order) ps.initDiyStock(off); this.phase = PHASE.LOBBY; this.round = 0; diff --git a/server/match/PlayerState.js b/server/match/PlayerState.js index 20738af6..ffad664a 100644 --- a/server/match/PlayerState.js +++ b/server/match/PlayerState.js @@ -81,6 +81,14 @@ // loadout never applies to it, "对于补位干员其技能不可更改"): battleInput marks it `standIn: true`, placeClass / // summon range / the bots' range read the stand-in record, it makes no summons (none of the 17 stand-ins has one), // and the scouting views draw it with the stand-in's art on the board. m.private exposes `standIns`. +// * 自选编队 (0.2.0 DIY, the owner's decisions of 2026-10-05; player/diy.js): the human's `seat.diy` picks are re-checked +// against this match's data and kits and fixed for the match (`diy`; bots none [ASSUMED]). With picks, `this.gd` is the +// player's data view: the slotted slots' ids (normal and elite) are the composed 自选 records, so the player's piece +// of a slot is the operator for every rule (name, class, position, bonds from its factions, no 特质, the pick's skill +// and module; tier, price, sell price and the 3 → elite merge are the slot's). Each slotted piece has its own stock +// (8 at tier 5, 5 at tier 6 [ASSUMED]; none when every bond of it is banned this match) and joins this player's shop +// rolls once the 调度中心 reaches the slot's level; it never enters the shared pool. battleInput carries `diy` (the +// pick), m.private exposes `diy` (and `diyBanned`), the scouting views draw the operator with its pick. // // Code layout: this file keeps the constructor (the per-player state fields); the methods live in // server/match/player/, one module per concern, and are installed on PlayerState.prototype below in a fixed order, @@ -99,6 +107,8 @@ // round.js the round lifecycle the match calls (startRound, endPrep, eliminate, recompute, bond views) and // battleInput // views.js pieceView, effectsView, m.private (privateView) +// diy.js 自选编队: the picks (setDiy), the player's data view, the DIY stock (initDiyStock, poolOf), the shop's +// DIY draws (diyRollEntries), the pick of a piece (diyPickOf) // common.js HAND_SIZE, TEMP_SIZE, MAX_OFFER_SLOTS, OK, fail import { FIELD } from './board.js'; @@ -113,11 +123,12 @@ import { PlayerItems } from './player/items.js'; import { PlayerPrep } from './player/prep.js'; import { PlayerRound } from './player/round.js'; import { PlayerViews } from './player/views.js'; +import { PlayerDiy, DiyStock } from './player/diy.js'; export class PlayerState { /** * @param {import('./Match.js').Match} m owning match - * @param {{ seat: number, playerId: string, name: string, isBot: boolean, connected: boolean }} seat + * @param {{ seat: number, playerId: string, name: string, isBot: boolean, connected: boolean, loadout?: any, notOwned?: any, diy?: any }} seat */ constructor(m, seat) { this.m = m; @@ -148,6 +159,18 @@ export class PlayerState { /** @type {Set} lookup set of `standIns` */ this._standInSet = new Set(); if (!this.isBot && seat.notOwned) this.setNotOwned(seat.notOwned); + /** + * 自选编队 (0.2.0): the slotted picks { [slotBaseId]: { charId, skillIndex, uniEquipId } } — the seat's when the match + * started, re-checked; frozen; {} = none (bots always). setDiy makes `this.gd` the player's data view. + */ + this.diy = Object.freeze({}); + /** @type {Map} slot id (normal / elite) → composed 自选 record (setDiy) */ + this._diyRecords = new Map(); + /** the copies of the slotted pieces (initDiyStock, once the match's bans are drawn) */ + this.diyStock = new DiyStock(); + /** slotted slots without stock: every bond of the operator is switched off this match */ + this.diyBanned = Object.freeze([]); + if (!this.isBot && seat.diy) this.setDiy(seat.diy); this.shop = { level: 1, upgradePrice: this.gd.upgradeBase(1) ?? 0, slots: [], frozen: false, freeRefreshes: 0 }; /** reward offers queue (merge rewards, special refreshes): { tier, source, label, slots: [{ kind, id, price, sold }] } */ this.offers = []; @@ -204,7 +227,7 @@ export class PlayerState { } // the method modules, in this order (a name defined twice is an error, never a silent override) -for (const part of [PlayerBasics, PlayerPieces, PlayerAcquire, PlayerEconomy, PlayerPlacement, PlayerItems, PlayerPrep, PlayerRound, PlayerViews]) { +for (const part of [PlayerBasics, PlayerPieces, PlayerAcquire, PlayerEconomy, PlayerPlacement, PlayerItems, PlayerPrep, PlayerRound, PlayerViews, PlayerDiy]) { for (const key of Reflect.ownKeys(part.prototype)) { if (key === 'constructor') continue; if (Object.prototype.hasOwnProperty.call(PlayerState.prototype, key)) throw new Error(`PlayerState.${String(key)} is defined twice`); diff --git a/server/match/audit.js b/server/match/audit.js index df2ca814..16eb7bff 100644 --- a/server/match/audit.js +++ b/server/match/audit.js @@ -127,7 +127,10 @@ export function attachAudit(m, { invariants = true, limit = 200 } = {}) { check('shop roll', () => { const base = gd.baseIdOf(s.id); if (t > ps.shop.level) fail(`${ps.playerId}: rolled tier ${t} at shop level ${ps.shop.level}`); - if (!m.pool.has(base)) fail(`${ps.playerId}: rolled ${s.id} outside the match pool (banned/hidden)`); + // (a slotted 自选 piece comes from the player's own stock, 0.2.0 player/diy.js — once the 调度中心 is at its level) + const diy = ps.diyStock && ps.diyStock.has(base) ? ps.diyStock.entries.get(base) : null; + if (!m.pool.has(base) && !diy) fail(`${ps.playerId}: rolled ${s.id} outside the match pool (banned/hidden)`); + if (diy && ps.shop.level < diy.shopLevel) fail(`${ps.playerId}: rolled 自选 ${s.id} at shop level ${ps.shop.level} < ${diy.shopLevel}`); if (s.basePrice !== gd.chessPrice(s.id)) fail(`${ps.playerId}: ${s.id} basePrice ${s.basePrice} != ${gd.chessPrice(s.id)}`); }); } @@ -243,7 +246,7 @@ export function attachAudit(m, { invariants = true, limit = 200 } = {}) { if (ps.deployCount > deployed0) fail(`${id}: a merge of ${baseId} grew the deploy count ${deployed0} → ${ps.deployCount}`); // a pure read of the deploy field (Match.deployMapFor, as invariants.js): the audit must not refresh the cache const dmap = typeof m.deployMapFor === 'function' ? m.deployMapFor(ps) : ps.deployMap(); - const pos = placeClass(ps, gd.chess(elite.id)); + const pos = placeClass(ps, (ps.gd || gd).chess(elite.id)); const want = mergeTile([...tiles.keys()].map((key) => ({ key })), (r, c) => canPlace(dmap, pos, r, c)); if (want) { if (loc.area !== 'board' || loc.key !== want.key) fail(`${id}: the elite of ${baseId} went to ${loc.area} ${loc.key || ''}, expected the deployed copy's tile ${want.key}`); diff --git a/server/match/bot.js b/server/match/bot.js index b2ce2587..1aedccaa 100644 --- a/server/match/bot.js +++ b/server/match/bot.js @@ -133,7 +133,7 @@ const DEFAULT_MELEE_RANGE = [[0, 0], [0, 1]]; * without inactive bonds keep exactly the earlier picks. */ export function botPickBand(m, ps) { - const gd = m.gd; + const gd = ps?.gd || m.gd; const ids = gd.bandIds(); if (!ids.length) return gd.defaultBandId; const lateFunds = (id) => /暂存/.test(String(gd.band(id)?.desc || '')); @@ -185,7 +185,7 @@ export function botPickCard(m, ps, cards, available) { * first blocker's hold on the ground. 0.5 without data. */ export function bountyKillChance(m, ps, card) { - const gd = m.gd; + const gd = ps?.gd || m.gd; const e = card && gd.enemy(card.enemyKey); if (!e || !e.stats) return 0.5; const model = fieldModel(m, ps); @@ -274,7 +274,8 @@ function tacticScore(m, ps, card) { // --------------------------------------------------------------------------------------------------- // evaluation helpers -const chessRec = (m, id) => m.gd.chess(id); +// a player's record of a chess id (its data view: a human's slotted 自选 slots are its operators, 0.2.0 player/diy.js) +const chessRec = (m, id, ps = null) => (ps?.gd || m.gd).chess(id); const isHealer = (c) => !!c && (c.dmgType === 'heal' || c.attackKind === 'heal'); // the record's own position: a 钩索师 / 推击手 is a MELEE blocker like any other (a 高台 does not block) const isBlocker = (c) => !!c && basePositionClass(c) === 'melee' && (c.stats?.blockCnt ?? 1) > 0 && c.attackKind !== 'none'; @@ -291,7 +292,7 @@ function ownedBonds(m, ps, exclude = null) { const base = m.gd.baseIdOf(p.id); if (seen.has(base)) continue; seen.add(base); - const c = chessRec(m, p.id); + const c = chessRec(m, p.id, ps); for (const b of (c && c.bonds) || []) counts.set(b, (counts.get(b) || 0) + 1); } return { counts, bases: seen }; @@ -393,7 +394,7 @@ function keeperBases(m, ps, plan) { const out = new Set(); for (const p of ps.board.values()) if (p.kind === 'chess') out.add(m.gd.baseIdOf(p.id)); for (const p of ps.allChess()) { - const c = chessRec(m, p.id); + const c = chessRec(m, p.id, ps); if (!c) continue; const bonds = c.bonds || []; if ((plan.focus && bonds.includes(plan.focus)) || (plan.second && bonds.includes(plan.second)) || c.tier >= ps.shop.level - 1) out.add(m.gd.baseIdOf(p.id)); @@ -405,7 +406,7 @@ function keeperBases(m, ps, plan) { function roles(m, ps) { const r = { blockers: 0, antiAir: 0, healers: 0, total: 0 }; for (const p of ps.allChess()) { - const c = chessRec(m, p.id); + const c = chessRec(m, p.id, ps); if (!c) continue; r.total++; if (isBlocker(c)) r.blockers++; @@ -473,7 +474,7 @@ function traitsOf(m, c) { /** Stand-alone value of an owned piece (no bond context): power, items, role fit, layer engines. */ function unitBase(m, piece, ctx) { - const c = chessRec(m, piece.id); + const c = chessRec(m, piece.id, ctx.ps); if (!c) return 0; let v = power(c) + (piece.items ? piece.items.length * 5 : 0); if (m.round <= 11) v += traitsOf(m, c).recurring * 4; @@ -485,7 +486,7 @@ function unitBase(m, piece, ctx) { /** Value of an owned piece for bench / sell decisions (its bonds counted against the other owned chess). */ function pieceValue(m, ps, piece, ctx) { - const c = chessRec(m, piece.id); + const c = chessRec(m, piece.id, ps); if (!c) return 0; return unitBase(m, piece, ctx) + bondValue(m, c, ownedBonds(m, ps, piece.uid), ctx.focus, ctx.second) * 0.8; } @@ -495,7 +496,7 @@ function pieceValue(m, ps, piece, ctx) { * members still count for BOARD_AND_DECK bonds) + composition (blockers, anti-air when the wave flies, ≤ 2 healers). */ function lineupScore(m, ps, set, ctx) { - const gd = m.gd; + const gd = ps?.gd || m.gd; const board = new Map(); set.forEach((p, i) => board.set(`x${i}`, p)); const inSet = new Set(set.map((p) => p.uid)); @@ -518,7 +519,7 @@ function lineupScore(m, ps, set, ctx) { let blockers = 0; let air = 0; let healers = 0; - for (const p of set) { const c = chessRec(m, p.id); if (isBlocker(c)) blockers++; if (hitsFly(c)) air++; if (isHealer(c)) healers++; } + for (const p of set) { const c = chessRec(m, p.id, ps); if (isBlocker(c)) blockers++; if (hitsFly(c)) air++; if (isHealer(c)) healers++; } v -= Math.max(0, Math.min(2, ctx.roles.blockers) - blockers) * 15; if (ctx.fly > 0 && ctx.roles.antiAir > 0 && air === 0) v -= 12; v -= Math.max(0, healers - 2) * 10; @@ -562,8 +563,8 @@ function chooseLineup(m, ps, ctx) { /** Shop / reward score of acquiring one copy of chess `id` (0 = not worth it). */ function buyScore(m, ps, id, ctx) { - const gd = m.gd; - const c = chessRec(m, id); + const gd = ps?.gd || m.gd; + const c = chessRec(m, id, ps); if (!c) return 0; let s = power(c) * 0.6; const tr = traitsOf(m, c); @@ -639,7 +640,7 @@ export function fieldModel(m, ps = null) { const wave = m.wave || (boss && boss.wave); const cacheKey = `${m.round}|${m.stageId}|${wave ? wave.templateId : 'boss'}|${boss ? boss.side : ''}`; if (m._botPath && m._botPath.key === cacheKey) return m._botPath; - const gd = m.gd; + const gd = ps?.gd || m.gd; const st = m.stage; const gpaths = (st && (st.groundPathsWithDevices || st.groundPaths)) || {}; const routesOut = []; @@ -835,7 +836,8 @@ export function rangeRec(ps, rec) { export function effDps(model, rec) { if (!rec) return 0; if (!model || !Array.isArray(model.armor) || !model.armor.length) return dpsOf(rec); - const id = rec.chessId || rec.tokenId || rec.id || rec.name; + // (a 自选 record — one DIY slot id, each player's own operator — is cached per operator and skill) + const id = rec.diyFor ? `${rec.chessId}@${rec.charId}#${rec.skill?.index ?? ''}` : rec.chessId || rec.tokenId || rec.id || rec.name; const cache = model.dps instanceof Map ? model.dps : null; if (cache && cache.has(id)) return cache.get(id); let v = 0; @@ -938,7 +940,8 @@ export function planLayout(m, ps, pieces, params = LAYOUT_PARAMS, opts = {}) { export function* planLayoutSteps(m, ps, pieces, params = LAYOUT_PARAMS, { occupied = new Set(), recOf = null } = {}) { const model = fieldModel(m, ps); const map = ps.deployMap(); - const rec = recOf || ((p) => (p.kind === 'token' ? m.gd.token(p.id) : rangeRec(ps, m.gd.chess(p.id)))); + const pgd = ps.gd || m.gd; + const rec = recOf || ((p) => (p.kind === 'token' ? pgd.token(p.id) : rangeRec(ps, pgd.chess(p.id)))); const layout = new Layout(model, params); const rank = (p) => { const r = rec(p); return isBlocker(r) ? 0 : isHealer(r) ? 2 : 1; }; const order = pieces.slice().sort((a, b) => rank(a) - rank(b) || dpsOf(rec(b)) - dpsOf(rec(a)) || a.uid - b.uid); @@ -953,7 +956,7 @@ export function* planLayoutSteps(m, ps, pieces, params = LAYOUT_PARAMS, { occupi const within = p.kind === 'token' && typeof ps.summonRange === 'function' ? ps.summonRange(p) : null; // ground tiles for a MELEE blocker. placeClass 'all' is only elite 歌蕾蒂娅 + HOK-Y: she may stand on a 高台, // and when one of those tiles covers the enemy road she is planned there (owner 2026-10-04: the bot uses the 高台). - const cls = p.kind === 'token' ? basePositionClass(r0) : placeClass(ps, m.gd.chess(p.id) || r0); + const cls = p.kind === 'token' ? basePositionClass(r0) : placeClass(ps, pgd.chess(p.id) || r0); const preferHigh = cls === 'all' && basePositionClass(r0) === 'melee'; let bestHigh = null; let bestHighV = -Infinity; @@ -1161,7 +1164,7 @@ function context(m, ps) { const copies = copyCounts(m, ps); let pairs = 0; for (const [b, k] of copies) if (k + 1 >= mergeNeed(m, b)) pairs++; - return { owned, focus: plan.focus, second: plan.second, keep: keeperBases(m, ps, plan), copies, pairs, roles: roles(m, ps), fly: model.flyTotal, model }; + return { ps, owned, focus: plan.focus, second: plan.second, keep: keeperBases(m, ps, plan), copies, pairs, roles: roles(m, ps), fly: model.flyTotal, model }; } /** Normal copies owned per base (board, hand, temp). */ @@ -1254,7 +1257,7 @@ function sellJunk(m, ps) { const keep = []; const junk = []; for (const p of bench) { - const c = chessRec(m, p.id); + const c = chessRec(m, p.id, ps); if (!c) continue; const base = m.gd.baseIdOf(p.id); const copies = c.isGolden ? 0 : ctx.copies.get(base) || 0; @@ -1271,7 +1274,7 @@ function sellJunk(m, ps) { /** Take the queued pick-one offers (merge rewards, special refreshes — free; they expire at prep end). */ function takeOffers(m, ps) { - const gd = m.gd; + const gd = ps?.gd || m.gd; for (let guard = 0; guard < 4 && ps.offers.length; guard++) { const offer = ps.offers[0]; const ctx = context(m, ps); @@ -1378,7 +1381,7 @@ function maybeFreeze(m, ps) { } function levelUp(m, ps, { spare = false } = {}) { - const gd = m.gd; + const gd = ps?.gd || m.gd; for (let guard = 0; guard < 6; guard++) { if (ps.shop.level >= gd.maxShopLevel) return; const price = Math.max(0, ps.shop.upgradePrice); @@ -1413,7 +1416,7 @@ function fundsReserve(m, ps) { /** Buying / rerolling toward the lineup; a step generator (yields after each purchase or reroll). */ function* buyLoopSteps(m, ps, { fillOnly = false, maxRefreshes = 0 } = {}) { - const gd = m.gd; + const gd = ps?.gd || m.gd; let refreshes = 0; const minPrice = 2; const reserve = fundsReserve(m, ps); @@ -1464,7 +1467,7 @@ function* buyLoopSteps(m, ps, { fillOnly = false, maxRefreshes = 0 } = {}) { const gain = lineupGain(m, ps, s.id, ctx, cur); if (gain <= 2) return; sc += Math.min(15, gain * 0.5); - } else if (gd.isGolden(s.id) || (!ctx.keep.has(base) && !(ctx.pairs < MAX_PAIRS && (chessRec(m, s.id)?.tier ?? 6) <= 3 && countFree(ps.hand) >= 3))) { + } else if (gd.isGolden(s.id) || (!ctx.keep.has(base) && !(ctx.pairs < MAX_PAIRS && (chessRec(m, s.id, ps)?.tier ?? 6) <= 3 && countFree(ps.hand) >= 3))) { // a second copy of a non-keeper with no bench room for another pair is clutter, sold later at a loss return; } @@ -1607,7 +1610,7 @@ const SUPPORT_SPOTS = Object.freeze([[0, -1, 'RIGHT'], [1, 0, 'DOWN'], [-1, 0, ' * first — they take the hits —, then DPS), pointing at it; null when none is free. */ function supportSpot(m, ps, p) { - const rec = m.gd.token(p.id); + const rec = (ps.gd || m.gd).token(p.id); if (!rec) return null; const map = ps.deployMap(); const pos = positionClass(rec); @@ -1618,7 +1621,7 @@ function supportSpot(m, ps, p) { const [dr, dc] = rotateOffset(0, 1, pieceDir(q)); faced.add(tileKey(r + dr, c + dc)); } - const ops = [...ps.board.entries()].filter(([k, q]) => q.kind === 'chess' && !faced.has(k)).map(([k, q]) => [k, m.gd.chess(q.id)]).filter(([, c]) => c) + const ops = [...ps.board.entries()].filter(([k, q]) => q.kind === 'chess' && !faced.has(k)).map(([k, q]) => [k, (ps.gd || m.gd).chess(q.id)]).filter(([, c]) => c) .sort((a, b) => Number(isBlocker(b[1])) - Number(isBlocker(a[1])) || dpsOf(b[1]) - dpsOf(a[1]) || (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0)); for (const [k] of ops) { const [r, c] = parseKey(k); @@ -1676,7 +1679,7 @@ const isMutationCell = (gd, itemId) => itemEffect(gd.item(itemId)) === 'char_che * progress would be lost), nobody already carrying a cell. null: the cell waits in the hand. */ export function cellTarget(m, ps, ctx = context(m, ps)) { - const gd = m.gd; + const gd = ps?.gd || m.gd; let best = null; let bestV = Infinity; for (const p of ps.allChess()) { @@ -1707,7 +1710,7 @@ function lastPerfect(m, ps) { * other equipment: the strongest deployed damage dealers with a free slot (SURVIVAL items blockers first). */ export function itemTarget(m, ps, item, ctx = context(m, ps)) { - const gd = m.gd; + const gd = ps?.gd || m.gd; const rec = gd.item(item.id); if (!rec) return null; const key = itemEffect(rec); @@ -1717,19 +1720,19 @@ export function itemTarget(m, ps, item, ctx = context(m, ps)) { const owned = ps.allChess(); const normal = (p) => !gd.isGolden(p.id); const byVal = (list, dir = -1) => list.slice().sort((a, b) => dir * (val(a) - val(b)) || a.uid - b.uid); - const rel = (p) => { let r = 0; for (const b of chessRec(m, p.id)?.bonds || []) r += b === ctx.focus ? 3 : b === ctx.second ? 2 : ps.bonds[b] && ps.bonds[b].active ? 1 : 0; return r; }; + const rel = (p) => { let r = 0; for (const b of chessRec(m, p.id, ps)?.bonds || []) r += b === ctx.focus ? 3 : b === ctx.second ? 2 : ps.bonds[b] && ps.bonds[b].active ? 1 : 0; return r; }; switch (key) { case 'use_equip_recruit_new_char_and_give_char_to_player_most_bond': { // a bench single of the highest tier (a pick of two of its tier), else the weakest deployed normal operator const counts = copyCounts(m, ps); const bench = owned.filter((p) => normal(p) && ps.find(p.uid)?.area !== 'board' && (counts.get(gd.baseIdOf(p.id)) || 0) < 2) - .sort((a, b) => (chessRec(m, b.id)?.tier || 0) - (chessRec(m, a.id)?.tier || 0) || val(a) - val(b) || a.uid - b.uid); + .sort((a, b) => (chessRec(m, b.id, ps)?.tier || 0) - (chessRec(m, a.id, ps)?.tier || 0) || val(a) - val(b) || a.uid - b.uid); return bench[0] || byVal(deployed.filter(normal), 1)[0] || null; } case 'use_equip_reward_char_chess': { const counts = copyCounts(m, ps); const pair = owned.filter((p) => normal(p) && (counts.get(gd.baseIdOf(p.id)) || 0) + 1 >= mergeNeed(m, gd.baseIdOf(p.id))) - .sort((a, b) => (chessRec(m, b.id)?.tier || 0) - (chessRec(m, a.id)?.tier || 0) || a.uid - b.uid); + .sort((a, b) => (chessRec(m, b.id, ps)?.tier || 0) - (chessRec(m, a.id, ps)?.tier || 0) || a.uid - b.uid); return pair[0] || owned.slice().sort((a, b) => rel(b) - rel(a) || val(b) - val(a) || a.uid - b.uid)[0] || null; } // 博士投影: the normal one promotes at the next round start, the golden one (缪尔赛思's R1 item, item merges) at once; @@ -1751,12 +1754,12 @@ export function itemTarget(m, ps, item, ctx = context(m, ps)) { if (consume) return list[0] || owned[0] || null; const free = (p) => (p.items || []).length < gd.equipPerChess; if (rec.requiresBondId) { - const member = list.find((p) => free(p) && (chessRec(m, p.id)?.bonds || []).includes(rec.requiresBondId)); + const member = list.find((p) => free(p) && (chessRec(m, p.id, ps)?.bonds || []).includes(rec.requiresBondId)); if (member) return member; } // damage dealers carry equipment first (healers / non-attackers last); survival items on blockers first - const dealer = (p) => { const c = chessRec(m, p.id); return c && !isHealer(c) && c.attackKind !== 'none'; }; - const blocker = (p) => isBlocker(chessRec(m, p.id)); + const dealer = (p) => { const c = chessRec(m, p.id, ps); return c && !isHealer(c) && c.attackKind !== 'none'; }; + const blocker = (p) => isBlocker(chessRec(m, p.id, ps)); const order = rec.category === 'SURVIVAL' ? [...list.filter(blocker), ...list.filter((p) => !blocker(p) && dealer(p)), ...list.filter((p) => !blocker(p) && !dealer(p))] : [...list.filter(dealer), ...list.filter((p) => !dealer(p))]; @@ -1794,7 +1797,7 @@ function useArt(m, ps, item, ctx) { } function equipItems(m, ps) { - const gd = m.gd; + const gd = ps?.gd || m.gd; const tried = new Set(); for (let guard = 0; guard < 12; guard++) { const item = [...ps.hand, ...ps.temp].find((p) => p && p.kind === 'item' && canUseItem(m, ps, p) && !tried.has(p.uid)); @@ -1820,7 +1823,7 @@ function makeHandRoom(m, ps, piece = null) { const room = () => freeSlot(ps.hand) >= 0 || (piece != null && !ps.temp.includes(piece)); if (room()) return true; if (sellWeakestHand(m, ps, { handOnly: true }) && room()) return true; - const gd = m.gd; + const gd = ps?.gd || m.gd; const junk = ps.isBot && !ps.autoplay ? ps.hand.filter((p) => p && p.kind === 'item' && !isMutationCell(gd, p.id)) .sort((a, b) => ((gd.item(a.id) || {}).price || 0) - ((gd.item(b.id) || {}).price || 0) || a.uid - b.uid)[0] : null; if (junk && tryDo(() => ps.destroy(junk.uid)) && room()) return true; diff --git a/server/match/builtinMeta.js b/server/match/builtinMeta.js index 95de6566..3cc38185 100644 --- a/server/match/builtinMeta.js +++ b/server/match/builtinMeta.js @@ -172,8 +172,10 @@ const ITEM_HANDLERS = { if (id) ids.push(id); } if (ids.length) ctx.offerChess(ids, { source: 'item', tier }); - // co-op: next prep, send the original chess to the teammate with the most members of its bonds - const to = mostBondMate(ctx, bonds); + // co-op: next prep, send the original chess to the teammate with the most members of its bonds — except a 自选 + // piece (0.2.0): its DIY slot is bound to this player's roster (no teammate's shop or slot can hold that operator), + // so nothing is sent [ASSUMED: the official text names no 自选 case] + const to = ctx.chessRecord(original)?.diyFor ? null : mostBondMate(ctx, bonds); if (to) ctx.addEffect({ id: `gift:${ev.item.uid}`, key: 'effect:builtin_gift', hidden: true, battle: false, params: { toPlayerId: to.playerId, chessId: original, bonds } }); }, }, diff --git a/server/match/effectsMeta.js b/server/match/effectsMeta.js index 4687419b..980bb5f5 100644 --- a/server/match/effectsMeta.js +++ b/server/match/effectsMeta.js @@ -280,7 +280,7 @@ export class EffectDispatcher { } _garrisons(ps, hook, ev) { - const gd = this.m.gd; + const gd = ps.gd || this.m.gd; const run = (piece, where) => { const rec = gd.chess(piece.id); if (!rec || !Array.isArray(rec.garrisonIds)) return; @@ -314,7 +314,7 @@ export class EffectDispatcher { * SERVER_GAIN. SERVER_PRICE cannot be triggered. Returns the number of garrison handlers run. */ triggerGarrisons(ps, piece, eventType, { asPiece = null, triggeredBy = null } = {}) { - const gd = this.m.gd; + const gd = ps.gd || this.m.gd; const hook = eventType === 'SERVER_PRICE' ? null : GARRISON_HOOK[eventType]; const rec = piece && piece.kind === 'chess' ? gd.chess(piece.id) : null; if (!hook || !rec || !Array.isArray(rec.garrisonIds) || this.depth >= MAX_DEPTH) return 0; @@ -426,7 +426,9 @@ function grantSpeaker(gd, source) { * @param {object|null} [ev] the event being dispatched (onPrice: ctx.modifyPrice / ctx.setPrice edit ev.price) */ export function makeCtx(m, ps, source, hook, ev = null) { - const gd = m.gd; + // the player's view of the data: its slotted 自选 slots are its operators (0.2.0, player/diy.js) — chessRecord, + // pieceBonds, a strategy's / 特质's bond and tier reads see the operator; the match's GameData for everyone else + const gd = ps.gd || m.gd; const view = (p) => (p ? ps.pieceView(p) : null); const ctx = { hook, @@ -516,8 +518,10 @@ export function makeCtx(m, ps, source, hook, ev = null) { if (opts.golden) id = gd.goldenIdOf(chessId) || chessId; if (!gd.chess(id)) return null; const base = gd.baseIdOf(id); - // "some effects fail when the cap is hit" (research 06 §7): by default a chess of the pool needs a free copy - if (opts.requirePool !== false && m.pool.has(base) && m.pool.left(base) < 1) return null; + // "some effects fail when the cap is hit" (research 06 §7): by default a chess of the pool needs a free copy (a + // 自选 piece: one of the player's own stock — player/diy.js poolOf) + const pool = typeof ps.poolOf === 'function' ? ps.poolOf(base) : m.pool; + if (opts.requirePool !== false && pool.has(base) && pool.left(base) < 1) return null; const p = ps.acquireChess(id, { source: opts.source || source.key || 'effect', toTemp: !!opts.toTemp, fromPool: opts.fromPool !== false }); // 「歌蕾蒂娅:获得斯卡蒂」 — every silent grantChess (a 特质, 余 SERVER_MOST_BOND, a band, an item, a choice). // opts.toast === false skips it. A caller that already says the same thing should pass that. diff --git a/server/match/fields.js b/server/match/fields.js index 6e71e9cc..f26e14c2 100644 --- a/server/match/fields.js +++ b/server/match/fields.js @@ -33,6 +33,7 @@ import { TICK, SNAPSHOT_EVERY } from '../sim/constants.js'; import { layerGainRoom } from '../../shared/constants.js'; import { uniteLeft } from '../sim/spec.js'; import { GRANTED_CAP_OVERRIDE } from '../sim/content/garrisons/battle.js'; +import { diyTokenOwner } from '../../shared/diy.js'; export const MAX_TICKS_PER_INTERVAL = 8; export const INTERVAL_MS = 1000 / 30; @@ -542,6 +543,17 @@ function summonsOf(gd, chessId) { for (const id of [chessId, rec && rec.baseId, rec && rec.goldenId]) for (const t of (id && idx.byOwner.get(id)) || []) out.add(t); return [...out]; } +/** + * Summon ids a 自选 piece (0.2.0: a DIY slot `slotId` with its `pick`) can create: data/backups.json tokens whose owners + * name the pick's owner form (`@`, shared/diy.js diyTokenOwner). + */ +function diySummonsOf(gd, slotId, pick) { + const tokens = gd.raw && gd.raw.backups && gd.raw.backups.tokens && typeof gd.raw.backups.tokens === 'object' ? gd.raw.backups.tokens : null; + const slot = typeof gd.chess === 'function' && typeof slotId === 'string' ? gd.chess(slotId) : null; + if (!tokens || !slot || !pick || typeof pick.charId !== 'string') return []; + const owner = diyTokenOwner(pick.charId, slot.status); + return Object.keys(tokens).filter((id) => Array.isArray(tokens[id] && tokens[id].owners) && tokens[id].owners.includes(owner)); +} /** Summons no chess owns (bond / band units every player may field: 炎佑, 预备干员-医疗, Touch). */ function ownerlessSummons(gd) { return summonIndex(gd).ownerless; } @@ -674,6 +686,7 @@ export function specBounds(spec, gd = null) { if (u.kind !== 'token') { chess.set(u.uid, defId); if (gd) for (const t of summonsOf(gd, defId)) defIds.add(t); + if (gd && u.diy) for (const t of diySummonsOf(gd, defId, u.diy)) defIds.add(t); } } // the layers each bond starts the battle with (PlayerBattleInput.bonds): a gain never passes BOND_LAYER_CAP diff --git a/server/match/invariants.js b/server/match/invariants.js index 6e855607..017398c1 100644 --- a/server/match/invariants.js +++ b/server/match/invariants.js @@ -2,7 +2,9 @@ // (test/match/harness.js checkInvariants asserts the list is empty) and by tools/matchrun.mjs --check sweeps. // // collectViolations(m) → string[] (empty when every invariant holds): -// pool 0 ≤ left ≤ cap and left + Σ copies held by pieces == cap per base chess; non-pool chess hold 0 copies +// pool 0 ≤ left ≤ cap and left + Σ copies held by pieces == cap per base chess; non-pool chess hold 0 copies; +// a player's 自选 stock (0.2.0, player/diy.js) the same against its own pieces of each slotted slot — a DIY +// piece is always a slotted slot of its owner's, and a DIY shop / reward card one of its stocked slots // economy funds / pendingFunds non-negative integers, LP finite, shop level in range, prices ≥ 0 // pieces unique uids; hand 10 / temp 5 slots; temp holds pieces only while the hand is full (a free hand slot pulls // a temp piece in, PlayerState._fillHandFromTemp); chess carry ≤ equipPerChess known items; a normal piece @@ -47,6 +49,9 @@ export function collectViolations(m, { limit = 25 } = {}) { for (const ps of m.players.values()) { const id = ps.playerId; + // the player's view of the data (its slotted 自选 slots are its operators) and the copies its 自选 pieces hold + const pgd = ps.gd || gd; + const diyHeld = new Map(); if (!Number.isInteger(ps.funds) || ps.funds < 0) fail(`${id}: funds ${ps.funds}`); if (!Number.isInteger(ps.pendingFunds) || ps.pendingFunds < 0) fail(`${id}: pendingFunds ${ps.pendingFunds}`); if (!Number.isFinite(ps.lp)) fail(`${id}: lp ${ps.lp}`); @@ -81,8 +86,9 @@ export function collectViolations(m, { limit = 25 } = {}) { for (const p of all) { note(ps, p); if (p.kind === 'chess') { - const rec = gd.chess(p.id); + const rec = pgd.chess(p.id); if (!rec) { fail(`${id}: unknown chess ${p.id}`); continue; } + if (rec.isDiy && !rec.diyFor) fail(`${id}: owns ${p.id}, a 自选 slot it has not filled`); if (!Array.isArray(p.items) || p.items.length > gd.equipPerChess) fail(`${id}: ${p.id} carries ${p.items && p.items.length} items`); for (const it of p.items || []) { note(ps, it); @@ -93,12 +99,13 @@ export function collectViolations(m, { limit = 25 } = {}) { const maxCopies = rec.isGolden ? gd.goldenCopies : 1; if (!Number.isInteger(p.poolCopies) || p.poolCopies < 0 || p.poolCopies > maxCopies) fail(`${id}: ${p.id} holds ${p.poolCopies} copies`); const base = gd.baseIdOf(p.id); - held.set(base, (held.get(base) || 0) + (p.poolCopies || 0)); + const tally = rec.isDiy ? diyHeld : held; + tally.set(base, (tally.get(base) || 0) + (p.poolCopies || 0)); } else if (p.kind === 'item') { if (!gd.item(p.id)) fail(`${id}: unknown item ${p.id}`); countItem(p); } else if (p.kind === 'token') { - if (!gd.token(p.id)) fail(`${id}: unknown token ${p.id}`); + if (!pgd.token(p.id)) fail(`${id}: unknown token ${p.id}`); if (!(p.count >= 1)) fail(`${id}: token stack count ${p.count}`); // summons exist only while their owner is deployed (withdrawing / selling / merging it removes them) if (!boardChessUids.has(p.ownerUid)) fail(`${id}: token ${p.uid} without a deployed owner (${p.ownerUid})`); @@ -115,7 +122,7 @@ export function collectViolations(m, { limit = 25 } = {}) { const [r, c] = parseKey(k); if (!(r >= FIELD.r0 && r <= FIELD.r1 && c >= FIELD.c0 && c <= FIELD.c1)) fail(`${id}: piece outside the board at ${k}`); if (p.kind === 'item') { fail(`${id}: item ${p.id} stands on the board`); continue; } - const rec = p.kind === 'token' ? gd.token(p.id) : gd.chess(p.id); + const rec = p.kind === 'token' ? pgd.token(p.id) : pgd.chess(p.id); const cls = p.kind === 'chess' ? placeClass(ps, rec) : positionClass(rec); if (rec && !canPlace(dmap, cls, r, c)) fail(`${id}: ${p.id} on an illegal tile ${k}`); // a "只能部署在召唤者攻击范围内" summon inside its owner's attack range (PlayerState.summonRange: a pure read) @@ -126,7 +133,7 @@ export function collectViolations(m, { limit = 25 } = {}) { if (deployed > ps.deployCap) fail(`${id}: ${deployed} chess deployed > cap ${ps.deployCap}`); // merges are immediate const copies = new Map(); - for (const p of all) if (p.kind === 'chess' && gd.chess(p.id) && !gd.isGolden(p.id)) { const b = gd.baseIdOf(p.id); copies.set(b, (copies.get(b) || 0) + 1); } + for (const p of all) if (p.kind === 'chess' && pgd.chess(p.id) && !gd.isGolden(p.id)) { const b = gd.baseIdOf(p.id); copies.set(b, (copies.get(b) || 0) + 1); } for (const [b, n] of copies) { const need = gd.mergeCount(b); if (need > 1 && gd.goldenIdOf(b) && n >= need) fail(`${id}: owns ${n} normal copies of ${b} (merges at ${need})`); @@ -140,7 +147,7 @@ export function collectViolations(m, { limit = 25 } = {}) { } // bonds are up to date try { - const fresh = computeBonds(gd, ps); + const fresh = computeBonds(pgd, ps); if (JSON.stringify(fresh) !== JSON.stringify(ps.bonds)) fail(`${id}: stale bonds (missing recompute)`); } catch (e) { fail(`${id}: computeBonds threw ${e && e.message}`); @@ -154,15 +161,24 @@ export function collectViolations(m, { limit = 25 } = {}) { if (s.kind === 'chess' ? !gd.chess(s.id) : !gd.item(s.id)) fail(`${id}: shop slot ${i} unknown ${s.kind} ${s.id}`); if (!Number.isInteger(s.basePrice) || s.basePrice < 0) fail(`${id}: shop slot ${i} basePrice ${s.basePrice}`); if (s.kind === 'chess' && banned.has(gd.baseIdOf(s.id))) fail(`${id}: banned chess ${s.id} in the shop`); + if (s.kind === 'chess' && gd.chess(s.id)?.isDiy && !(ps.diyStock && ps.diyStock.has(gd.baseIdOf(s.id)))) fail(`${id}: 自选 slot ${s.id} in the shop without its stock`); }); for (const o of ps.offers) { for (const s of o.slots || []) { if (s.kind === 'item' ? !gd.item(s.id) : !gd.chess(s.id)) fail(`${id}: bad reward slot ${s.kind} ${s.id}`); if (s.kind !== 'item' && banned.has(gd.baseIdOf(s.id)) && o.source === 'merge') fail(`${id}: banned chess ${s.id} offered as a merge reward`); + if (s.kind !== 'item' && gd.chess(s.id)?.isDiy && !(ps.diyStock && ps.diyStock.has(gd.baseIdOf(s.id)))) fail(`${id}: 自选 slot ${s.id} offered without its stock`); } if (!o.slots || !o.slots.length || o.slots.length > 6) fail(`${id}: reward offer with ${o.slots && o.slots.length} slots`); } } + // 自选 stock accounting (player/diy.js): left + held == cap per slotted slot; a slot without stock holds nothing + for (const [base, e] of ps.diyStock ? ps.diyStock.entries : []) { + if (!(e.left >= 0 && e.left <= e.cap)) fail(`${id}: 自选 stock ${base}: left ${e.left} cap ${e.cap}`); + const h = diyHeld.get(base) || 0; + if (e.left + h !== e.cap) fail(`${id}: 自选 stock ${base}: left ${e.left} + held ${h} != cap ${e.cap}`); + } + for (const [base, n] of diyHeld) if (!(ps.diyStock && ps.diyStock.has(base)) && n !== 0) fail(`${id}: 自选 slot ${base} without stock holds ${n} copies`); } // shared pool accounting diff --git a/server/match/match/views.js b/server/match/match/views.js index d720b9d2..3d5f5c05 100644 --- a/server/match/match/views.js +++ b/server/match/match/views.js @@ -81,7 +81,7 @@ export class MatchViews { // nobody can watch an eliminated player (g.watch refuses them, they have no field) and the result screen reads // m.result's own bonds, so their layers would only cost every m.public bytes for the rest of the match // (the mode-off bonds with members included, `off: true`, as in m.private — bondsMeta.offBondCounts) - bonds: ps.alive ? bondList(this.gd, ps.bondsView(), { off: offBondCounts(this.gd, ps) }) : [], + bonds: ps.alive ? bondList(this.gd, ps.bondsView(), { off: offBondCounts(ps.gd || this.gd, ps) }) : [], fieldId: this.fieldOf(ps), status: this.statusOf(ps), autoplay: ps.autoplay, @@ -186,11 +186,18 @@ export class MatchViews { * hand row (row 7) — the scout renders like the own prep bench. */ prepFieldMeta(ps) { const units = []; + // the scouted player's own view of the data (0.2.0 自选编队: its slotted DIY slots are its operators — player/diy.js) + const gd = ps.gd || this.gd; + // a 自选 piece's pick (like the sim's UnitInfo.diy): the scout's card composes the operator from it (shared/diy.js) + const diyOf = (piece) => { + const p = piece.kind === 'chess' && typeof ps.diyPickOf === 'function' ? ps.diyPickOf(piece.id) : null; + return p ? { charId: p.charId, skillIndex: p.skillIndex, uniEquipId: p.uniEquipId } : undefined; + }; for (const { r, c, piece } of boardOrder(ps.board)) { - const chess = piece.kind === 'token' ? null : this.gd.chess(piece.id); + const chess = piece.kind === 'token' ? null : gd.chess(piece.id); // 0.2.0 补位: a chess this player fields as its stand-in is deployed with the stand-in's body — name, art, max HP, // skill, like the sim's UnitInfo (`standInFor` = the replaced operator's charId) - const rec = piece.kind === 'token' ? this.gd.token(piece.id) : ps.fieldRecord(chess); + const rec = piece.kind === 'token' ? gd.token(piece.id) : ps.fieldRecord(chess); const assets = (rec && rec.assets) || {}; // DESIGN §16: the skill / module THIS player's operator fights with (the scout's detail card shows it, like the // sim's UnitInfo in a shared field); moduleId only for an elite @@ -205,6 +212,7 @@ export class MatchViews { // the equipped items (like the sim's UnitInfo): a 变形同构体 wearer shows as a member of the bond it grants items: piece.kind === 'chess' && Array.isArray(piece.items) && piece.items.length ? piece.items.map((it) => it.id) : undefined, standInFor: rec && rec.standInFor ? rec.standInFor : undefined, + diy: diyOf(piece), }); } // the hand (整备区) and the 临时整备区 scout exactly like the own prep bench renders them: pieces as units on @@ -215,7 +223,7 @@ export class MatchViews { // (a held chess the player fields as its stand-in keeps the chess's own art — the hand shows the original operator, // the approved 0.2.0 补位 plan — and carries `standInFor`: its card shows the stand-in it deploys as) const benchUnit = (piece, i, y) => { - const rec = piece.kind === 'item' ? this.gd.item(piece.id) : piece.kind === 'token' ? this.gd.token(piece.id) : this.gd.chess(piece.id); + const rec = piece.kind === 'item' ? gd.item(piece.id) : piece.kind === 'token' ? gd.token(piece.id) : gd.chess(piece.id); const assets = (rec && rec.assets) || {}; const lo = piece.kind === 'chess' && rec ? ps.loadoutFor(rec) : null; const standIn = piece.kind === 'chess' && rec && ps.fieldsStandIn(rec) ? this.gd.standIn(rec.chessId) : null; @@ -229,6 +237,7 @@ export class MatchViews { moduleId: lo && typeof lo.moduleId === 'string' ? lo.moduleId : undefined, items: piece.kind === 'chess' && Array.isArray(piece.items) && piece.items.length ? piece.items.map((it) => it.id) : undefined, standInFor: standIn && standIn.standInFor ? standIn.standInFor : undefined, + diy: diyOf(piece), }); }; for (let i = 0; i < ps.hand.length; i++) { diff --git a/server/match/player/acquire.js b/server/match/player/acquire.js index b1a6dbd1..19e60aca 100644 --- a/server/match/player/acquire.js +++ b/server/match/player/acquire.js @@ -37,10 +37,12 @@ export class PlayerAcquire { */ acquireChess(chessId, { source = 'grant', toTemp = false, fromPool = true, silent = false } = {}) { const rec = this.gd.chess(chessId); - if (!rec) return null; + // a DIY slot is only ever this player's own 自选 piece: a slot it has not filled has no body (甄选干员) to gain + if (!rec || (rec.isDiy && !rec.diyFor)) return null; const base = this.gd.baseIdOf(chessId); const need = rec.isGolden ? this.gd.goldenCopies : 1; - const taken = fromPool ? this.m.pool.take(base, need) : 0; + // the shared pool, or this player's stock of a slotted 自选 piece (player/diy.js poolOf) + const taken = fromPool ? this.poolOf(base).take(base, need) : 0; const piece = this.newPiece('chess', chessId, { poolCopies: taken }); this.round.gainedChess++; let owned = piece; @@ -121,7 +123,7 @@ export class PlayerAcquire { // equipment, which would be lost in temp — a summon stack removed there comes back at the next round start) if (where === 'board') this.grantTokensFor(elite); if (!where) { - this.m.pool.give(baseId, copies); + this.poolOf(baseId).give(baseId, copies); this.m.toast(this, 'warn', '整备区已满,晋升的精锐干员无法放入'); this.m.log.warn?.(`[match ${this.m.roomCode}] ${this.playerId}: merge result dropped (hand+temp full)`); this.recompute(); @@ -142,7 +144,7 @@ export class PlayerAcquire { if (!goldenId) return false; const base = this.gd.baseIdOf(piece.id); const extra = Math.max(0, this.gd.goldenCopies - (piece.poolCopies || 0)); - piece.poolCopies = (piece.poolCopies || 0) + this.m.pool.take(base, extra); + piece.poolCopies = (piece.poolCopies || 0) + this.poolOf(base).take(base, extra); piece.id = goldenId; this.recompute(); return true; @@ -205,6 +207,8 @@ export class PlayerAcquire { * copy-weighted roll from the shared pool excluding the ones already drawn; a tier left without another chess tops * up from the tier below (user playtest #6 item 19: the official promotion reward never offers one operator twice — * the user's first-hand report; the normal shop's slots may repeat). The offer reserves no copies (the pick takes one). + * The player's slotted 自选 pieces join the draw like in its shop (player/diy.js diyRollEntries: once the 调度中心 is at + * the slot's level) — the reward is a temporary refresh of the shop ("临时刷新3名…干员") [ASSUMED]. */ pushRewardOffer(source = 'merge', { tier = null, ids = null, label = null } = {}) { const ro = this.gd.rewardOffer(); @@ -216,7 +220,7 @@ export class PlayerAcquire { const fresh = (id) => !list.includes(id); for (let i = 0; i < ro.count; i++) { let id = null; - for (let tt = t; tt >= 1 && !id; tt--) id = this.m.pool.roll(this.m.rngShop, { tier: tt, filter: fresh }); + for (let tt = t; tt >= 1 && !id; tt--) id = this.m.pool.roll(this.m.rngShop, { tier: tt, filter: fresh, extra: this.diyRollEntries() }); if (id) list.push(id); } } diff --git a/server/match/player/basics.js b/server/match/player/basics.js index 1088abfe..c9411d96 100644 --- a/server/match/player/basics.js +++ b/server/match/player/basics.js @@ -77,13 +77,16 @@ export class PlayerBasics { /** * The skill index / module a chess record fights with under this player's loadout (DESIGN §16) — for a chess this - * player fields as its stand-in (0.2.0 补位) the stand-in's backup selection, whatever the loadout says. + * player fields as its stand-in (0.2.0 补位) the stand-in's backup selection, whatever the loadout says; for a slotted + * 自选 slot (0.2.0, player/diy.js) its pick's skill and module (the composed record's defaults — the loadout never names + * a DIY slot: shared/protocol.js checkLoadout). */ loadoutFor(chessRecord) { if (this.fieldsStandIn(chessRecord)) { return resolveLoadout(null, this.gd.standIn(chessRecord.chessId), (id) => this.gd.standIn(id) || this.gd.chess(id)); } - return resolveLoadout(this.loadout, chessRecord, (id) => this.gd.chess(id)); + const rec = chessRecord && chessRecord.isDiy && typeof chessRecord.chessId === 'string' ? this.gd.chess(chessRecord.chessId) || chessRecord : chessRecord; + return resolveLoadout(this.loadout, rec, (id) => this.gd.chess(id)); } /** @@ -119,13 +122,16 @@ export class PlayerBasics { /** * The record this player's piece of chess record `rec` fights with (0.2.0 补位): the stand-in record - * (gd.standIn — the chess's identity, the stand-in's body) when the player fields its stand-in, else `rec` itself. - * Rules about the unit's body read it (placement class, summon / bot ranges, the scouting art); rules about the chess - * (price, bonds, 特质, merges, pools) keep reading gd.chess. + * (gd.standIn — the chess's identity, the stand-in's body) when the player fields its stand-in; for a DIY slot record + * the player's 自选 record of that slot (this.gd — a caller holding the match's own record of the slot gets the + * operator); else `rec` itself. Rules about the unit's body read it (placement class, summon / bot ranges, the + * scouting art); rules about the chess (price, bonds, 特质, merges, pools) keep reading gd.chess. * @param {object|null} rec */ fieldRecord(rec) { - return rec && this.fieldsStandIn(rec) ? this.gd.standIn(rec.chessId) || rec : rec; + if (rec && this.fieldsStandIn(rec)) return this.gd.standIn(rec.chessId) || rec; + if (rec && rec.isDiy && !rec.diyFor && typeof rec.chessId === 'string') return this.gd.chess(rec.chessId) || rec; + return rec; } /** diff --git a/server/match/player/diy.js b/server/match/player/diy.js new file mode 100644 index 00000000..3b240da2 --- /dev/null +++ b/server/match/player/diy.js @@ -0,0 +1,209 @@ +// 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 (`@`, 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} */ + 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} 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 }` + * @returns {boolean} false when the picks are malformed (nothing changes) or the player is a bot + */ + setDiy(picks) { + if (this.isBot) return false; + const gd = this.m.gd; + const data = gd.raw; + const res = checkDiyPicks(picks, { data, kitted: KITTED_CHARS }); + 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} 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; + } +} diff --git a/server/match/player/economy.js b/server/match/player/economy.js index 51bd9e67..bc75d022 100644 --- a/server/match/player/economy.js +++ b/server/match/player/economy.js @@ -1,6 +1,6 @@ // server/match/player/economy.js — PlayerState methods: the economy — funds, spending (onSpend), prep-side bond layer -// gains (clamped by layerGainRoom), the shop (onPrice prices, copy-weighted rolls from the shared pool, freeze) and its -// intents: g.buy, g.refresh, g.freeze, g.levelUp, g.sell. +// gains (clamped by layerGainRoom), the shop (onPrice prices, copy-weighted rolls from the shared pool plus the player's +// own 自选 stock — player/diy.js —, freeze) and its intents: g.buy, g.refresh, g.freeze, g.levelUp, g.sell. // Installed on PlayerState.prototype by server/match/PlayerState.js (a method container: never instantiated; `this` is // the player state). @@ -61,7 +61,8 @@ export class PlayerEconomy { } _rollChessSlot() { - const id = this.m.pool.roll(this.m.rngShop, { maxTier: this.shop.level }); + // the slotted 自选 pieces join the draw once the 调度中心 reaches their slot's level (player/diy.js diyRollEntries) + const id = this.m.pool.roll(this.m.rngShop, { maxTier: this.shop.level, extra: this.diyRollEntries() }); return id ? { kind: 'chess', id, basePrice: this.gd.chessPrice(id), frozen: false, sold: false } : null; } @@ -115,10 +116,12 @@ export class PlayerEconomy { let piece; if (slot.kind === 'chess') { const rec = this.gd.chess(slot.id); - if (!rec) return fail(ERR.BAD_TARGET); + // a DIY slot sells only as this player's own 自选 piece (a slotted slot's record carries `diyFor`) + if (!rec || (rec.isDiy && !rec.diyFor)) return fail(ERR.BAD_TARGET); const base = this.gd.baseIdOf(slot.id); const need = rec.isGolden ? this.gd.goldenCopies : 1; - if (this.m.pool.has(base) && this.m.pool.left(base) < need) return fail(ERR.SOLD_OUT); + const pool = this.poolOf(base); + if (pool.has(base) && pool.left(base) < need) return fail(ERR.SOLD_OUT); if (handFull) return fail(ERR.HAND_FULL); this.spend(price); slot.sold = true; diff --git a/server/match/player/pieces.js b/server/match/player/pieces.js index 2c18bb41..e852c6a1 100644 --- a/server/match/player/pieces.js +++ b/server/match/player/pieces.js @@ -1,7 +1,8 @@ // server/match/player/pieces.js — PlayerState methods: piece bookkeeping — new pieces, lookup by uid (board / hand / // temp / equipped), the chess locations, detach and stow (hand first, overflow temp; a free hand slot pulls a temp piece -// in, _fillHandFromTemp), the per-piece round counters (拉普兰德), pool copies back to the shared pool, and summon stacks -// (removal, lifting, counts, the stack granted for an owner). +// in, _fillHandFromTemp), the per-piece round counters (拉普兰德), pool copies back to the shared pool (a 自选 piece's to +// the player's own stock), and summon stacks (removal, lifting, counts, the stack granted for an owner — a 自选 owner's +// from its pick, player/diy.js placeableTokens). // Installed on PlayerState.prototype by server/match/PlayerState.js (a method container: never instantiated; `this` is // the player state). @@ -132,10 +133,11 @@ export class PlayerPieces { return v; } - /** Return a piece's pool copies (and its equipped items are handled by the caller). */ + /** Return a piece's pool copies — to the shared pool, or a 自选 piece's to this player's stock (poolOf). */ returnCopies(piece) { if (piece && piece.kind === 'chess' && piece.poolCopies > 0) { - this.m.pool.give(this.gd.baseIdOf(piece.id), piece.poolCopies); + const base = this.gd.baseIdOf(piece.id); + this.poolOf(base).give(base, piece.poolCopies); piece.poolCopies = 0; } } diff --git a/server/match/player/prep.js b/server/match/player/prep.js index 61cc6a72..5403fad6 100644 --- a/server/match/player/prep.js +++ b/server/match/player/prep.js @@ -34,10 +34,11 @@ export class PlayerPrep { if (handFull) return fail(ERR.HAND_FULL); } else { const rec = this.gd.chess(slot.id); - if (!rec) return fail(ERR.BAD_TARGET); + if (!rec || (rec.isDiy && !rec.diyFor)) return fail(ERR.BAD_TARGET); const base = this.gd.baseIdOf(slot.id); const need = rec.isGolden ? this.gd.goldenCopies : 1; - if (this.m.pool.has(base) && this.m.pool.left(base) < need) return fail(ERR.SOLD_OUT); + const pool = this.poolOf(base); // the shared pool, or this player's 自选 stock + if (pool.has(base) && pool.left(base) < need) return fail(ERR.SOLD_OUT); if (handFull) return fail(ERR.HAND_FULL); } const price = Number.isFinite(slot.price) && slot.price > 0 ? Math.trunc(slot.price) : 0; diff --git a/server/match/player/round.js b/server/match/player/round.js index 9f353f32..ce52d061 100644 --- a/server/match/player/round.js +++ b/server/match/player/round.js @@ -1,7 +1,8 @@ // server/match/player/round.js — PlayerState methods: the round lifecycle the match calls — startRound (income, pending // coins, the shop, summon stacks topped up), endPrep, eliminate (every copy back to the pool), recompute (legality, // out-of-range summons, temp pieces into free hand slots, bonds), the bond views — and battleInput (the player's -// PlayerBattleInput: board units with their loadout or their 补位 mark, carried 联防 state, the reached layers). +// PlayerBattleInput: board units with their loadout, their 补位 mark or their 自选 pick, carried 联防 state, the reached +// layers). // Installed on PlayerState.prototype by server/match/PlayerState.js (a method container: never instantiated; `this` is // the player state). @@ -102,7 +103,12 @@ export class PlayerRound { for (const { r, c, piece } of boardOrder(this.board)) { if (piece.kind === 'chess') { const u = { uid: piece.uid, kind: 'chess', chessId: piece.id, row: r, col: c, dir: pieceDir(piece), items: (piece.items || []).map((i) => i.id) }; - if (this.fieldsStandIn(piece.id)) { + const pick = this.diyPickOf(piece.id); + if (pick) { + // 0.2.0 自选编队: a slotted DIY slot fights as its pick (sim getChess(id, { diy }): the operator's body, the pick's + // skill and module — no loadout fields, docs/SIM.md §12) + u.diy = { charId: pick.charId, skillIndex: pick.skillIndex, uniEquipId: pick.uniEquipId }; + } else if (this.fieldsStandIn(piece.id)) { // 0.2.0 补位: a chess this player does not own fights as its stand-in (sim getChess(id, { standIn: true }): the // backup skill / module — no loadout fields, docs/SIM.md §12) u.standIn = true; diff --git a/server/match/player/views.js b/server/match/player/views.js index f006800a..fe2b44f1 100644 --- a/server/match/player/views.js +++ b/server/match/player/views.js @@ -91,6 +91,12 @@ export class PlayerViews { // 0.2.0 补位: the base chess ids this player fields as their stand-ins in this match (the not-owned list the seat had // at the match start; [] = every operator owned) — the client's 「替补:X」 badges, the stand-in's art and card standIns: this.standIns, + // 0.2.0 自选编队: this player's 自选 picks in this match ({ [slotBaseId]: { charId, skillIndex, uniEquipId } } — the seat's + // when the match started; {} = none) — the client composes the operator's record for its own cards and pieces + // (shared/diy.js diyRecord); `diyBanned`: the slotted slots out of the shop this match (every bond of the operator + // switched off) + diy: this.diy, + diyBanned: this.diyBanned, stats: { dmgDealt: Math.round(this.stats.dmgDealt), kills: this.stats.kills, leaks: this.stats.leaks, gold: this.stats.gold, refreshes: this.stats.refreshes, merges: this.stats.merges, diff --git a/server/match/pool.js b/server/match/pool.js index 7c269b7a..33bdd256 100644 --- a/server/match/pool.js +++ b/server/match/pool.js @@ -12,7 +12,9 @@ // // Rolls: each chess slot draws ONE copy uniformly from all remaining copies of eligible chess with tier ≤ shop level // ("copy-weighted"; duplicates within a roll allowed). The item slot picks a tier with the same tier shares, then a -// uniform shop-eligible item of that tier (falling back to lower tiers). +// uniform shop-eligible item of that tier (falling back to lower tiers). A roll may add entries outside the pool +// (`extra`, after its own): one player's 自选 stock (0.2.0, player/diy.js diyRollEntries) — weighted by its copies like +// any chess, drawn by that player's shop only. /** * Per-match disabled bond set D and banned chess (research 01 A2): D = uniform sample of `core` core bonds and `addon` @@ -90,22 +92,30 @@ export class SharedPool { return k; } - /** Remaining copies of eligible chess (tier ≤ maxTier, or exactly `tier`). */ - _eligible({ maxTier = 6, tier = null, filter = null } = {}) { + /** + * Remaining copies of eligible chess (tier ≤ maxTier, or exactly `tier`): the pool's entries, then `extra` ([id, entry] + * pairs of the same shape — a player's 自选 stock) under the same filters. + */ + _eligible({ maxTier = 6, tier = null, filter = null, extra = null } = {}) { const out = []; - for (const [id, e] of this.entries) { - if (e.left <= 0) continue; - if (tier != null ? e.tier !== tier : e.tier > maxTier) continue; - if (filter && !filter(id, e)) continue; - out.push([id, e.left]); - } + const scan = (list) => { + for (const [id, e] of list) { + if (e.left <= 0) continue; + if (tier != null ? e.tier !== tier : e.tier > maxTier) continue; + if (filter && !filter(id, e)) continue; + out.push([id, e.left]); + } + }; + scan(this.entries); + if (extra) scan(extra); return out; } /** * Copy-weighted roll: one copy uniformly among remaining copies of eligible chess. Returns a base id or null. * @param {Function} rng - * @param {{ maxTier?: number, tier?: number|null, filter?: (id: string, e: object) => boolean }} [opts] + * @param {{ maxTier?: number, tier?: number|null, filter?: (id: string, e: object) => boolean, + * extra?: Iterable<[string, { left: number, tier: number }]>|null }} [opts] */ roll(rng, opts = {}) { const el = this._eligible(opts); diff --git a/server/match/results.js b/server/match/results.js index cca869f1..cd76b76f 100644 --- a/server/match/results.js +++ b/server/match/results.js @@ -95,9 +95,13 @@ export function buildResult(m, outcome) { const teamRounds = victory ? gd.bossRound + (hiddenCleared ? 1 : 0) : Math.max(0, Math.min(m.round, gd.bossRound) - 1); const rows = players.map((ps) => { const roundsPassed = !ps.alive && ps.eliminatedRound != null ? Math.max(0, ps.eliminatedRound - 1) : teamRounds; - const lineup = boardOrder(ps.board).filter((x) => x.piece.kind === 'chess').map(({ r, c, piece }) => ({ - id: piece.id, golden: gd.isGolden(piece.id), tier: gd.tierOf(piece.id), row: r, col: c, items: (piece.items || []).map((i) => i.id), - })); + const lineup = boardOrder(ps.board).filter((x) => x.piece.kind === 'chess').map(({ r, c, piece }) => { + const e = { id: piece.id, golden: gd.isGolden(piece.id), tier: gd.tierOf(piece.id), row: r, col: c, items: (piece.items || []).map((i) => i.id) }; + // 0.2.0 自选编队: a DIY slot's pick — the result screen names and draws the operator (shared/diy.js diyRecord) + const pick = typeof ps.diyPickOf === 'function' ? ps.diyPickOf(piece.id) : null; + if (pick) e.diy = { charId: pick.charId, skillIndex: pick.skillIndex, uniEquipId: pick.uniEquipId }; + return e; + }); // the team's clear counts for the players still in; an eliminated / departed teammate did not pass the boss round const cleared = victory && ps.alive; return { diff --git a/server/net.js b/server/net.js index 8189376c..341c4334 100644 --- a/server/net.js +++ b/server/net.js @@ -29,6 +29,8 @@ // // The handler object (implemented by server/lobby.js) receives: // onHello(session, { resumed, repeat }) after `welcome` was sent +// welcomeInfo() → object (optional) extra fields of every `welcome` (never one of its own keys): the lobby's +// `diyKitted` (0.2.0 自选编队 — which operators a DIY slot may field) // onMessage(session, msg) → { ok: true } | { error: ERR code, detail?: string } | undefined // routeGame(session, msg) → same (optional): client-side combat reports `b.progress` / `b.result` (DESIGN §14) go // straight to the running match through it; without it they reach onMessage @@ -61,10 +63,10 @@ export const NET_DEFAULTS = Object.freeze({ /** * Intents that also draw from the per-connection heavy bucket: g.watch (its reply is a large state resend, m.field), * room.loadout (a ≤ 160-entry map validated against the game data; the client debounces its edits), room.ownership - * (a ≤ 160-id list, the same way) and room.spectate + * (a ≤ 160-id list, the same way), room.diy (≤ 8 自选 picks checked against the data, the same way) and room.spectate * (taking a spectator seat in a running match resends its state like a watcher's g.watch — server/lobby.js spectate). */ -export const HEAVY_TYPES = new Set(['g.watch', 'room.loadout', 'room.ownership', 'room.spectate']); +export const HEAVY_TYPES = new Set(['g.watch', 'room.loadout', 'room.ownership', 'room.diy', 'room.spectate']); /** Close codes (see header). */ export const CLOSE = Object.freeze({ REPLACED: 4001, HELLO_TIMEOUT: 4002, POLICY: 1008, SHUTDOWN: 1001 }); @@ -109,6 +111,8 @@ export class Session { this.loadout = null; /** @type {readonly string[] | null} checked not-owned chess ids (干员持有, lobby-owned, 0.2.0 补位) */ this.notOwned = null; + /** @type {Readonly> | null} checked 自选 picks (lobby-owned, 0.2.0 自选编队) */ + this.diy = null; /** @type {string} client address of the latest connection (logging) */ this.addr = '?'; /** @type {string | null} per-network limit key of the latest connection (null = not limited), see clientAddress */ @@ -500,7 +504,7 @@ export class Network { /** * @param {{ * registry: SessionRegistry, - * handler: { onHello?: Function, onMessage: Function, onDisconnect?: Function, onExpire?: Function }, + * handler: { onHello?: Function, onMessage: Function, onDisconnect?: Function, onExpire?: Function, welcomeInfo?: Function }, * log?: { info: Function, warn: Function, error: Function, debug?: Function }, * now?: () => number, * options?: Partial, @@ -656,7 +660,9 @@ export class Network { session.addr = conn.ip; session.limitKey = conn.key; - const welcome = { t: 'welcome', playerId: session.playerId, token: session.token, name: session.name, serverNow: now, version: PROTOCOL_VERSION, resumed }; + let extra = null; + try { extra = this.handler.welcomeInfo?.() ?? null; } catch (e) { this.log.error('[net] welcomeInfo crashed', e); } + const welcome = { ...(extra && typeof extra === 'object' ? extra : null), t: 'welcome', playerId: session.playerId, token: session.token, name: session.name, serverNow: now, version: PROTOCOL_VERSION, resumed }; if (validRid(rid)) welcome.rid = rid; this.reply(conn, welcome); try { diff --git a/shared/diy.js b/shared/diy.js index c3cb24bc..7bca58b9 100644 --- a/shared/diy.js +++ b/shared/diy.js @@ -55,7 +55,9 @@ function diyOf(data) { } /** - * The 自选 slot a chess id names: its base (normal) id, its tier and both records. Null for any other id. + * The 自选 slot a chess id names: its base (normal) id, its tier, the 调度中心 level whose shop lists it (`shopLevel`: + * activity_table shopLevelDisplayDataDict — the tier-5 slots at level 5, the tier-6 slots at level 6; the tier when the + * data lacks it) and both records. Null for any other id. * @param {string} slotId a slot's normal (`_a`) or elite (`_b`) id * @param {DiyData} data */ @@ -65,7 +67,8 @@ export function diySlot(slotId, data) { const baseId = own(diy.slots, slotId) ? slotId : Object.keys(diy.slots).find((id) => diy.slots[id]?.goldenId === slotId); const s = baseId ? diy.slots[baseId] : null; if (!s) return null; - return { baseId, goldenId: s.goldenId, tier: s.tier, elite: slotId !== baseId, normal: chessOf(data, baseId), golden: chessOf(data, s.goldenId) }; + const shopLevel = Number.isInteger(s.shopLevel) && s.shopLevel > 0 ? s.shopLevel : s.tier; + return { baseId, goldenId: s.goldenId, tier: s.tier, shopLevel, elite: slotId !== baseId, normal: chessOf(data, baseId), golden: chessOf(data, s.goldenId) }; } /** The base ids of the 自选 slots (data order: tier 5 then tier 6). @param {DiyData} data @returns {string[]} */ diff --git a/shared/protocol.js b/shared/protocol.js index 0634e46c..e00d8451 100644 --- a/shared/protocol.js +++ b/shared/protocol.js @@ -3,6 +3,7 @@ import { DIFFICULTIES, NAME_MAX_LEN, ROOM_CODE_LEN, MAX_SEATS, EMOTES, GEO } from './constants.js'; import { isDroppableChess } from './standIn.js'; +import { diySlotIds, validateDiyPicks } from './diy.js'; // ---- tiny validators ------------------------------------------------------- const isInt = (v, lo = -Infinity, hi = Infinity) => Number.isInteger(v) && v >= lo && v <= hi; @@ -204,6 +205,52 @@ export function checkNotOwned(list, getChess) { return { ok: true, notOwned, dropped: list.length - notOwned.length }; } +// ---- 自选编队 (0.2.0 DIY): room.diy { picks } ------------------------------------------------------------------------ + +/** + * `room.diy { picks }`: the player's 自选编队 — `{ [slotBaseId]: { charId, skillIndex?, uniEquipId? } | null }` for the + * four DIY slots (data/backups.json `diy.slots`: two at tier 5, two at tier 6; shared/diy.js). An out-of-match setting + * like 干员持有: stored per session / seat, a match takes the picks its seat had at its start. Structural limit below + * (room for more slots in a later season); the semantic check (`checkDiyPicks`) is LENIENT, like checkNotOwned: an + * illegal pick — not a pick of the slot's tier, an operator without a kit, a prototype off its locked skill, an unknown + * skill / module, the same operator twice in a tier, an owned operator in a second slot, an unknown slot — is dropped, + * never the whole roster; only malformed input is BAD_MSG. + */ +export const DIY_LIMITS = Object.freeze({ slots: 8 }); +const isDiyPickWire = (p) => p === null || (isPlain(p) && isId(p.charId) + && nullable((v) => isInt(v, 0, 9))(p.skillIndex) && nullable(isId)(p.uniEquipId)); +/** Structural check of `room.diy.picks`: a map of ≤ 8 slot ids → a pick `{ charId, skillIndex?, uniEquipId? }` or null. */ +export const isDiyPicks = (v) => isMap(v, DIY_LIMITS.slots, isId, isDiyPickWire); + +/** + * Semantic check + normalisation of a 自选 roster against the game data (`{ chess, backups }` or a sim DataSource) and + * the kit registry (`kitted`: server/sim/content/kits/index.js KITTED_CHARS — an operator without a kit is never fielded): + * the slots are taken in data order (tier 5, then tier 6), and each pick is kept when the roster so far plus it still + * passes shared/diy.js validateDiyPicks — so the result always passes it, and of two picks that clash (one owned operator + * in two slots, one operator twice in a tier) the first slot keeps it. Kept picks are complete + * (`{ charId, skillIndex, uniEquipId }`: a prototype's locked selection, uniEquipId null = no module). + * @param {any} picks `room.diy.picks` + * @param {{ data: any, kitted?: Iterable|((id: string) => boolean)|null }} opts + * @returns {{ ok: true, picks: Record, 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} */ + const kept = {}; + let dropped = 0; + for (const id of Object.keys(picks)) if (picks[id] != null && !slots.includes(id)) dropped++; + for (const slotId of slots) { + const pick = Object.hasOwn(picks, slotId) ? picks[slotId] : null; + if (pick == null) continue; + const res = validateDiyPicks({ ...kept, [slotId]: pick }, { data, kitted }); + if ('ok' in res) kept[slotId] = res.picks[slotId]; + else dropped++; + } + return { ok: true, picks: kept, dropped }; +} + // ---- unit stats (user playtest #4 item 7): m.unitStats units and the browser battle's live stats --------------------- const fin = (v, d = 0) => (typeof v === 'number' && Number.isFinite(v) ? v : d); @@ -289,6 +336,9 @@ export const C2S = { // operator ownership (干员持有, 0.2.0 补位): stored per session / seat; a match takes the list its seat had when it // started (an out-of-match setting — during a match it is stored for the next one: ROOM_STARTED) 'room.ownership': { notOwned: isNotOwnedList }, + // 自选编队 (0.2.0 DIY): the player's DIY slot picks; stored per session / seat like room.ownership (a match takes the + // picks its seat had when it started; during a match they are stored for the next one: ROOM_STARTED) + 'room.diy': { picks: isDiyPicks }, // spectator seats (remake feature, community report #26; MAX_SPECTATORS): take one of a co-op room's spectator seats — // in its lobby or while its match runs — never a player seat; the host frees one by playerId (the spectator gets // room.closed { reason: 'kicked' }). room.leave / g.leave leave a spectator seat like a player seat. diff --git a/test/lobby-diy.test.js b/test/lobby-diy.test.js new file mode 100644 index 00000000..ca02f41b --- /dev/null +++ b/test/lobby-diy.test.js @@ -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, []); + }); +}); diff --git a/test/match/diy-shop.test.js b/test/match/diy-shop.test.js new file mode 100644 index 00000000..e66c970e --- /dev/null +++ b/test/match/diy-shop.test.js @@ -0,0 +1,323 @@ +// 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(); +}); diff --git a/test/match/harness.js b/test/match/harness.js index f71c8d0f..a459b318 100644 --- a/test/match/harness.js +++ b/test/match/harness.js @@ -170,6 +170,7 @@ function legacyInvariants(m) { assert.ok(ps.hand.every((x) => x == null) && ps.temp.every((x) => x == null), 'eliminated player keeps nothing'); } const chessUids = new Set(); + const diyHeld = new Map(); const all = [...ps.board.values(), ...ps.hand.filter(Boolean), ...ps.temp.filter(Boolean)]; for (const p of all) if (p.kind === 'chess') chessUids.add(p.uid); let deployed = 0; @@ -195,7 +196,9 @@ function legacyInvariants(m) { for (const it of p.items) { note(it); assert.equal(it.kind, 'item'); assert.ok(m.gd.item(it.id), `unknown item ${it.id}`); } assert.ok(Number.isInteger(p.poolCopies) && p.poolCopies >= 0); const base = m.gd.baseIdOf(p.id); - held.set(base, (held.get(base) || 0) + p.poolCopies); + // a 自选 piece's copies are its player's own stock (0.2.0, server/match/player/diy.js) + const tally = ps.diyStock && ps.diyStock.has(base) ? diyHeld : held; + tally.set(base, (tally.get(base) || 0) + p.poolCopies); } else if (p.kind === 'item') { assert.ok(m.gd.item(p.id), `unknown item ${p.id}`); } else if (p.kind === 'token') { @@ -210,6 +213,9 @@ function legacyInvariants(m) { const need = m.gd.mergeCount(b); if (need > 1 && m.gd.goldenIdOf(b)) assert.ok(n < need, `${ps.playerId} owns ${n} copies of ${b} (merge ${need})`); } + for (const [base, e] of ps.diyStock ? ps.diyStock.entries : []) { + assert.equal(e.left + (diyHeld.get(base) || 0), e.cap, `${ps.playerId} 自选 stock ${base}: left ${e.left} + held ${diyHeld.get(base) || 0} != cap ${e.cap}`); + } } for (const [base, e] of pool.entries) { assert.ok(e.left >= 0 && e.left <= e.cap, `pool ${base} left ${e.left} cap ${e.cap}`); From e480b1cb26236a54cb809b7b45a9fe207981afa1 Mon Sep 17 00:00:00 2001 From: sganggs Date: Mon, 5 Oct 2026 23:37:23 +0800 Subject: [PATCH 2/9] =?UTF-8?q?feedback5=20WK:=20a=20=E8=87=AA=E9=80=89=20?= =?UTF-8?q?pick=20never=20carries=20a=20=E9=9B=86=E6=88=90=E6=88=98?= =?UTF-8?q?=E7=95=A5=20module=20(ISW-A:=20=E5=87=AF=E5=B0=94=E5=B8=8C,=20?= =?UTF-8?q?=E5=82=80=E5=BD=B1,=20=E8=8F=B2=E4=BA=9A=E6=A2=85=E5=A1=94,=20?= =?UTF-8?q?=E6=8F=90=E4=B8=B0,=20=E8=89=BE=E4=B8=BD=E5=A6=AE,=20=E9=9C=8D?= =?UTF-8?q?=E5=B0=94=E6=B5=B7=E9=9B=85=20=E2=80=94=20their=20own=20effect?= =?UTF-8?q?=20applies=20only=20"=E5=9C=A8=E9=9B=86=E6=88=90=E6=88=98?= =?UTF-8?q?=E7=95=A5=E4=B8=AD")=20[ASSUMED:=20not=20usable=20outside=20?= =?UTF-8?q?=E9=9B=86=E6=88=90=E6=88=98=E7=95=A5,=20the=20owner's=20decisio?= =?UTF-8?q?n=20of=202026-10-05];=20shared/diy.js=20isDiyModule,=20checkDiy?= =?UTF-8?q?Pick=20refuses=20one;=20diySlot=20reports=20the=20slot's=20shop?= =?UTF-8?q?Level=20(test/diy.test.js)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Golden unchanged (no kitted operator has an ISW-A module). --- shared/diy.js | 21 +++++++++++++++++++-- test/diy.test.js | 20 ++++++++++++++++++-- 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/shared/diy.js b/shared/diy.js index 7bca58b9..ca4e7597 100644 --- a/shared/diy.js +++ b/shared/diy.js @@ -12,7 +12,8 @@ // - no 特质 (PRTS "甄选加入的干员不会拥有任何特质"); bonds from the operator's factions (`diy.operators[id].bonds`); // - the normal form is E2 Lv1, skill rank 4, no module; the elite E2 Lv60, rank 7, its module at stage 1 (tier 5) or 3 // (tier 6) — the slot record's `status` (activity_table diyChessDict) picks the unit form; -// - an owned pick chooses its skill (any of 3) and module (any of its modules, or none); a prototype carries the skill +// - an owned pick chooses its skill (any of 3) and module (any of its modules but a 集成战略-only one — ISW-A, "在集成战略 +// 中…" [ASSUMED: not usable outside 集成战略, the owner's decision of 2026-10-05] — or none); a prototype carries the skill // and module of its 补位 rows at that tier (`diy.locked`: "技能携带规则与系统补位时一致" — [ASSUMED] that reading, the // owner's decision of 2026-10-05); // - potential 0 for everyone [ASSUMED: no account]. @@ -24,6 +25,20 @@ import { composeUnitRecord, unitForm, statusKey } from './standIn.js'; /** The tiers that have 自选 slots. */ export const DIY_TIERS = Object.freeze([5, 6]); +/** + * Module types a 自选 pick never carries: the 集成战略 modules (ISW-A — their own effect applies only "在集成战略中") + * [ASSUMED: not usable outside 集成战略, the owner's decision of 2026-10-05]. + */ +export const DIY_EXCLUDED_MODULE_TYPE = /^ISW-/; + +/** + * Whether a module of a unit form (`forms[k].modules[]` entry) may be picked for a 自选 piece (DIY_EXCLUDED_MODULE_TYPE). + * @param {any} mod + */ +export function isDiyModule(mod) { + return isObj(mod) && typeof mod.uniEquipId === 'string' && !DIY_EXCLUDED_MODULE_TYPE.test(String(mod.typeName ?? '')); +} + /** * @typedef {{ charId: string, skillIndex?: number|null, uniEquipId?: string|null }} DiyPick a 自选 pick: the operator, * its skill index (0-based; a prototype's is its locked one) and module (uniEquipId; null / absent / 'none' = none) @@ -160,9 +175,11 @@ export function checkDiyPick(slotId, pick, data) { } if (!forms.every((f) => (f.skills ?? []).some((s) => s && s.index === skillIndex))) return { error: `${charId}: no skill ${skillIndex}` }; const elite = forms[1]; - if (uniEquipId !== null && !(elite.modules ?? []).some((m) => m && m.uniEquipId === uniEquipId)) { + const mod = uniEquipId !== null ? (elite.modules ?? []).find((m) => m && m.uniEquipId === uniEquipId) : null; + if (uniEquipId !== null && !mod) { return { error: `${charId}: no module ${uniEquipId} at stage ${slot.golden?.status?.equipLevel ?? '?'}` }; } + if (mod && !proto && !isDiyModule(mod)) return { error: `${charId}: ${uniEquipId} is a 集成战略 module (${mod.typeName})` }; return { ok: true, pick: { charId, skillIndex, uniEquipId } }; } diff --git a/test/diy.test.js b/test/diy.test.js index a9e141f0..a51799c6 100644 --- a/test/diy.test.js +++ b/test/diy.test.js @@ -8,7 +8,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; -import { diyPool, diySlot, diySlotIds, checkDiyPick, diyRecord, diyRecordOf, validateDiyPicks, lockedSelection, diyTokenOwner, DIY_TIERS } from '../shared/diy.js'; +import { diyPool, diySlot, diySlotIds, checkDiyPick, diyRecord, diyRecordOf, validateDiyPicks, lockedSelection, diyTokenOwner, isDiyModule, DIY_TIERS } from '../shared/diy.js'; import { unitForm, composeUnitRecord } from '../shared/standIn.js'; import { composeStats, composeTalents } from '../shared/loadoutRecord.js'; import { normalizeChess, resolveLoadout, loadoutRecord } from '../server/sim/simdata.js'; @@ -29,7 +29,8 @@ test('slots: two per tier (5, 6), each with its elite twin', () => { assert.deepEqual(DIY_TIERS, [5, 6]); assert.deepEqual(diySlotIds(DATA), [T5, T5B, T6, T6B]); assert.deepEqual(diySlot('chess_char_6_diy2_b', DATA) && { ...diySlot('chess_char_6_diy2_b', DATA), normal: undefined, golden: undefined }, - { baseId: T6B, goldenId: 'chess_char_6_diy2_b', tier: 6, elite: true, normal: undefined, golden: undefined }); + { baseId: T6B, goldenId: 'chess_char_6_diy2_b', tier: 6, shopLevel: 6, elite: true, normal: undefined, golden: undefined }); + assert.deepEqual([T5, T5B, T6, T6B].map((id) => diySlot(id, DATA).shopLevel), [5, 5, 6, 6], 'shopLevelDisplayDataDict: the 调度中心 level that lists the slot'); assert.equal(diySlot('chess_char_5_01_a', DATA), null, 'not a slot'); assert.equal(diyTokenOwner(SIEGE, chess.chess_char_6_diy1_b.status), 'char_112_siege@2/60/7/3'); }); @@ -102,6 +103,21 @@ test('checkDiyPick: an owned 6★ chooses any of its 3 skills and any module of for (const [slot, pick, re] of bad) assert.match(checkDiyPick(slot, pick, DATA).error, re, JSON.stringify(pick)); }); +test('checkDiyPick: an owned pick never carries a 集成战略 module (ISW-A) [ASSUMED, the owner\'s decision of 2026-10-05]', () => { + 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 c = checkDiyPick(slot, { charId, skillIndex: 0, uniEquipId: mod.uniEquipId }, DATA); + if (/^ISW-/.test(mod.typeName)) { assert.match(c.error || '', /集成战略 module/, `${charId} ${mod.uniEquipId}`); assert.equal(isDiyModule(mod), false); checked++; } + else assert.ok(c.ok, `${charId} ${mod.uniEquipId}: ${c.error}`); + } + } + } + assert.ok(checked >= 6, 'the ISW-A modules of the pool (凯尔希, 傀影, 菲亚梅塔, 提丰, 艾丽妮, 霍尔海雅) at both stages'); +}); + test('diyRecord: the slot\'s identity (tier, price, merge, status), no 特质, derived bonds, the operator\'s body at the slot form; module active on the elite only, stage 1 / 3', () => { const cases = [[T5, false, '2/1/4/0', 0], [T5, true, '2/60/7/1', 1], [T6, false, '2/1/4/0', 0], [T6, true, '2/60/7/3', 3]]; for (const [slotId, elite, key, stage] of cases) { From 0def7babfd0bc867b09da8fac6de0b94b560d849 Mon Sep 17 00:00:00 2001 From: sganggs Date: Tue, 6 Oct 2026 00:13:31 +0800 Subject: [PATCH 3/9] =?UTF-8?q?feedback5=20WK:=20=E8=87=AA=E9=80=89?= =?UTF-8?q?=E7=BC=96=E9=98=9F=20in=20the=20browser=20=E2=80=94=20the=20?= =?UTF-8?q?=E5=B9=B2=E5=91=98=E8=B0=83=E9=85=8D=20overlay's=20third=20tab?= =?UTF-8?q?=20=E8=87=AA=E9=80=89=E7=BC=96=E9=98=9F=20(5=E9=98=B6=20=C3=972?= =?UTF-8?q?,=206=E9=98=B6=20=C3=972=20slots;=20the=20slot=20picker=20lists?= =?UTF-8?q?=20the=20kitted=20picks=20of=20the=20tier=20with=20avatar,=20na?= =?UTF-8?q?me,=20class,=20derived=20bonds=20and=20a=20=E5=8E=9F=E5=9E=8B?= =?UTF-8?q?=20/=20=E5=B7=B2=E6=8C=81=E6=9C=89=20tag;=20an=20owned=20pick?= =?UTF-8?q?=20chooses=20its=20skill=20and=20module=20=E2=80=94=20=E9=9B=86?= =?UTF-8?q?=E6=88=90=E6=88=98=E7=95=A5=20modules=20left=20out=20=E2=80=94,?= =?UTF-8?q?=20a=20prototype=20shows=20its=20locked=20skill;=20sp.pref.diy,?= =?UTF-8?q?=20export=20/=20import,=20=E5=85=A8=E9=83=A8=E6=B8=85=E7=A9=BA,?= =?UTF-8?q?=20synced=20as=20room.diy=20through=20the=20shared=20engine,=20?= =?UTF-8?q?welcome.diyKitted=20kept),=20the=20operator=20on=20shop=20/=20r?= =?UTF-8?q?eward=20cards,=20own=20pieces=20(=E3=80=8C=E8=87=AA=E9=80=89?= =?UTF-8?q?=E3=80=8D=20tag,=20its=20model=20on=20bench=20and=20board),=20t?= =?UTF-8?q?he=20detail=20card=20(pick's=20skill=20and=20module),=20placeme?= =?UTF-8?q?nt=20and=20range=20previews,=20bond=20popups=20(own=20and=20a?= =?UTF-8?q?=20teammate's=20=E8=87=AA=E9=80=89=20member),=20the=20result=20?= =?UTF-8?q?screen;=20new=20UI=20strings=20through=20t()=20with=20English;?= =?UTF-8?q?=20the=20e2e=20fast=20server's=20SP=5FSTART=5FLEVEL=20hook?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bonds of 自选 pieces checked everywhere membership is decided (the owner's reminder of 2026-10-05): the match's counts through the player's data view (煌 in 炎 and 维多利亚, one member per slot, 绝技 elites), the sim's bond effects through def.bonds (叙拉古 on deployment, 协防 ×1.2 / elite ×1.4), the client's strip, popups, cards and detail card. Golden unchanged. Tests: test/ui/diy-ui.test.js (15), test/match/diy-bonds.test.js (4), test/i18n.test.js pins screens/diy.js; test/ui/diy.e2e.test.js (SP_E2E=1, run once: pass). --- public/css/screens/game-panels.css | 2 + public/css/screens/game-shop.css | 3 + public/css/screens/loadout.css | 98 ++++++++ public/i18n/en.json | 56 ++++- public/js/main.js | 6 +- public/js/render/app.js | 12 +- public/js/render/app/host.js | 19 +- public/js/render/app/info.js | 5 + public/js/screens/diy.js | 244 ++++++++++++++++++ public/js/screens/game.js | 22 +- public/js/screens/game/standInTags.js | 36 ++- public/js/screens/loadout.js | 83 +++++-- public/js/screens/result.js | 10 +- public/js/ui/bondStrip.js | 13 +- public/js/ui/detailPanel.js | 30 ++- public/js/ui/diyModel.js | 200 +++++++++++++++ public/js/ui/fallbackField.js | 11 +- public/js/ui/gameComponents.js | 7 +- public/js/ui/gameLogic.js | 1 + public/js/ui/gameLogic/bonds.js | 19 +- public/js/ui/gameLogic/diy.js | 94 +++++++ public/js/ui/loadoutSync.js | 80 +++++- public/js/ui/shopBar.js | 16 +- public/js/ui/watchBonds.js | 7 +- server/match/player/diy.js | 6 +- test/e2e/client.mjs | 1 + test/e2e/fastServer.mjs | 8 + test/i18n.test.js | 1 + test/match/diy-bonds.test.js | 121 +++++++++ test/ui/diy-ui.test.js | 340 ++++++++++++++++++++++++++ test/ui/diy.e2e.test.js | 164 +++++++++++++ 31 files changed, 1637 insertions(+), 78 deletions(-) create mode 100644 public/js/screens/diy.js create mode 100644 public/js/ui/diyModel.js create mode 100644 public/js/ui/gameLogic/diy.js create mode 100644 test/match/diy-bonds.test.js create mode 100644 test/ui/diy-ui.test.js create mode 100644 test/ui/diy.e2e.test.js diff --git a/public/css/screens/game-panels.css b/public/css/screens/game-panels.css index a7c3fd65..aee835ec 100644 --- a/public/css/screens/game-panels.css +++ b/public/css/screens/game-panels.css @@ -91,6 +91,7 @@ .dtag-elite { background: var(--gold); color: #1d1400; } .dtag-token { background: #a9c3d8; color: #0b1520; } /* 0.2.0 补位: the card of a chess fielded as its stand-in (ui/detailPanel.js ChessDetail) */ +.dtag-diy { font-size: .13rem; font-weight: 700; padding: .01rem .08rem; letter-spacing: .08em; background: var(--mint-500); color: var(--text-on-mint); } .dtag-standin { font-size: .13rem; font-weight: 700; padding: .01rem .08rem; letter-spacing: .04em; background: var(--ice); color: var(--ice-900); } .dtag-kind { border: 1px solid var(--line-2); color: var(--text-lo); font-weight: 500; } .drank { font-size: .13rem; font-weight: 700; padding: .01rem .08rem; } @@ -558,6 +559,7 @@ button.bpop__harmony:hover { border-style: solid; color: #fff; } .sitag { position: absolute; transform: translate(-50%, .02rem); pointer-events: none; white-space: nowrap; font-size: max(.12rem, 9px); font-weight: 700; line-height: 1.35; padding: 0 .05rem; letter-spacing: .03em; background: var(--ice); color: var(--ice-900); box-shadow: 0 1px 3px rgba(0, 0, 0, .7); opacity: .92; } .sitag--board { background: #c2e3ff; } +.sitag--diy { background: var(--mint-500); color: var(--text-on-mint); } .gm.is-dragging .sitags { display: none; } /* ---- underframe: the selection diamond of a tapped own piece (ui/underframe.js): 撤退 / 出售 +N / 销毁 ---- diff --git a/public/css/screens/game-shop.css b/public/css/screens/game-shop.css index 6fa003fe..3ac8c551 100644 --- a/public/css/screens/game-shop.css +++ b/public/css/screens/game-shop.css @@ -133,6 +133,9 @@ /* 0.2.0 补位: a chess the player does not own — its card stays, the stand-in fights (ui/shopBar.js ChessCard) */ .scard__standin { position: absolute; z-index: 3; left: 0; top: .38rem; max-width: calc(100% - .1rem); font-size: .12rem; font-weight: 700; padding: .01rem .06rem; background: var(--ice); color: var(--ice-900); letter-spacing: .04em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; box-shadow: 0 1px 3px rgba(0, 0, 0, .6); } +/* 0.2.0 自选编队: a DIY slot the player filled — the card is its operator, only in this player's shop (ui/shopBar.js) */ +.scard__diy { position: absolute; z-index: 3; left: 0; top: .38rem; font-size: .12rem; font-weight: 700; padding: .01rem .06rem; + background: var(--mint-500); color: var(--text-on-mint); letter-spacing: .08em; white-space: nowrap; box-shadow: 0 1px 3px rgba(0, 0, 0, .6); } .scard:not(.is-disabled):not(.scard--sold):hover { transform: translateY(-.08rem); border-color: var(--mint-400); box-shadow: 0 0 0 1px var(--mint-400), 0 .1rem .24rem rgba(23, 249, 183, .22); } .scard:not(.is-disabled):not(.scard--sold):active { transform: translateY(-.04rem); } .scard.is-disabled { cursor: not-allowed; } diff --git a/public/css/screens/loadout.css b/public/css/screens/loadout.css index 8f6b8861..910842aa 100644 --- a/public/css/screens/loadout.css +++ b/public/css/screens/loadout.css @@ -385,6 +385,104 @@ .lo-tab { padding: .02rem .08rem; letter-spacing: .04em; } } +/* ---- 自选编队 (screens/diy.js): four DIY slots by tier, the slot picker (operator list + skill / module) --------------- */ +.diy { position: relative; flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; gap: .12rem; padding: .16rem .3rem .2rem; } +.diy__body { flex: 1 1 auto; min-height: 0; overflow: auto; -webkit-overflow-scrolling: touch; overscroll-behavior: contain; display: flex; flex-direction: column; gap: .18rem; padding: 0 .06rem .2rem 0; + scrollbar-width: thin; scrollbar-color: var(--line-3) transparent; } +.diy-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(max(5.2rem, 300px), 1fr)); gap: .1rem; } +.diy-slot { + position: relative; display: grid; grid-template-columns: auto minmax(0, 1fr) auto; gap: .1rem; align-items: start; min-width: 0; + padding: .08rem; background: rgba(255, 255, 255, .03); border: 1px solid var(--line); border-left: 3px solid var(--tc, var(--line-3)); +} +.diy-slot--t5 { --tc: var(--tier-5); } .diy-slot--t6 { --tc: var(--tier-6); } +.diy-slot.is-empty { display: flex; flex-direction: column; padding: 0; border-style: dashed; border-left-style: solid; } +.diy-slot__fill { + display: flex; flex-direction: column; align-items: center; justify-content: center; gap: .05rem; width: 100%; min-height: max(1.3rem, 84px); + padding: .1rem; font: inherit; color: var(--text-md); background: none; border: 0; cursor: pointer; touch-action: manipulation; +} +.diy-slot__fill:hover { background: rgba(255, 255, 255, .05); color: var(--text-hi); } +.diy-slot__fill b { font-size: max(.16rem, 11px); } +.diy-slot__fill small { font-size: max(.12rem, 9px); color: var(--text-lo); } +.diy-slot__plus { display: grid; place-items: center; width: max(.4rem, 26px); height: max(.4rem, 26px); border: 1px solid var(--line-3); border-radius: 50%; } +.diy-slot__plus .icon { width: 60%; height: 60%; } +.diy-slot__art { display: grid; place-items: center; width: max(.9rem, 56px); height: max(1.3rem, 84px); overflow: hidden; background: #0a0d0c; border: 1px solid var(--line-2); } +.diy-slot__art img { width: 100%; height: 100%; object-fit: cover; object-position: top; } +.diy-slot__art b { font-size: max(.3rem, 18px); color: var(--text-lo); } +.diy-slot__body { display: flex; flex-direction: column; gap: .03rem; min-width: 0; } +.diy-slot__head { display: flex; align-items: center; gap: .05rem; } +.diy-slot__label { font-size: max(.12rem, 9px); color: var(--text-lo); letter-spacing: .06em; } +.diy-slot__name { font-size: max(.17rem, 12px); font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.diy-slot__class { font-size: max(.12rem, 9px); color: var(--text-lo); } +.diy-slot__kit { display: flex; align-items: center; gap: .05rem; min-width: 0; font-size: max(.13rem, 9px); color: var(--text-md); } +.diy-slot__kit > span { min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.diy-slot__sicon, .diy-slot__micon { flex: none; display: inline-grid; place-items: center; width: max(.24rem, 16px); height: max(.24rem, 16px); object-fit: contain; font-size: .8em; color: var(--text-lo); } +.diy-slot__micon--none { border: 1px solid var(--line-2); } +.diy-slot__acts { display: flex; flex-direction: column; gap: .04rem; } +.diy-slot__bad { display: flex; align-items: center; gap: .04rem; margin: .02rem 0 0; font-size: max(.12rem, 9px); color: var(--amber); } +.diy-slot.is-bad { border-color: rgba(255, 196, 0, .45); } +.diy-tag { display: inline-flex; align-items: center; height: 1.5em; padding: 0 .4em; font-size: max(.11rem, 8px); font-weight: 700; border-radius: 1em; white-space: nowrap; } +.diy-tag--proto { color: var(--ice-900); background: var(--ice); } +.diy-tag--own { color: var(--text-on-mint); background: var(--mint-500); } +.diy-bonds { display: flex; flex-wrap: wrap; gap: .03rem .06rem; } +.diy-bond { display: inline-flex; align-items: center; gap: .03rem; font-size: max(.12rem, 9px); color: var(--text-md); } +.diy-bond .bglyph, .diy-bond img { width: max(.16rem, 11px); height: max(.16rem, 11px); } +/* the picker covers the tab: the list of the slot's tier on the left, the chosen operator's skill / module on the right */ +.diy-pick { position: absolute; inset: 0; z-index: 3; display: flex; flex-direction: column; gap: .08rem; padding: .12rem .3rem .14rem; background: var(--bg-1, #0d1110); } +.diy-pick__head { flex: none; display: flex; align-items: center; gap: .08rem; font-size: max(.16rem, 11px); } +.diy-pick__note { flex: 1; min-width: 0; font-size: max(.12rem, 9px); color: var(--text-lo); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.diy-pick__x { display: grid; place-items: center; width: max(.4rem, 28px); height: max(.4rem, 28px); color: var(--text-md); background: none; border: 1px solid var(--line-2); cursor: pointer; } +.diy-pick__bar { flex: none; display: flex; align-items: center; gap: .1rem; flex-wrap: wrap; } +.diy-pick__search { flex: 1 1 2rem; min-width: 120px; height: max(.36rem, 26px); padding: 0 .08rem; font: inherit; font-size: max(.13rem, 10px); color: var(--text-hi); background: rgba(255, 255, 255, .04); border: 1px solid var(--line-2); } +.diy-pick__main { flex: 1 1 auto; min-height: 0; display: grid; grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr); gap: .12rem; } +.diy-pick__list, .diy-pick__detail { min-height: 0; overflow: auto; -webkit-overflow-scrolling: touch; overscroll-behavior: contain; scrollbar-width: thin; scrollbar-color: var(--line-3) transparent; } +.diy-pick__list { display: grid; grid-template-columns: repeat(auto-fill, minmax(max(3.2rem, 200px), 1fr)); align-content: start; gap: .06rem; } +.diy-opt { + position: relative; display: flex; align-items: center; gap: .07rem; min-width: 0; padding: .06rem; font: inherit; color: var(--text-hi); text-align: left; + background: rgba(255, 255, 255, .03); border: 1px solid var(--line); cursor: pointer; touch-action: manipulation; +} +.diy-opt:hover { background: rgba(255, 255, 255, .06); } +.diy-opt.is-sel { border-color: var(--mint-500); background: rgba(52, 211, 153, .08); } +.diy-opt.is-taken { opacity: .45; cursor: not-allowed; } +.diy-opt__ava { flex: none; display: grid; place-items: center; width: max(.5rem, 32px); height: max(.5rem, 32px); overflow: hidden; background: #0a0d0c; border: 1px solid var(--line-2); } +.diy-opt__ava img { width: 100%; height: 100%; object-fit: cover; } +.diy-opt__txt { display: flex; flex-direction: column; gap: .02rem; min-width: 0; } +.diy-opt__top { display: flex; align-items: center; gap: .04rem; min-width: 0; } +.diy-opt__name { font-size: max(.15rem, 11px); font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.diy-opt__class { display: inline-flex; align-items: center; gap: .03rem; font-size: max(.11rem, 8px); color: var(--text-lo); } +.diy-opt__prof { width: max(.14rem, 10px); height: max(.14rem, 10px); } +.diy-opt__taken { position: absolute; right: .04rem; bottom: .03rem; font-size: max(.1rem, 8px); color: var(--amber); } +.diy-pick__detail { display: flex; flex-direction: column; gap: .06rem; padding: .06rem; background: rgba(255, 255, 255, .02); border: 1px solid var(--line); } +.diy-pick__who { display: flex; align-items: center; gap: .08rem; } +.diy-pick__ava { display: grid; place-items: center; width: max(.6rem, 40px); height: max(.6rem, 40px); overflow: hidden; background: #0a0d0c; border: 1px solid var(--line-2); } +.diy-pick__ava img { width: 100%; height: 100%; object-fit: cover; } +.diy-pick__wtxt { display: flex; flex-direction: column; flex: 1; min-width: 0; } +.diy-pick__wtxt b { font-size: max(.17rem, 12px); } +.diy-pick__wtxt small { font-size: max(.12rem, 9px); color: var(--text-lo); } +.diy-pick__sec { display: flex; align-items: baseline; gap: .06rem; margin: .04rem 0 0; font-size: max(.14rem, 10px); color: var(--text-md); } +.diy-pick__sec small { font-size: max(.11rem, 8px); color: var(--text-lo); font-weight: 400; } +.diy-pick__choices { display: flex; flex-direction: column; gap: .04rem; } +.diy-choice { + display: flex; align-items: flex-start; gap: .07rem; padding: .05rem; font: inherit; color: var(--text-hi); text-align: left; + background: rgba(255, 255, 255, .03); border: 1px solid var(--line); cursor: pointer; touch-action: manipulation; +} +.diy-choice:disabled { cursor: default; } +.diy-choice.is-on { border-color: var(--mint-500); background: rgba(52, 211, 153, .08); } +.diy-choice__icon { flex: none; display: grid; place-items: center; width: max(.36rem, 24px); height: max(.36rem, 24px); color: var(--text-lo); } +.diy-choice__icon img { width: 100%; height: 100%; object-fit: contain; } +.diy-choice__body { display: flex; flex-direction: column; gap: .02rem; min-width: 0; } +.diy-choice__head { display: flex; align-items: center; gap: .05rem; flex-wrap: wrap; font-size: max(.14rem, 10px); } +.diy-choice__desc { font-size: max(.12rem, 9px); line-height: 1.45; color: var(--text-md); } +.diy-pick__foot { flex: none; display: flex; justify-content: flex-end; gap: .08rem; } +/* 0.2.0 自选编队: the slots the player filled (the entry button's chip) */ +.lo-entry__diy { color: var(--text-on-mint); background: var(--mint-500); } +@media (max-width: 760px) { + .diy { padding: .1rem .16rem .16rem; } + .diy-pick { padding: .08rem .12rem .1rem; } + .diy-pick__main { grid-template-columns: minmax(0, 1fr); grid-template-rows: minmax(0, 1fr) minmax(0, 1.1fr); } + .diy-slot { grid-template-columns: auto minmax(0, 1fr); } + .diy-slot__acts { grid-column: 1 / -1; flex-direction: row; justify-content: flex-end; } +} + /* ---- entry button (lobby / room / briefing) --------------------------------------------------------------------------- */ .lo-entry { position: relative; gap: .08rem; } .lo-entry__icon { width: 1.25em; height: 1.25em; object-fit: contain; flex: none; } diff --git a/public/i18n/en.json b/public/i18n/en.json index 411a899b..0b02eff0 100644 --- a/public/i18n/en.json +++ b/public/i18n/en.json @@ -845,5 +845,59 @@ "【奇迹】下次刷新不消耗资金": "[Marvel] Your next refresh is free", "【维多利亚】获得{n}件维式重锤": "[Victoria] Obtained {n} {n|Victorian Hammer|Victorian Hammers}", "【远见】所有干员购买价格永久降低": "[Foresight] Every Operator's purchase price is permanently reduced", - "【远见】远见干员购买价格永久降低": "[Foresight] [Foresight] Operators' purchase price is permanently reduced" + "【远见】远见干员购买价格永久降低": "[Foresight] [Foresight] Operators' purchase price is permanently reduced", + "原型": "Prototype", + "已持有": "Owned", + "{tier}阶 自选": "Tier {tier} Custom", + "{slot}:选择干员": "{slot}: choose an Operator", + "选择干员": "Choose an Operator", + "调度中心等级 {level} 起出现在商店": "In the shop from Dispatch Center level {level}", + "这项自选在当前版本不可用,开局时会被移除": "This pick is not available in this version; it is dropped when the match starts", + "(锁定)": "(locked)", + "(精锐时生效)": "(as an Elite)", + "更换": "Change", + "清空": "Clear", + "已在{tier}阶自选中": "Already in a Tier {tier} Custom slot", + "锁定": "Locked", + "6★ 原型干员与持有的 6★ 干员,另有 4★ 原型干员(先锋、特种除外)": "6★ prototype Operators and the 6★ Operators you own, plus 4★ Reserve Operators (no Vanguards or Specialists)", + "6★ 原型干员与持有的 6★ 干员": "6★ prototype Operators and the 6★ Operators you own", + "筛选": "Filter", + "可选干员": "Operators to pick", + "连接服务器后才能选择自选干员": "Connect to the server to pick Custom Squad Operators", + "原型干员的技能与补位时一致,不可更改": "A prototype carries the skill it uses as a stand-in; it cannot be changed", + "精锐时生效 · 模组等级 {stage}": "As an Elite · module stage {stage}", + "从左侧选择一名干员": "Choose an Operator on the left", + "5阶与6阶各有两个自选名额:选择持有的 6★ 干员(任选技能与模组)或原型干员(技能锁定)。自选干员只出现在你自己的商店里,调度中心达到该阶等级后才会刷新,价格与同阶干员相同;盟约按干员实际所属阵营分配,没有特质。": "Tiers 5 and 6 have two Custom Squad slots each: pick a 6★ Operator you own (any skill and module) or a prototype Operator (skill locked). A Custom Squad Operator appears only in your own shop, once the Dispatch Center reaches its tier, at the price of any Operator of that tier; its Alliances follow its actual factions, and it has no Attribute.", + "{tier}阶": "Tier {tier}", + "阶": "Tier", + "自选编队:{name}(只在你的商店出现)": "Custom Squad: {name} (only in your shop)", + "自选": "Custom", + "自选编队:所选技能与模组,没有特质,盟约按所属阵营分配": "Custom Squad: the chosen skill and module, no Attribute, Alliances by its factions", + "自选编队:{name}": "Custom Squad: {name}", + "全部清空": "Clear All", + "清空全部 {n} 个自选名额?": "Clear all {n} Custom Squad {n|slot|slots}?", + "文件过大,请选择「导出」下载的自选编队文件": "The file is too large. Choose a Custom Squad file downloaded with \"Export\".", + "导入失败:{error}": "Import failed: {error}", + "已导入 {n} 个自选名额": "Imported {n} Custom Squad {n|slot|slots}", + "(另有 {n} 项不可用,未导入)": " ({n} unavailable {n|entry was|entries were} not imported)", + "已导入:全部名额为空": "Imported: every slot empty", + "自选编队是局外设置:本局按开局时的设置进行,修改将在下一局生效": "The Custom Squad is set outside matches: this match keeps the picks it started with; changes apply from the next match", + "局外设置,下一局生效 · 联机时只进入你自己的商店": "Set outside matches, applies from the next match · in team play only in your own shop", + "干员调配 / 干员持有 / 自选编队": "Operator Loadout / Operator Roster / Custom Squad", + "5阶、6阶的自选名额:持有的 6★ 干员或原型干员": "The Custom Squad slots of tiers 5 and 6: 6★ Operators you own or prototype Operators", + "自选编队": "Custom Squad", + "{n} 个自选名额已选择": "{n} Custom Squad {n|slot|slots} filled", + "已选": "Filled", + "导出自选编队(可复制或下载)": "Export the Custom Squad (copy or download)", + "导入自选编队(粘贴或选择文件)": "Import a Custom Squad (paste or choose a file)", + "共 {n} 个自选名额已选择。复制或下载这份数据,即可在别的设备或浏览器上导入。": "{n} Custom Squad {n|slot|slots} filled. Copy or download this data to import it on another device or browser.", + "把导出的自选编队数据粘贴到下方,或点「选择文件」。导入会覆盖当前的自选编队。": "Paste exported Custom Squad data below, or click \"Choose File\". Importing replaces the current Custom Squad.", + "在此粘贴导出的自选编队内容…": "Paste the exported Custom Squad here…", + "导出自选编队": "Export Custom Squad", + "导入自选编队": "Import Custom Squad", + "自选编队是局外设置,修改将在下一局生效": "The Custom Squad is set outside matches; changes apply from the next match", + "下一局生效": "Applies next match", + "这份数据来自更新的版本(v{v}),请先更新游戏": "This data comes from a newer version (v{v}). Update the game first.", + "这不是自选编队的数据": "This is not Custom Squad data", + "里面没有自选编队的数据": "It holds no Custom Squad data" } diff --git a/public/js/main.js b/public/js/main.js index e275d341..5b109e91 100644 --- a/public/js/main.js +++ b/public/js/main.js @@ -21,7 +21,8 @@ // re-layout; ui/compat.js polyfills are imported before anything else. // 干员调配 (DESIGN §16): an overlay over any route (, opened from lobby / room / briefing); its loadout is // kept in sync with the server by installLoadoutSync (room.loadout after every welcome and edit), its 干员持有 tab's -// not-owned list (0.2.0 补位) by installOwnershipSync (room.ownership, likewise). +// not-owned list (0.2.0 补位) by installOwnershipSync (room.ownership, likewise), the 自选编队 picks (0.2.0 DIY) by +// installDiySync (room.diy, likewise; it also keeps welcome.diyKitted for the picker). // Game data: every text of the game is static data (/data/*.json) downloaded once per page; the in-match files are // warmed in the background as soon as the player is in a room (warmGameData), before the match needs them. // Language (ui/lang.js, docs/I18N.md): chosen before the first render (initLang); App re-renders on a switch (useLang). @@ -48,7 +49,7 @@ import { settingsStore } from './ui/settings.js'; import { GuideHost } from './ui/guide.js'; import { installDeviceSupport } from './ui/device.js'; import { LoadoutHost } from './screens/loadout.js'; -import { installLoadoutSync, installOwnershipSync } from './ui/loadoutSync.js'; +import { installLoadoutSync, installOwnershipSync, installDiySync } from './ui/loadoutSync.js'; import { startBuildGuard } from './ui/buildGuard.js'; import { initLang, useLang, tickerText } from './ui/lang.js'; import { t, N_, translateWire } from '../../shared/i18n.js'; @@ -340,6 +341,7 @@ async function boot() { wireNet(); installLoadoutSync({ net }); installOwnershipSync({ net }); + installDiySync({ net }); net.attachBrowserHooks(); // Audio: unlock on first gesture, BGM follows the route / match phase (js/audio.js). installAudio({ getManifest: () => data.get('assets'), subscribe: store.subscribe, getState: store.get, selectRoute, settings: settingsStore.get() }); diff --git a/public/js/render/app.js b/public/js/render/app.js index 613260ac..a4f86610 100644 --- a/public/js/render/app.js +++ b/public/js/render/app.js @@ -276,6 +276,7 @@ export async function createFieldView(host, options = {}) { let penList = null; let ownPen = null; // the own m.private.nextEnemies (fallback composition of a scouted teammate's pen) let standInList = []; // the own m.private.standIns (0.2.0 补位): board pieces of these chess draw the stand-in + let diyPicks = {}; // the own m.private.diy (0.2.0 自选编队): pieces of these DIY slots draw the operator let camBeforePen = null; // { kind, opts } the camera the pen returns to let leader = null; // { key, view, stand, area } the round leader standing on the boss field in the prep (setLeader) let leaderHidden = true; // shown only by the boss-field prep camera (leaderShown) @@ -288,7 +289,7 @@ export async function createFieldView(host, options = {}) { timeScale: () => (mode === 'battle' ? interp.rate : 1), // (a chess fighting as its 补位 stand-in — `standInFor` — reads the stand-in's record: its attack interval) lookupDef: (info) => (info.side === 'enemy' ? data.enemy(info.defId) - : (info.standInFor && data.standIn(info.defId)) || data.chess(info.defId) || data.token(info.defId)), + : (info.standInFor && data.standIn(info.defId)) || (info.diy && data.diy(info.defId, info.diy)) || data.chess(info.defId) || data.token(info.defId)), crowded: () => views.size > 90, renderer: app.renderer, frameNo: () => frameNo, @@ -304,7 +305,7 @@ export async function createFieldView(host, options = {}) { timeScale: () => ctx.timeScale(), loadLevel: () => loadLevel, fieldRect: () => (mode === 'battle' && battleMeta ? battleMeta.rect : null), - subProfOf: (defId, info = null) => ((info && info.standInFor && data.standIn(defId)) || data.chess(defId))?.subProfessionId || null, + subProfOf: (defId, info = null) => ((info && info.standInFor && data.standIn(defId)) || (info && info.diy && data.diy(defId, info.diy)) || data.chess(defId))?.subProfessionId || null, view: (id) => views.get(id) || null, screenSize: size, fieldTop: () => { @@ -633,12 +634,16 @@ export async function createFieldView(host, options = {}) { // 0.2.0 补位: a board piece of a chess the player does not own (m.private.standIns) is deployed as its stand-in — // the stand-in's model; on the bench it keeps the chess's own (the hand shows the original operator) const si = area === 'board' && chess && standInList.includes(chess.baseId || chess.chessId) ? data.standIn(piece.id) : null; - const rec = si || chess; + // 0.2.0 自选编队: a DIY slot the player filled is its operator, on the board and on the bench alike + const pick = chess && chess.isDiy ? diyPicks[chess.baseId || chess.chessId] : null; + const dr = pick ? data.diy(piece.id, pick) : null; + const rec = si || dr || chess; return { kind: 'op', side: 'ally', defId: piece.id, spine: rec?.assets?.spine || rec?.charId || null, avatar: rec?.assets?.avatar || rec?.charId || null, tier: chess?.tier || piece.tier || 1, golden: !!(piece.golden || chess?.isGolden), dir, ...(si ? { standInFor: si.standInFor } : null), + ...(dr ? { diy: { charId: pick.charId, skillIndex: pick.skillIndex ?? null, uniEquipId: pick.uniEquipId ?? null } } : null), }; } @@ -687,6 +692,7 @@ export async function createFieldView(host, options = {}) { }; const src = ps && typeof ps === 'object' ? ps : {}; standInList = Array.isArray(src.standIns) ? src.standIns.filter((x) => typeof x === 'string') : []; + diyPicks = src.diy && typeof src.diy === 'object' ? src.diy : {}; ownPen = Array.isArray(src.nextEnemies) ? src.nextEnemies : null; setPenList(ownPen); addList(src.hand, 'hand'); diff --git a/public/js/render/app/host.js b/public/js/render/app/host.js index 4c93c19f..8be7e73a 100644 --- a/public/js/render/app/host.js +++ b/public/js/render/app/host.js @@ -2,6 +2,7 @@ import { createAssets, assets as defaultAssets } from '../../assets.js'; import { standInRecord } from '../../../../shared/standIn.js'; +import { diyRecordOf } from '../../../../shared/diy.js'; function withTimeout(p, ms) { return Promise.race([p, new Promise((resolve) => setTimeout(resolve, ms))]); @@ -40,10 +41,26 @@ function makeData(src) { standIns.set(c, rec); return rec; }; + // 0.2.0 自选编队: the composed 自选 record of a DIY slot filled with a pick (shared/diy.js over chess.json + backups.json) + const diys = new Map(); + const chessMap = () => { + try { return (src && typeof src.get === 'function' ? src.get('chess') : src && src.chess) || null; } catch { return null; } + }; + const diy = (id, pick) => { + const c = look('chess', id); + const b = backups(); + if (!c || !c.isDiy || !b || !pick || typeof pick.charId !== 'string') return null; + const key = `${id}|${pick.charId}|${pick.skillIndex ?? ''}|${pick.uniEquipId ?? ''}`; + if (diys.has(key)) return diys.get(key); + let rec; + try { rec = diyRecordOf(c, pick, { chess: chessMap(), backups: b }); } catch { rec = null; } + diys.set(key, rec); + return rec; + }; return { chess: (id) => look('chess', id), token: (id) => look('tokens', id), item: (id) => look('items', id), enemy: (id) => look('enemies', id), stage: (id) => look('stages', id), bond: (id) => look('bonds', id), - standIn, + standIn, diy, }; } diff --git a/public/js/render/app/info.js b/public/js/render/app/info.js index a51d0c1a..1eaa8306 100644 --- a/public/js/render/app/info.js +++ b/public/js/render/app/info.js @@ -45,5 +45,10 @@ export function renderInfo(u) { // 0.2.0 补位: the replaced operator's charId of a chess fighting as its stand-in (UnitInfo.standInFor; spine / avatar / // name are already the stand-in's) — the detail card and the view's data lookups (attack interval, splash FX) follow it standInFor: typeof u.standInFor === 'string' && u.standInFor ? u.standInFor : undefined, + // 0.2.0 自选编队: the pick of a DIY slot's unit (UnitInfo.diy { charId, skillIndex, uniEquipId }; spine / avatar / name + // are already the operator's) — the detail card composes the operator from it, the data lookups follow it + diy: u.diy && typeof u.diy === 'object' && typeof u.diy.charId === 'string' + ? { charId: u.diy.charId, skillIndex: Number.isInteger(u.diy.skillIndex) ? u.diy.skillIndex : null, uniEquipId: typeof u.diy.uniEquipId === 'string' ? u.diy.uniEquipId : null } + : undefined, }; } diff --git a/public/js/screens/diy.js b/public/js/screens/diy.js new file mode 100644 index 00000000..00c8a7aa --- /dev/null +++ b/public/js/screens/diy.js @@ -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`${proto ? t('原型') : t('已持有')}`; +} + +/** Bond chips (the bonds derived from the operator's factions; 协防 when none matches). */ +function Bonds({ bonds }) { + return html`${(bonds || []).map((b) => html`<${BondGlyph} bondId=${b} />${bondName(b)}`)}`; +} + +/** 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`
+ + ${pick ? html`

<${Icon} name="warn" />${t('这项自选在当前版本不可用,开局时会被移除')}

` : null} +
`; + } + 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`
+
<${Img} src=${chessPortraitUrl(m, elite || rec)} fallback=${html`${[...(rec.name || '?')][0]}`} />
+
+
<${TierChip} tier=${slot.tier} size="sm" />${label}<${KindTag} proto=${proto} />
+ ${rec.name} + ${classLine(rec)} + <${Bonds} bonds=${rec.bonds} /> + + <${Img} src=${skillRecordIconUrl(m, sk, { empty: false })} class="diy-slot__sicon" fallback=${html`${skillLabel(sk?.index)}`} /> + ${skillLabel(sk?.index)} ${sk?.name || ''}${proto ? html` ${t('(锁定)')}` : null} + + + ${mod ? html`<${Img} src=${moduleTypeIconUrl(data.get('local'), mod.type)} class="diy-slot__micon" fallback=${html`${moduleBadge({ typeName: mod.type })}`} />` : html`—`} + ${mod ? html`${mod.type || ''} ${mod.name || ''}` : t('不装备模组')} ${t('(精锐时生效)')} + + ${illegal ? html`

<${Icon} name="warn" />${t('这项自选在当前版本不可用,开局时会被移除')}

` : null} +
+
+ <${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('清空')} +
+
`; +} + +/** 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``; +} + +/** 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``; +} + +/** 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``; +} + +/** + * 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, 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``; +} + +/** + * 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, legal: Record, 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`
+
+

${t('5阶与6阶各有两个自选名额:选择持有的 6★ 干员(任选技能与模组)或原型干员(技能锁定)。自选干员只出现在你自己的商店里,调度中心达到该阶等级后才会刷新,价格与同阶干员相同;盟约按干员实际所属阵营分配,没有特质。')}

+
+
+ ${tiers.map((tier) => html`
+

<${TierChip} tier=${tier} size="sm" />${ROMAN[tier]}${t('阶')}

+
+ ${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)} />`)} +
+
`)} +
+ ${slot ? html`<${DiyPicker} m=${m} slot=${slot} picks=${picks} kitted=${kitted} onClose=${() => setPicking(null)} + onDone=${(pick) => { onSet(slot.slotId, pick); setPicking(null); }} />` : null} +
`; +} diff --git a/public/js/screens/game.js b/public/js/screens/game.js index 374cf484..c1e662aa 100644 --- a/public/js/screens/game.js +++ b/public/js/screens/game.js @@ -96,7 +96,7 @@ import { snapHud, activeBubbles, shortcutFor, shortcutBlocked, closesOnFieldPress, phaseTotalSeconds, homeFieldId, ownFieldId, normalizeSp, sortedPlayers, countdownState, shopBlockReason, stageOverrides, effectiveStage, watchTarget, dropFailureReason, previewEnemyKey, prepCamera, prepCameraFor, foldCamera, deployFieldOf, panelSide, panelSlots, bondPopupPlace, unitLoadout, deployedRecord, - mergeTarget, modeOffBonds, readyFundsPrompt, ownerBandId, + mergeTarget, modeOffBonds, readyFundsPrompt, ownerBandId, ownDiyRecord, } from '../ui/gameLogic.js'; import { toast } from '../ui/toasts.js'; import { BriefingScreen } from './briefing.js'; @@ -236,9 +236,13 @@ function MatchScreen() { // the field the own pieces are deployed on: the own board, or the player's half of the boss field in a boss round's // prep (user playtest #5 item 7: legality and the legal-tile highlights read THOSE tiles, like the server) const deployField = deployFieldOf(pub, myId); + // the own pieces' records: a DIY slot the player filled is its 自选 operator (0.2.0, m.private.diy — position, range, + // name; gameLogic/diy.js), every other chess its data record + const ownChess = (id) => { const c = gd.chess(id); return ownDiyRecord(c, live.current.priv, { chess: data.get('chess'), backups: data.get('backups') }) || c; }; const placeCtx = useMemo(() => placementContext({ priv, stage: gd.stage(pub?.stageId), editable, field: deployField, - getChess: gd.chess, getToken: gd.token, getItem: gd.item, getEffect: gd.effect, backups: gd.backups, + getChess: (id) => { const c = gd.chess(id); return ownDiyRecord(c, priv, { chess: data.get('chess'), backups: data.get('backups') }) || c; }, + getToken: gd.token, getItem: gd.item, getEffect: gd.effect, backups: gd.backups, }), [priv, pub?.stageId, editable, gd.ready, deployField]); live.current = { pub, priv, field, editable, placeCtx, watching, watchWho, home, myId, detail, drawer, bondOpen, emoteOpen, settingsOpen, exitOpen, drag, facing, sel, selBusy, pen, collapsedNow: collapsed, localDone: false, canPause: false, paused }; @@ -719,12 +723,12 @@ function MatchScreen() { // the drop target is the tile under the pointer (render/drag.js; an item dropped on a unit's tile equips that unit — // user playtest #4 item 1); `tile` = the last target tile (tileHover), `released` = the pointer went up (not a cancel) const ptr = { released: false, tile: null }; - const lookups = { getChess: gd.chess, getToken: gd.token, getItem: gd.item, chessRecord: (rec) => deployedRecord(rec, live.current.priv, gd.chess, gd.backups) }; + const lookups = { getChess: ownChess, getToken: gd.token, getItem: gd.item, chessRecord: (rec) => deployedRecord(rec, live.current.priv, ownChess, gd.backups) }; const runIntent = async (intent) => { const L = live.current; if (intent.confirmReplace) { // both slots used: the player picks the equipped item to destroy (cancel ⇒ nothing is sent) - const request = replaceRequest(L.placeCtx, intent, gd.chess, gd.item); + const request = replaceRequest(L.placeCtx, intent, ownChess, gd.item); if (request) { const uid = await openReplaceRef.current(request); if (!Number.isInteger(uid)) { audio.sfx('back', { volume: 0.5 }); return; } @@ -750,7 +754,7 @@ function MatchScreen() { /** Open the direction wheel for a legal board drop (the piece stays on the tile meanwhile). */ const openFacing = (entry, t) => { const piece = entry.piece; - const rec = piece.kind === 'item' ? gd.item(piece.id) : piece.kind === 'token' ? gd.token(piece.id) : gd.chess(piece.id); + const rec = piece.kind === 'item' ? gd.item(piece.id) : piece.kind === 'token' ? gd.token(piece.id) : ownChess(piece.id); holdPiece(view, piece.uid, { row: t.row, col: t.col }); setSel(null); setFacing({ uid: piece.uid, piece, row: t.row, col: t.col, grid: previewGrid(lookups, piece), name: rec?.name || '' }); @@ -889,8 +893,8 @@ function MatchScreen() { // ---- direction step (research 09 §1.2) and the selected piece's underframe ------------------------------------ // DESIGN §16: previews show the range the unit fights with under the player's loadout (an elite's module grid); a // chess the player does not own the range of its stand-in (0.2.0 补位, gameLogic deployedRecord) - const lookups = useMemo(() => ({ getChess: gd.chess, getToken: gd.token, getItem: gd.item, - chessRecord: (rec) => deployedRecord(rec, live.current.priv, gd.chess, gd.backups) }), [gd.ready]); + const lookups = useMemo(() => ({ getChess: ownChess, getToken: gd.token, getItem: gd.item, + chessRecord: (rec) => deployedRecord(rec, live.current.priv, ownChess, gd.backups) }), [gd.ready]); const heldRef = useRef(new Map()); // uid → { row, col, t } committed placements awaiting m.private const releaseHold = useCallback((uid) => { heldRef.current.delete(uid); @@ -1200,7 +1204,7 @@ function MatchScreen() { ${tempNotice ? html`<${TempRowNotice} view=${view} count=${temp.count} items=${temp.items} label=${!drag && !facing} ready=${phase === PHASE.PREP && !!priv?.ready} />` : null} - ${showPrep && view && viewKind !== 'loading' && priv && !pen ? html`<${StandInTags} view=${view} priv=${priv} getChess=${gd.chess} backups=${gd.backups} />` : null} + ${showPrep && view && viewKind !== 'loading' && priv && !pen ? html`<${StandInTags} view=${view} priv=${priv} getChess=${gd.chess} backups=${gd.backups} diyData=${{ chess: data.get('chess'), backups: data.get('backups') }} />` : null}
<${TopBar} pub=${pub} priv=${priv} conn=${conn} hud=${hud} total=${total} drawer=${drawer} @@ -1270,7 +1274,7 @@ function MatchScreen() { ${selEntry && editable && !facing && !drag && showPrep ? html`<${Underframe} key=${sel.uid} view=${view} uid=${sel.uid} row=${pieceTile(selEntry)?.row} col=${pieceTile(selEntry)?.col} actions=${underframeActions(placeCtx, sel.uid)} busy=${selBusy} - name=${(selEntry.piece.kind === 'item' ? gd.item(selEntry.piece.id) : selEntry.piece.kind === 'token' ? gd.token(selEntry.piece.id) : gd.chess(selEntry.piece.id))?.name || ''} + name=${(selEntry.piece.kind === 'item' ? gd.item(selEntry.piece.id) : selEntry.piece.kind === 'token' ? gd.token(selEntry.piece.id) : ownChess(selEntry.piece.id))?.name || ''} onRetreat=${retreatSel} onSell=${sellSel} onDestroy=${sellSel} />` : null}
diff --git a/public/js/screens/game/standInTags.js b/public/js/screens/game/standInTags.js index 92ad2837..7e49e264 100644 --- a/public/js/screens/game/standInTags.js +++ b/public/js/screens/game/standInTags.js @@ -1,12 +1,15 @@ // public/js/screens/game/standInTags.js — 0.2.0 补位: the 「替补:X」 tag under the player's own prep pieces (hand, // 临时整备区, board) of a chess they do not own (m.private.standIns). The hand keeps the chess's own model (the card shows // the original operator); on the board the model is the stand-in's (render/app.js pieceInfo) — the tag says which -// chess it is either way. Anchored to each piece's drawn body (view.pieceScreenRect, followed every frame like the -// direction wheel follows its tile); never takes the pointer, so the pieces stay draggable. +// chess it is either way. 0.2.0 自选编队: the 「自选」 tag under the player's own pieces of a DIY slot it filled +// (m.private.diy) — the model is the operator's everywhere. Anchored to each piece's drawn body (view.pieceScreenRect, +// followed every frame like the direction wheel follows its tile); never takes the pointer, so the pieces stay +// draggable. import { useEffect, useRef, useState } from '../../../vendor/hooks.module.js'; import { html } from '../../ui/components.js'; -import { fieldsStandIn, standInOf, standInLabel } from '../../ui/gameLogic.js'; +import { fieldsStandIn, standInOf, standInLabel, ownDiyRecord } from '../../ui/gameLogic.js'; +import { t } from '../../../../shared/i18n.js'; const rawOf = (view) => (view && view.raw) || view || null; @@ -30,9 +33,27 @@ export function standInPieces(priv, getChess, backups) { return out; } -/** @param {{ view: any, priv: any, getChess: (id: string) => any, backups: any }} props */ -export function StandInTags({ view, priv, getChess, backups }) { - const items = standInPieces(priv, getChess, backups); +/** + * The player's own prep pieces of a DIY slot it filled (0.2.0 自选编队): [{ uid, label, name, area, diy: true }]. + * @param {any} priv m.private @param {(id: string) => any} getChess @param {{ chess: any, backups: any }} data + */ +export function diyPieces(priv, getChess, data) { + const out = []; + if (!priv || !priv.diy || typeof priv.diy !== 'object' || !Object.keys(priv.diy).length) return out; + const add = (p, area) => { + if (!p || p.kind !== 'chess' || !Number.isInteger(p.uid)) return; + const rec = ownDiyRecord(getChess(p.id), priv, data); + if (rec) out.push({ uid: p.uid, label: t('自选'), name: rec.name, area, diy: true }); + }; + for (const p of Array.isArray(priv.hand) ? priv.hand : []) add(p, 'hand'); + for (const p of Array.isArray(priv.temp) ? priv.temp : []) add(p, 'temp'); + for (const p of Array.isArray(priv.board) ? priv.board : []) add(p, 'board'); + return out; +} + +/** @param {{ view: any, priv: any, getChess: (id: string) => any, backups: any, diyData?: { chess: any, backups: any } | null }} props */ +export function StandInTags({ view, priv, getChess, backups, diyData = null }) { + const items = [...standInPieces(priv, getChess, backups), ...(diyData ? diyPieces(priv, getChess, diyData) : [])]; const [pos, setPos] = useState({}); const last = useRef(''); const key = items.map((it) => it.uid).join(','); @@ -64,7 +85,8 @@ export function StandInTags({ view, priv, getChess, backups }) { return html``; diff --git a/public/js/screens/loadout.js b/public/js/screens/loadout.js index de01b249..c3c51534 100644 --- a/public/js/screens/loadout.js +++ b/public/js/screens/loadout.js @@ -14,6 +14,8 @@ // The loadout lives in ui/loadoutSync.js (localStorage + room.loadout); the model is ui/loadoutModel.js. // The second tab, 干员持有 (0.2.0 补位, screens/ownership.js): which NORMAL chess the player owns — a chess marked 未持有 is // deployed as its official stand-in (room.ownership; ui/ownershipModel.js) — with its own 导出 / 导入 / 全部持有. +// The third tab, 自选编队 (0.2.0 DIY, screens/diy.js): the operators of the four DIY slots (room.diy; ui/diyModel.js) — +// with its own 导出 / 导入 / 全部清空. // Keyboard: Esc closes, ←/→ move through the (filtered) roster when focus is not in the search field (干员调配 tab). import { useEffect, useMemo, useRef, useState } from '../../vendor/hooks.module.js'; @@ -29,9 +31,12 @@ import { MODULE_NONE, PROF_ORDER, PROF_NAME, rosterOf, filterRoster, recordsOf, chessOptions, effectiveChoice, setChoice, resetChoice, changedCount, skillLabel, moduleBadge, attrRows, skillTags, serializeExport, parseImport, LOADOUT_IMPORT_MAX_BYTES, } from '../ui/loadoutModel.js'; -import { loadoutStore, openLoadout, closeLoadout, setEntries, applyLoadoutEntries, setNotOwned, applyOwnershipImport } from '../ui/loadoutSync.js'; +import { loadoutStore, openLoadout, closeLoadout, setEntries, applyLoadoutEntries, setNotOwned, applyOwnershipImport, setDiyPicks, applyDiyImport } from '../ui/loadoutSync.js'; import { setOwned, notOwnedCount, serializeOwnership, parseOwnershipImport, OWNERSHIP_IMPORT_MAX_BYTES } from '../ui/ownershipModel.js'; import { OwnershipPanel, useOwnershipRoster } from './ownership.js'; +import { DiyPanel, diyData } from './diy.js'; +import { diyCount, sanitizeDiyPicks, setPick, serializeDiy, parseDiyImport, DIY_IMPORT_MAX_BYTES } from '../ui/diyModel.js'; +import { t } from '../../../shared/i18n.js'; import { copyText } from '../ui/clipboard.js'; import { toast } from '../ui/toasts.js'; @@ -71,7 +76,7 @@ function readFileText(file) { }); } -/** `stronghold-loadout-20261003-1245.json` (`stronghold-ownership-…` for the 干员持有 list) */ +/** `stronghold-loadout-20261003-1245.json` (`stronghold-ownership-…` for the 干员持有 list, `stronghold-diy-…` for 自选编队) */ function exportFilename(now = new Date(), what = 'loadout') { const p = (n) => String(n).padStart(2, '0'); return `stronghold-${what}-${now.getFullYear()}${p(now.getMonth() + 1)}${p(now.getDate())}-${p(now.getHours())}${p(now.getMinutes())}.json`; @@ -383,10 +388,12 @@ const SYNC_TEXT = { }; /** The 干员持有 tab's status line: the setting never applies to a running match. */ const OWN_SYNC_TEXT = { ...SYNC_TEXT, locked: ['下一局生效', 'is-warn'] }; +/** The 自选编队 tab's status line (msgids, translated where shown): out of match, like 干员持有. */ +const DIY_SYNC_TEXT = { ...OWN_SYNC_TEXT }; /** The overlay screen. */ function LoadoutScreen({ st }) { - const ready = useData('chess', 'bonds', 'assets', 'local'); + const ready = useData('chess', 'bonds', 'assets', 'local', 'backups'); const phase = useStore((s) => s.match?.public?.phase || null); const inMatch = useStore((s) => !!s.room?.inMatch); // co-op briefing (INFO_CHECK, 25 s): the overlay covers the briefing's own countdown, so it shows the time left — the @@ -411,9 +418,18 @@ function LoadoutScreen({ st }) { const [narrowDetail, setNarrowDetail] = useState(false); // phones: the detail slides over the roster const [io, setIo] = useState(null); // 导出 / 导入 dialog: { mode, text } | null - const tab = st.tab === 'ownership' ? 'ownership' : 'loadout'; + const tab = st.tab === 'ownership' || st.tab === 'diy' ? st.tab : 'loadout'; const ownRoster = useOwnershipRoster(ready); const nNotOwned = notOwnedCount(st.notOwned, ownRoster); + // 自选编队 (0.2.0 DIY): the stored picks, those the server would keep (the kit list of the last welcome) + const nDiy = diyCount(st.diy); + const diyLegal = ready ? sanitizeDiyPicks(st.diy, diyData(), st.diyKitted || []) : {}; + const setDiySlot = (slotId, pick) => setDiyPicks(setPick(loadoutStore.get().diy, slotId, pick)); + const clearDiy = async () => { + if (!nDiy) return; + const ok = await confirmDialog({ title: t('全部清空'), text: t('清空全部 {n} 个自选名额?', { n: nDiy }), okText: t('全部清空') }); + if (ok) setDiyPicks({}); + }; const setTab = (t) => loadoutStore.set({ tab: t }); const toggleOwned = (id, owned) => setNotOwned(setOwned(loadoutStore.get().notOwned, id, owned)); const ownAll = async () => { @@ -434,27 +450,38 @@ function LoadoutScreen({ st }) { // the clipboard, or the textarea); `io.kind` says which const ioText = io?.text ?? ''; const ioOwn = io?.kind === 'ownership'; + const ioDiy = io?.kind === 'diy'; const openExport = () => setIo(tab === 'ownership' ? { mode: 'export', kind: 'ownership', text: serializeOwnership(loadoutStore.get().notOwned) } - : { mode: 'export', kind: 'loadout', text: serializeExport(loadoutStore.get().entries) }); + : tab === 'diy' ? { mode: 'export', kind: 'diy', text: serializeDiy(loadoutStore.get().diy) } + : { mode: 'export', kind: 'loadout', text: serializeExport(loadoutStore.get().entries) }); const openImport = () => setIo({ mode: 'import', kind: tab, text: '' }); const ioCopy = async () => { const ok = await copyText(ioText); toast(ok ? '已复制到剪贴板' : '复制失败,请在文本框中手动全选复制', ok ? 'success' : 'warn'); }; - const ioDownload = () => downloadText(exportFilename(new Date(), ioOwn ? 'ownership' : 'loadout'), ioText); + const ioDownload = () => downloadText(exportFilename(new Date(), ioOwn ? 'ownership' : ioDiy ? 'diy' : 'loadout'), ioText); const ioPick = () => fileRef.current?.click(); const ioFile = async (e) => { const f = e.currentTarget.files && e.currentTarget.files[0]; e.currentTarget.value = ''; // picking the same file twice must fire again if (!f) return; // refuse a huge pick before reading it into memory (a real payload is a few KB) + if (ioDiy && f.size > DIY_IMPORT_MAX_BYTES) { toast(t('文件过大,请选择「导出」下载的自选编队文件'), 'error'); return; } if (f.size > (ioOwn ? OWNERSHIP_IMPORT_MAX_BYTES : LOADOUT_IMPORT_MAX_BYTES)) { toast(`文件过大,请选择「导出」下载的${ioOwn ? '干员持有' : '调配'}文件`, 'error'); return; } try { setIo({ ...io, mode: 'import', text: await readFileText(f) }); } catch { toast('读取文件失败', 'error'); } }; const ioApply = () => { // an import before chess.json is loaded would sanitise every entry away — refuse instead of wiping the loadout if (!ready) { toast('干员数据仍在载入,请稍候再导入', 'warn'); return; } + if (ioDiy) { + const r = parseDiyImport(ioText); + if (!r.ok) { toast(t('导入失败:{error}', { error: t(r.error, r.params) }), 'error'); return; } + const { applied, dropped } = applyDiyImport(r.picks, diyData(), loadoutStore.get().diyKitted || []); + setIo(null); + toast(applied ? t('已导入 {n} 个自选名额', { n: applied }) + (dropped ? t('(另有 {n} 项不可用,未导入)', { n: dropped }) : '') : t('已导入:全部名额为空'), dropped ? 'warn' : 'success'); + return; + } if (ioOwn) { const r = parseOwnershipImport(ioText); if (!r.ok) { toast(`导入失败:${r.error}`, 'error'); return; } @@ -482,7 +509,7 @@ function LoadoutScreen({ st }) { const typing = e.target && /^(INPUT|SELECT|TEXTAREA)$/.test(e.target.tagName); if (e.key === 'Escape') { e.preventDefault(); e.stopImmediatePropagation(); closeLoadout(); return; } if (typing) return; - if ((e.key === 'ArrowRight' || e.key === 'ArrowLeft') && loadoutStore.get().tab !== 'ownership') { + if ((e.key === 'ArrowRight' || e.key === 'ArrowLeft') && loadoutStore.get().tab !== 'ownership' && loadoutStore.get().tab !== 'diy') { const ids = filterRoster(rosterOf(data.list('chess')), loadoutStore.get().filters, loadoutStore.get().entries, getChess, getBond).map((c) => c.chessId); if (!ids.length) return; const cur = Math.max(0, ids.indexOf(loadoutStore.get().sel)); @@ -503,12 +530,15 @@ function LoadoutScreen({ st }) { if (el && typeof el.scrollIntoView === 'function') el.scrollIntoView({ block: 'nearest' }); }, [selId]); - const [syncText, syncCls] = tab === 'ownership' ? OWN_SYNC_TEXT[st.ownSync] || OWN_SYNC_TEXT.idle : SYNC_TEXT[st.sync] || SYNC_TEXT.idle; + const [syncText, syncCls] = tab === 'ownership' ? OWN_SYNC_TEXT[st.ownSync] || OWN_SYNC_TEXT.idle + : tab === 'diy' ? DIY_SYNC_TEXT[st.diySync] || DIY_SYNC_TEXT.idle : SYNC_TEXT[st.sync] || SYNC_TEXT.idle; const fromText = st.from === 'briefing' ? '确认本局信息阶段结束前可调整本局配置' : '开始模拟前可调整干员携带的技能与模组,干员等级不可调整'; // 干员持有 is out of match: a running match keeps the list its seat had at its start const ownLocked = inMatch && !!phase && phase !== PHASE.LOBBY; const ownText = ownLocked ? '干员持有是局外设置:本局按开局时的设置进行,修改将在下一局生效' : '局外设置,下一局生效 · 联机时只影响你自己的棋子 · 默认全部持有'; + const diyText = ownLocked ? t('自选编队是局外设置:本局按开局时的设置进行,修改将在下一局生效') + : t('局外设置,下一局生效 · 联机时只进入你自己的商店'); return html`<${Fragment}>
- <${MicroLabel} tone="mint">${tab === 'ownership' ? 'OPERATOR ROSTER' : 'OPERATOR LOADOUT'} -
+ <${MicroLabel} tone="mint">${tab === 'ownership' ? 'OPERATOR ROSTER' : tab === 'diy' ? 'SELF-SELECT SQUAD' : 'OPERATOR LOADOUT'} +
+
- ${tab === 'ownership' ? html`
+ ${tab === 'diy' ? html`
+ ${syncText ? html`${t(syncText)}` : null} + ${t('已选')} ${nDiy}/4 + <${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('全部清空')} +
` : tab === 'ownership' ? html`
${syncText ? html`${syncText}` : null} 未持有 ${nNotOwned}/${ownRoster.length} <${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}>全部恢复默认
`} - ${tab === 'ownership' + ${tab === 'diy' + ? html`

<${Icon} name="info" />${diyText}

` + : tab === 'ownership' ? html`

<${Icon} name="info" />${ownText}

` : html`

<${Icon} name="info" />${locked ? '本局的调配已锁定(确认本局信息后无法修改),修改将在下一局生效' : fromText}

`} - ${!ready ? html`
<${Spinner} size="sm" />正在载入干员数据(打开页面后仅载入一次)…
` : tab === 'ownership' + ${!ready ? html`
<${Spinner} size="sm" />正在载入干员数据(打开页面后仅载入一次)…
` : 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`
@@ -562,7 +604,8 @@ function LoadoutScreen({ st }) {
`}
${io ? html`<${Modal} open=${true} onClose=${() => setIo(null)} - title=${`${io.mode === 'export' ? '导出' : '导入'}${ioOwn ? '干员持有' : '干员调配'}`} micro=${ioOwn ? 'OPERATOR ROSTER' : 'OPERATOR LOADOUT'} + title=${ioDiy ? t(io.mode === 'export' ? '导出自选编队' : '导入自选编队') : `${io.mode === 'export' ? '导出' : '导入'}${ioOwn ? '干员持有' : '干员调配'}`} + micro=${ioOwn ? 'OPERATOR ROSTER' : ioDiy ? 'SELF-SELECT SQUAD' : 'OPERATOR LOADOUT'} actions=${io.mode === 'export' ? html`<${Button} variant="ghost" onClick=${() => setIo(null)}>关闭 <${Button} variant="secondary" icon="copy" data-testid="loadout-io-copy" onClick=${ioCopy}>复制 @@ -570,7 +613,11 @@ function LoadoutScreen({ st }) { : html`<${Button} variant="ghost" onClick=${() => setIo(null)}>取消 <${Button} variant="secondary" data-testid="loadout-io-pick" onClick=${ioPick}>选择文件 <${Button} variant="primary" icon="check" data-testid="loadout-io-apply" disabled=${!ioText.trim() || !ready} onClick=${ioApply}>导入`}> -

${ioOwn +

${ioDiy + ? (io.mode === 'export' + ? t('共 {n} 个自选名额已选择。复制或下载这份数据,即可在别的设备或浏览器上导入。', { n: nDiy }) + : t('把导出的自选编队数据粘贴到下方,或点「选择文件」。导入会覆盖当前的自选编队。')) + : ioOwn ? (io.mode === 'export' ? html`共 ${nNotOwned} 名干员未持有。复制或下载这份数据,即可在别的设备或浏览器上导入。` : html`把导出的干员持有数据粘贴到下方,或点「选择文件」。导入会覆盖当前的干员持有设置。`) @@ -578,7 +625,7 @@ function LoadoutScreen({ st }) { ? html`共 ${nChanged} 名干员已调整。复制或下载这份数据,即可在别的设备或浏览器上导入。` : html`把导出的内容粘贴到下方,或点「选择文件」。${nChanged ? html`导入会覆盖当前的 ${nChanged} 名干员调配。` : 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) const entries = useStore((s) => s.entries, Object.is, loadoutStore); const notOwned = useStore((s) => s.notOwned, Object.is, loadoutStore); + const diy = useStore((s) => s.diy, Object.is, loadoutStore); const n = badgeCount(entries, data.status('chess') === 'ready' ? (id) => data.lookup('chess', id) : null); // 0.2.0 补位: how many operators the player marked as not owned (the 干员持有 tab) — easy to forget between sessions const off = Array.isArray(notOwned) ? notOwned.length : 0; + // 0.2.0 自选编队: how many DIY slots the player filled + const nDiy = diyCount(diy); return html``; } diff --git a/public/js/screens/result.js b/public/js/screens/result.js index 8d9857c5..0fea546a 100644 --- a/public/js/screens/result.js +++ b/public/js/screens/result.js @@ -6,7 +6,9 @@ // { victory, roundsPassed, lastRound?, hiddenCleared?, bossId?, hiddenBossId?, difficulty?, modeId?, durationMs?, // players: [{ playerId, seat, name, isBot, alive, lp, bandId, roundsPassed?, // title: 'comment_1' | { id, name? } | null, -// lineup: [{ id /* chessId, golden id if elite */, golden?, tier?, items?: [itemId] }], +// lineup: [{ id /* chessId, golden id if elite */, golden?, tier?, items?: [itemId], +// diy? /* 0.2.0 自选编队: a DIY slot's pick { charId, skillIndex, uniEquipId } — the card +// draws the operator (gameLogic diyRecordFor) */ }], // bonds?: [{ bondId, layers, active }], // stats: { dmgDealt, kills, leaks, gold /* funds SPENT */, refreshes, merges, bossDamage?, itemsEquipped?, // activatedLayers?, lpLost?, perfectRounds? } }] } @@ -14,7 +16,8 @@ import { useEffect } from '../../vendor/hooks.module.js'; import { html, Button, Icon, MicroLabel, DifficultyTag } from '../ui/components.js'; import { useGameData, Img, UnitThumb, BandIcon, PlayerAvatar, BondGlyph, LpTower, Sprite } from '../ui/gameComponents.js'; -import { normalizeResult, fmtNum } from '../ui/gameLogic.js'; +import { normalizeResult, fmtNum, diyRecordFor } from '../ui/gameLogic.js'; +import { data } from '../data.js'; import { enemyIconUrl, titleIconUrl, uiUrl } from '../ui/assetUrls.js'; import { store, useStore, emptyMatch } from '../store.js'; import { audio } from '../audio.js'; @@ -46,7 +49,8 @@ function PlayerCard({ p, myId, titles, best, solo = false }) {
- ${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`${p.alive === false ? '阵容已撤离' : 'NO INFO'}`}
${bonds.length ? html`
${bonds.map((b) => html` diff --git a/public/js/ui/bondStrip.js b/public/js/ui/bondStrip.js index 2d331d72..03dee155 100644 --- a/public/js/ui/bondStrip.js +++ b/public/js/ui/bondStrip.js @@ -21,10 +21,11 @@ import { html, BondDisc, Icon, MicroLabel, Tooltip } from './components.js'; import { RichText, UnitThumb, BondGlyph, GIcon } from './gameComponents.js'; -import { sortBonds, bondMembers, nextThreshold, bondTier, harmonyMembers, HARMONY_BOND, memberHeadCount, briefingBondTip } from './gameLogic.js'; +import { sortBonds, bondMembers, nextThreshold, bondTier, harmonyMembers, HARMONY_BOND, memberHeadCount, briefingBondTip, diyGetter, diyRecordFor } from './gameLogic.js'; import { formatBondEffect } from './richText.js'; import { bondIconUrl } from './assetUrls.js'; import { data } from '../data.js'; +import { t } from '../../../shared/i18n.js'; const cx = (...p) => p.flat().filter(Boolean).join(' '); @@ -99,8 +100,11 @@ export function BondPopup({ bondId, entry, priv, banned = [], onClose, onMember, const th = Array.isArray(entry?.thresholds) && entry.thresholds.length ? entry.thresholds : b.thresholds || []; const tier = off ? 0 : entry?.tier ?? bondTier(count, th, b.maxCount); const active = !off && (entry ? !!entry.active : tier > 0); - const getChess = (id) => data.lookup('chess', id); - const members = bondMembers(b, priv, banned, getChess, (id) => data.lookup('items', id)); + // (自选 pieces are their operators: the own ones by m.private.diy, a watched teammate's by the pick its unit carries) + const dd = { chess: data.get('chess'), backups: data.get('backups') }; + const getChess = diyGetter((id) => data.lookup('chess', id), priv, dd); + const pieceRecord = (p) => (p && p.diy && typeof p.diy === 'object' ? diyRecordFor(data.lookup('chess', p.id), p.diy, dd) : null) || getChess(p.id); + const members = bondMembers(b, priv, banned, getChess, (id) => data.lookup('items', id), pieceRecord); const countsHand = entry?.countsHand ?? b.countsHand; const next = nextThreshold(count, th); const hasNow = !off && !!(b.effectDescRaw || b.effectDesc); @@ -149,10 +153,11 @@ export function BondPopup({ bondId, entry, priv, banned = [], onClose, onMember, ${members.map((mb) => html``)}
diff --git a/public/js/ui/detailPanel.js b/public/js/ui/detailPanel.js index eaf509f9..00a3019d 100644 --- a/public/js/ui/detailPanel.js +++ b/public/js/ui/detailPanel.js @@ -33,13 +33,14 @@ import { html, Icon, TierChip, MicroLabel, Button, confirmDialog, useTicker } from './components.js'; import { Img, RichText, UnitThumb, BondGlyph, GIcon } from './gameComponents.js'; -import { attackInterval, rangeGridBox, fmtNum, tileKey, chessLoadout, nextThreshold, bondTier, briefingBondTip, pieceBondIds, grantedBonds, morphPairings, fieldsStandIn, standInOf, standInLoadout, standInLabel } from './gameLogic.js'; +import { attackInterval, rangeGridBox, fmtNum, tileKey, chessLoadout, nextThreshold, bondTier, briefingBondTip, pieceBondIds, grantedBonds, morphPairings, fieldsStandIn, standInOf, standInLoadout, standInLabel, ownDiyRecord, ownDiyPick, diyRecordFor, pickGetter } from './gameLogic.js'; import { chessPortraitUrl, skillIconUrl, skillRecordIconUrl, profIconUrl, subProfIconUrl, itemIconUrl, enemyIconUrl, tokenAvatarUrl, factionIconUrl, uiUrl, moduleTypeIconUrl } from './assetUrls.js'; import { abilityRows } from './abilityLines.js'; import { data } from '../data.js'; import { attackRangeGrid } from '../../../shared/loadoutRecord.js'; import { SKILL_SUMMON_START_DEPLOY } from '../../../shared/constants.js'; import { moduleBadge } from './loadoutModel.js'; +import { t } from '../../../shared/i18n.js'; const cx = (...p) => p.flat().filter(Boolean).join(' '); @@ -354,10 +355,12 @@ export function chessStatsBlock({ rec, chess, live = null }) {
`; } -export function ChessDetail({ chess, piece, unit, snapHp, editable, onSell, bonds, offBonds = null, loadout, onBond, live = null, hint = null, unitItems = null, standIn = null }) { +export function ChessDetail({ chess, piece, unit, snapHp, editable, onSell, bonds, offBonds = null, loadout, onBond, live = null, hint = null, unitItems = null, standIn = null, diy = null }) { const m = data.get('assets'); const hp = hpOf(live, snapHp); - const getChess = (id) => data.lookup('chess', id); + // 0.2.0 自选编队: `chess` is then the composed 自选 record (the operator, the slot's tier / price); its skill and module are + // the pick's — the lookups resolve the slot's ids to the same pick (shared/diy.js), so the loadout reads them as defaults + const getChess = diy ? pickGetter((id) => data.lookup('chess', id), diy, { chess: data.get('chess'), backups: data.get('backups') }) : (id) => data.lookup('chess', id); // 0.2.0 补位: a chess fielded as its stand-in keeps its identity (name, tier, bonds, 特质, sell price) and shows the // stand-in's body — portrait, class, 特性, stats, range, skill, talents, module (its backup selection, no loadout) const si = standIn && standIn.standInFor ? standIn : null; @@ -392,6 +395,7 @@ export function ChessDetail({ chess, piece, unit, snapHp, editable, onSell, bond ${golden ? html`精锐` : null} ${piece?.kind === 'token' ? html`召唤物` : null} ${si ? html`${standInLabel(si)}` : null} + ${diy ? html`${t('自选')}` : null}

${c.name}

${si ? `${si.name}${si.appellation && si.appellation !== si.name ? ` · ${si.appellation}` : ''}` : c.appellation || ''} @@ -613,11 +617,18 @@ export function TokenDetail({ token, piece, ownerId = null, snapHp = null, live * @param {Map} pieces indexPieces(priv) * @param {{ priv?: any, backups?: any }} [opts] 0.2.0 补位: the player's own pieces and cards of a chess in * m.private.standIns — and a unit carrying `standInFor` — resolve with `standIn` (the composed stand-in record the - * card shows as the body) + * card shows as the body); 0.2.0 自选编队: the player's own pieces and cards of a DIY slot it filled (m.private.diy) — + * and a unit carrying `diy` — resolve to the composed 自选 record (`chess`, the operator) with `diy` = the pick */ export function resolveDetail(target, pieces, { priv = null, backups = data.get('backups') } = {}) { if (!target) return null; const ownStandIn = (c) => (fieldsStandIn(priv, c) ? standInOf(c, backups) : null); + const dd = { chess: data.get('chess'), backups }; + /** the own card of chess `c`: its 自选 record and pick when the player filled that DIY slot */ + const ownDiy = (c) => { + const rec = c ? ownDiyRecord(c, priv, dd) : null; + return rec ? { chess: rec, diy: ownDiyPick(priv, c) } : null; + }; if (target.kind === 'piece') { const e = pieces?.get(target.uid); if (!e) return null; @@ -625,6 +636,8 @@ export function resolveDetail(target, pieces, { priv = null, backups = data.get( if (p.kind === 'item') { const it = data.lookup('items', p.id); return it ? { type: 'item', item: it, piece: p } : null; } if (p.kind === 'token') { const t = data.lookup('tokens', p.id); return t ? { type: 'token', token: t, piece: p, ownerId: tokenOwnerId(p, pieces) } : null; } const c = data.lookup('chess', p.id); + const d = ownDiy(c); + if (d) return { type: 'chess', chess: d.chess, piece: p, standIn: null, diy: d.diy }; return c ? { type: 'chess', chess: c, piece: p, standIn: ownStandIn(c) } : null; } if (target.kind === 'chess') { @@ -634,6 +647,8 @@ export function resolveDetail(target, pieces, { priv = null, backups = data.get( // (a bond popup's member card of a teammate's strip — `owner` another player — and the mode's banned list (`foreign`) // show the chess as it is: the viewer's 补位 list is not theirs) const foreign = !!target.foreign || (target.owner != null && !!priv && target.owner !== priv.playerId); + const d = foreign ? null : ownDiy(c); + if (d) return { type: 'chess', chess: d.chess, hint: target.hint || null, standIn: null, diy: d.diy, ...(items.length ? { unitItems: items } : {}) }; return c ? { type: 'chess', chess: c, hint: target.hint || null, standIn: foreign ? null : ownStandIn(c), ...(items.length ? { unitItems: items } : {}) } : null; } if (target.kind === 'item') { const it = data.lookup('items', target.id); return it ? { type: 'item', item: it } : null; } @@ -651,6 +666,11 @@ export function resolveDetail(target, pieces, { priv = null, backups = data.get( let si = null; if (c && typeof u.standInFor === 'string' && u.standInFor) si = standInOf(c, backups); else if (c && own?.piece) si = ownStandIn(c); + // 0.2.0 自选编队: a unit says itself which operator fills its DIY slot (UnitInfo diy); an own piece's unit follows + // m.private.diy like the piece + const pick = c && u.diy && typeof u.diy === 'object' ? u.diy : own?.piece ? ownDiyPick(priv, c) : null; + const dr = pick ? diyRecordFor(c, pick, dd) : null; + if (dr) return { type: 'chess', chess: dr, piece: own?.piece || null, unitId: u.id, unitItems: Array.isArray(u.items) ? u.items : null, standIn: null, diy: pick }; if (c) return { type: 'chess', chess: c, piece: own?.piece || null, unitId: u.id, unitItems: Array.isArray(u.items) ? u.items : null, standIn: si }; const t = data.lookup('tokens', u.defId); if (t) return { type: 'token', token: t, unitId: u.id, ownerId: tokenOwnerId(own?.piece, pieces) }; @@ -695,7 +715,7 @@ export function DetailPanel({ detail, editable, snapHp, onClose, onSell, onDestr
${detail.type === 'chess' ? html`<${ChessDetail} chess=${detail.chess} piece=${detail.piece} snapHp=${snapHp} editable=${editable} onSell=${sellIt} bonds=${bonds} offBonds=${offBonds} loadout=${loadout} onBond=${onBond} live=${liveNow} hint=${detail.hint || null} unitItems=${detail.unitItems || null} - standIn=${detail.standIn || null} />` : null} + standIn=${detail.standIn || null} diy=${detail.diy || null} />` : null} ${detail.type === 'item' ? html`<${ItemDetail} item=${detail.item} piece=${detail.piece} editable=${editable} onDestroy=${destroyIt} offBonds=${offBonds} />` : null} ${detail.type === 'enemy' ? html`<${EnemyDetail} enemy=${detail.enemy} snapHp=${snapHp} count=${detail.count} live=${liveNow} />` : null} ${detail.type === 'token' ? html`<${TokenDetail} token=${detail.token} piece=${detail.piece} ownerId=${detail.ownerId ?? null} snapHp=${snapHp} live=${liveNow} />` : null} diff --git a/public/js/ui/diyModel.js b/public/js/ui/diyModel.js new file mode 100644 index 00000000..adcb2299 --- /dev/null +++ b/public/js/ui/diyModel.js @@ -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} + */ +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|null} kitted + * @returns {Record} + */ +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} picks the current roster @param {any} data + * @param {Iterable|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 } | { ok: false, error: string, params?: Record }} + */ +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) }; +} diff --git a/public/js/ui/fallbackField.js b/public/js/ui/fallbackField.js index c9f70a23..690785f3 100644 --- a/public/js/ui/fallbackField.js +++ b/public/js/ui/fallbackField.js @@ -21,7 +21,7 @@ import { render } from '../../vendor/preact.module.js'; import { html, TierChip } from './components.js'; import { GEO } from '../../../shared/constants.js'; import { chessAvatarUrl, itemIconUrl, tokenAvatarUrl, enemyIconUrl } from './assetUrls.js'; -import { tileKey, hasFlag, UF, penPlacement, PEN, fieldTile, fieldsStandIn, standInOf } from './gameLogic.js'; +import { tileKey, hasFlag, UF, penPlacement, PEN, fieldTile, fieldsStandIn, standInOf, ownDiyRecord } from './gameLogic.js'; const DRAG_PX = 6; const DMG_TTL = 900; @@ -126,12 +126,17 @@ export function createFallbackView(host, opts = {}) { const chess = lookup('chess', p.id); // 0.2.0 补位: on the board a chess the player does not own is its stand-in (render/app.js pieceInfo) const si = area === 'board' && fieldsStandIn(st.priv, chess) ? standInOf(chess, dataStore?.get?.('backups') ?? null) : null; - return chessAvatarUrl(mm, si || chess); + return chessAvatarUrl(mm, si || ownDiy(chess) || chess); + } + /** 0.2.0 自选编队: the player's own piece of a DIY slot it filled is its operator (gameLogic ownDiyRecord) */ + function ownDiy(chess) { + return chess ? ownDiyRecord(chess, st.priv, { chess: dataStore?.get?.('chess') ?? null, backups: dataStore?.get?.('backups') ?? null }) : null; } function pieceName(p) { if (p.kind === 'item') return lookup('items', p.id)?.name || '道具'; if (p.kind === 'token') return lookup('tokens', p.id)?.name || '召唤物'; - return lookup('chess', p.id)?.name || '干员'; + const chess = lookup('chess', p.id); + return (ownDiy(chess) || chess)?.name || '干员'; } function Piece({ p, x, y, L, area }) { diff --git a/public/js/ui/gameComponents.js b/public/js/ui/gameComponents.js index d0bc46f4..6ebc22f2 100644 --- a/public/js/ui/gameComponents.js +++ b/public/js/ui/gameComponents.js @@ -93,15 +93,16 @@ export const isGoldenPiece = (piece, chess) => !!(piece?.golden || chess?.isGold /** * Square unit thumbnail for a piece / chess / item / token / enemy: art + tier chip + elite frame. * @param {{ kind?: 'chess'|'item'|'token'|'enemy', id: string, golden?: boolean, size?: 'xs'|'sm'|'md'|'lg', tier?: number, - * showTier?: boolean, class?: string, dim?: boolean, badge?: any, title?: string }} props + * showTier?: boolean, class?: string, dim?: boolean, badge?: any, title?: string, rec?: any }} props + * `rec`: the chess record to draw instead of the data's (a 自选 piece's composed record — 0.2.0, gameLogic/diy.js) */ -export function UnitThumb({ kind = 'chess', id, golden, size = 'md', tier, showTier = true, class: cls, dim = false, badge = null, title }) { +export function UnitThumb({ kind = 'chess', id, golden, size = 'md', tier, showTier = true, class: cls, dim = false, badge = null, title, rec = null }) { const m = data.get('assets'); let src = null; let name = ''; let t = tier; if (kind === 'chess') { - const c = data.lookup('chess', id); + const c = rec || data.lookup('chess', id); src = chessAvatarUrl(m, c); name = c?.name || ''; t = t ?? c?.tier; diff --git a/public/js/ui/gameLogic.js b/public/js/ui/gameLogic.js index 4c62eca3..ff50239f 100644 --- a/public/js/ui/gameLogic.js +++ b/public/js/ui/gameLogic.js @@ -42,4 +42,5 @@ export { DEFAULT_SETTINGS, sanitizeSettings } from './gameLogic/settings.js'; export { normalizeResult } from './gameLogic/result.js'; export { chessLoadout, unitLoadout } from './gameLogic/loadout.js'; export { standInIds, fieldsStandIn, standInOf, cardStandIn, standInGetter, standInLoadout, deployedRecord, deployedModuleId, standInLabel } from './gameLogic/standIn.js'; +export { diyPicks, ownDiyPick, diyRecordFor, ownDiyRecord, cardDiy, diyGetter, pickGetter, isDiyRecord } from './gameLogic/diy.js'; export { panelSide, panelSlots, PANEL_RIGHT_GAP, PANEL_RIGHT_BOTTOM, PANEL_RIGHT_BOTTOM_SHOP, BPOP, bondPopupPlace } from './gameLogic/panel.js'; diff --git a/public/js/ui/gameLogic/bonds.js b/public/js/ui/gameLogic/bonds.js index c8e281fe..dca2ba9d 100644 --- a/public/js/ui/gameLogic/bonds.js +++ b/public/js/ui/gameLogic/bonds.js @@ -155,14 +155,18 @@ export function harmonyMembers(priv, getChess = () => null) { * operators that are members through 变形同构体 (grantedBonds; `granted: true` and `items`: the item ids of the copy that * wears the pair — the card the row opens shows them; one row per operator — normal and elite copies are one member, * like the count's distinct members), so "成员 x/y" agrees with the count the server reports (在场; memberHeadCount). + * 0.2.0 自选编队: the 自选 pieces of the bond (a DIY slot filled with an operator, whose bonds come from its factions — the + * own pieces through `getChess` (gameLogic/diy.js diyGetter), a teammate's through `pieceRecord` (its UnitInfo `diy` + * pick) are rows too (`diy: true`, the operator's name and record `rec`). * `inHand`: in the 整备区 (hand), not the 5 temporary slots — what BOARD_AND_DECK bonds (投资人 远见 奇迹) count. * @param {any} bond bonds.json record * @param {any} priv m.private (hand/board/temp) — or a teammate's field operators (ui/watchBonds.js ownerBoard) * @param {Set|string[]} [banned] banned base chess ids * @param {(id:string)=>any} [getChess] * @param {(id:string)=>any} [getItem] items.json lookup (the 变形同构体 pairings) + * @param {((p: any) => any)|null} [pieceRecord] the record of one of the pieces (default: getChess(p.id)) */ -export function bondMembers(bond, priv, banned = [], getChess = () => null, getItem = () => null) { +export function bondMembers(bond, priv, banned = [], getChess = () => null, getItem = () => null, pieceRecord = null) { const bannedSet = banned instanceof Set ? banned : new Set(Array.isArray(banned) ? banned : []); const members = Array.isArray(bond?.visibleMembers) && bond.visibleMembers.length ? bond.visibleMembers : (Array.isArray(bond?.members) ? bond.members : []); const baseOf = (id) => getChess(id)?.baseId || (typeof id === 'string' ? id.replace(/_b$/, '_a') : id); @@ -173,11 +177,22 @@ export function bondMembers(bond, priv, banned = [], getChess = () => null, getI /** base id → { on: on the board?, hand: in the hand?, items: the wearer's item ids } — operators of the player that join this bond through 变形同构体 */ const granted = new Map(); const grants = (p) => typeof bond?.bondId === 'string' && grantedBonds(p.items, getItem).includes(bond.bondId); + /** base id → { on, hand, rec } — the player's 自选 pieces whose operator carries this bond */ + const diy = new Map(); + const diyOf = (p, on, hand) => { + const rec = pieceRecord ? pieceRecord(p) : getChess(p.id); + if (!rec || typeof rec.diyFor !== 'string' || !Array.isArray(rec.bonds) || !rec.bonds.includes(bond?.bondId)) return false; + const g = diy.get(rec.diyFor); + if (!g) diy.set(rec.diyFor, { on, hand, rec }); + else { g.on = g.on || on; g.hand = g.hand || hand; } + return true; + }; const itemIds = (p) => p.items.map((it) => (typeof it === 'string' ? it : it?.id)).filter((x) => typeof x === 'string'); for (const p of Array.isArray(priv?.board) ? priv.board : []) { if (p?.kind !== 'chess') continue; const base = baseOf(p.id); onBoard.add(base); owned.add(base); + if (diyOf(p, true, false)) continue; if (!memberSet.has(base) && !granted.has(base) && grants(p)) granted.set(base, { on: true, hand: false, items: itemIds(p) }); } for (const [list, hand] of [[priv?.hand, true], [priv?.temp, false]]) { @@ -186,6 +201,7 @@ export function bondMembers(bond, priv, banned = [], getChess = () => null, getI const base = baseOf(p.id); owned.add(base); if (hand) inHand.add(base); + if (diyOf(p, false, hand)) continue; if (memberSet.has(base) || !grants(p)) continue; const g = granted.get(base); if (!g) granted.set(base, { on: false, hand, items: itemIds(p) }); @@ -200,6 +216,7 @@ export function bondMembers(bond, priv, banned = [], getChess = () => null, getI const c = getChess(id); rows.push({ id, tier: c?.tier ?? 0, name: c?.name ?? id, onBoard: g.on, owned: true, inHand: g.hand, banned: false, granted: true, items: g.items }); } + for (const [id, g] of diy) rows.push({ id, tier: g.rec.tier ?? 0, name: g.rec.name ?? id, onBoard: g.on, owned: true, inHand: g.hand, banned: false, diy: true, rec: g.rec }); return rows.sort((a, b) => (b.onBoard - a.onBoard) || (b.owned - a.owned) || (a.tier - b.tier) || (a.id < b.id ? -1 : 1)); } diff --git a/public/js/ui/gameLogic/diy.js b/public/js/ui/gameLogic/diy.js new file mode 100644 index 00000000..545c01f4 --- /dev/null +++ b/public/js/ui/gameLogic/diy.js @@ -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'; diff --git a/public/js/ui/loadoutSync.js b/public/js/ui/loadoutSync.js index fa4199b5..72abe481 100644 --- a/public/js/ui/loadoutSync.js +++ b/public/js/ui/loadoutSync.js @@ -1,23 +1,28 @@ -// Operator loadout + ownership state and server sync (DESIGN §16; 干员持有 — 0.2.0 补位). +// Operator loadout + ownership + 自选编队 state and server sync (DESIGN §16; 干员持有 — 0.2.0 补位; 自选编队 — 0.2.0 DIY). // // `loadoutStore` holds the per-browser loadout (`entries`, persisted in localStorage through store.js savePref), the -// per-browser not-owned list of the 干员持有 tab (`notOwned`, likewise) and the overlay's screen state (open / origin / -// tab / selection / filters). `installLoadoutSync()` / `installOwnershipSync()` (called once by main.js) keep the -// server's copies current — one sync engine (installPrefSync), two settings: after every `welcome` (new or resumed +// per-browser not-owned list of the 干员持有 tab (`notOwned`, likewise), the 自选编队 picks (`diy`, likewise; with +// `diyKitted` = the operators the server lets a DIY slot field, from every `welcome`) and the overlay's screen state +// (open / origin / tab / selection / filters). `installLoadoutSync()` / `installOwnershipSync()` / `installDiySync()` +// (called once by main.js) keep the server's copies current — one sync engine (installPrefSync), three settings: after +// every `welcome` (new or resumed // session — the server keeps them on the session and on the seat, so joining a room needs no resend) and after every // edit (debounced; a pending edit goes out at once when the overlay closes), they send `room.loadout { entries }` // (sanitised against the loaded data/chess.json: ui/loadoutModel.js sanitizeEntries — a stale entry is dropped, never -// the whole loadout) and `room.ownership { notOwned }` (sent as stored: the server keeps the droppable chess and drops -// the rest, so no data is needed). Replies: RATE → retried later; WRONG_PHASE / ROOM_STARTED → the running match keeps -// what it took (the loadout locks when INFO_CHECK ends; the ownership never changes during a match) — stored for the -// next match, not an error for the player; anything else is logged. `sync` / `ownSync` ∈ 'idle' | 'pending' | -// 'sending' | 'synced' | 'locked' | 'error' are mirrored into the store for the screen's status line. +// the whole loadout), `room.ownership { notOwned }` (sent as stored: the server keeps the droppable chess and drops +// the rest, so no data is needed) and `room.diy { picks }` (sent as stored, structurally clean: the server keeps the +// legal picks). Replies: RATE → retried later; WRONG_PHASE / ROOM_STARTED → the running match keeps what it took (the +// loadout locks when INFO_CHECK ends; the ownership and the 自选 picks never change during a match) — stored for the +// next match, not an error for the player; anything else is logged. `sync` / `ownSync` / `diySync` ∈ 'idle' | +// 'pending' | 'sending' | 'synced' | 'locked' | 'error' are mirrored into the store for the screen's status line. import { createStore, loadPref, savePref } from '../store.js'; import { data } from '../data.js'; import { LOADOUT_PREF, parseStored, toStored, sanitizeEntries } from './loadoutModel.js'; import { OWNERSHIP_PREF, parseStoredOwnership, toStoredOwnership, cleanIds, sanitizeNotOwned } from './ownershipModel.js'; +import { DIY_PREF, parseStoredDiy, toStoredDiy, cleanPicks, sanitizeDiyPicks } from './diyModel.js'; import { toast } from './toasts.js'; +import { t } from '../../../shared/i18n.js'; export const SYNC_DEBOUNCE_MS = 500; export const RETRY_MS = 1500; @@ -28,18 +33,24 @@ function readStored() { function readStoredOwnership() { try { return parseStoredOwnership(loadPref(OWNERSHIP_PREF, null)); } catch { return []; } } +function readStoredDiy() { + try { return parseStoredDiy(loadPref(DIY_PREF, null)); } catch { return {}; } +} /** Loadout + ownership + screen state (separate from the app store: it must survive room / match resets). */ export const loadoutStore = createStore({ entries: readStored(), notOwned: readStoredOwnership(), // 干员持有: base chess ids marked 未持有 (sorted; [] = every operator owned) + diy: readStoredDiy(), // 自选编队: { [slotBaseId]: { charId, skillIndex?, uniEquipId? } } ({} = every slot empty) + diyKitted: null, // the operators a DIY slot may field (welcome.diyKitted; null before the first welcome) open: false, from: null, // 'lobby' | 'room' | 'briefing' - tab: 'loadout', // 'loadout' (干员调配) | 'ownership' (干员持有) + tab: 'loadout', // 'loadout' (干员调配) | 'ownership' (干员持有) | 'diy' (自选编队) sel: null, // selected base chess id filters: { tier: null, prof: null, bond: null, query: '', changedOnly: false }, sync: 'idle', ownSync: 'idle', + diySync: 'idle', }); /** Replace the stored entries (persisted at once; the sync picks the change up). */ @@ -86,9 +97,30 @@ export function applyOwnershipImport(list, lookup) { return { applied: clean.length, dropped: Math.max(0, asked - clean.length) }; } +/** Replace the stored 自选编队 picks (persisted at once, the sync picks the change up). */ +export function setDiyPicks(picks) { + const next = cleanPicks(picks); + try { savePref(DIY_PREF, toStoredDiy(next)); } catch { /* private mode: the session keeps it */ } + loadoutStore.set({ diy: next }); +} + +/** + * Apply an imported roster (parseDiyImport(...).picks), sanitised against the loaded data and the kit list (the picks + * the server would keep), persisted and synced like an edit. An empty roster is a valid import (= every slot empty). + * @param {Record} picks @param {any} data `{ chess, backups }` @param {Iterable|null} kitted + * @returns {{ applied: number, dropped: number }} + */ +export function applyDiyImport(picks, data, kitted) { + const asked = Object.keys(cleanPicks(picks)).length; + const clean = sanitizeDiyPicks(picks, data, kitted); + setDiyPicks(clean); + const applied = Object.keys(clean).length; + return { applied, dropped: Math.max(0, asked - applied) }; +} + /** * Open the 干员调配 overlay. @param {'lobby'|'room'|'briefing'} from @param {string|null} [sel] - * @param {'loadout'|'ownership'|null} [tab] the tab to show (default: the last one) + * @param {'loadout'|'ownership'|'diy'|null} [tab] the tab to show (default: the last one) */ export function openLoadout(from = 'lobby', sel = null, tab = null) { data.load('chess'); @@ -96,7 +128,7 @@ export function openLoadout(from = 'lobby', sel = null, tab = null) { data.load('assets'); data.load('local'); data.load('backups'); - loadoutStore.set({ open: true, from, ...(sel ? { sel } : {}), ...(tab === 'loadout' || tab === 'ownership' ? { tab } : {}) }); + loadoutStore.set({ open: true, from, ...(sel ? { sel } : {}), ...(tab === 'loadout' || tab === 'ownership' || tab === 'diy' ? { tab } : {}) }); } export const closeLoadout = () => loadoutStore.set({ open: false }); @@ -107,7 +139,8 @@ export const closeLoadout = () => loadoutStore.set({ open: false }); */ function installPrefSync({ net, timers, target, notify, key, stateKey, msgType, field, prepare, lockedText, tag }) { const T = timers || { setTimeout: (fn, ms) => globalThis.setTimeout(fn, ms), clearTimeout: (id) => globalThis.clearTimeout(id) }; - const tell = notify || ((text) => toast(text, 'warn')); + // (`lockedText` is a msgid: the toast is translated when it shows — docs/I18N.md) + const tell = notify || ((text) => toast(t(text), 'warn')); let timer = null; let seq = 0; // requests sent (the reply of an older one never overrides a newer one's state) let pendingJson = null; // JSON of the newest request still awaiting its reply @@ -228,3 +261,24 @@ export function installOwnershipSync({ net, timers, target = loadoutStore, notif prepare: async () => cleanIds(target.get().notOwned), }); } + +/** + * Wire the 自选编队 sync once (0.2.0 DIY): `room.diy { picks }` after every welcome and edit. The picks go as stored + * (structurally clean); the server keeps the legal ones. During a match the server stores them for the next one + * (ROOM_STARTED → 'locked'): the setting is out of match. Every `welcome` also brings the operators a DIY slot may field + * (`diyKitted`, the picker's list). + * @param {{ net: any, timers?: { setTimeout: Function, clearTimeout: Function }, target?: ReturnType, + * 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(); } }; +} diff --git a/public/js/ui/shopBar.js b/public/js/ui/shopBar.js index 967bbdb2..b60fd627 100644 --- a/public/js/ui/shopBar.js +++ b/public/js/ui/shopBar.js @@ -19,14 +19,17 @@ // §16): the skill icon above the name, mint-framed with 已调配 in its title when it is not the default skill (and the // module type of an elite card, with its official type icon when the local-client art has it). A chess the player does // not own (0.2.0 补位, m.private.standIns) keeps its card — name, portrait, bonds, price — with an ice 「替补:X」 badge, -// and the skill icon is the stand-in's (its backup skill: the one that fights). +// and the skill icon is the stand-in's (its backup skill: the one that fights). A 自选 piece (0.2.0, m.private.diy — a DIY +// slot the player filled, only ever in its own shop) shows the operator — name, portrait, class, the bonds of its +// factions, the pick's skill and module — with a mint 「自选」 badge. import { useEffect, useState } from '../../vendor/hooks.module.js'; import { html, Icon, HexBadge, TierChip, Tooltip, MicroLabel } from './components.js'; import { Img, BondGlyph, CoinGlyph, GIcon, RichText } from './gameComponents.js'; -import { priceTone, mergeProgress, mergeTarget, shopBlockReason, chessLoadout, offerHeader, briefingBondTip, fieldsStandIn, standInOf, standInLoadout, standInLabel } from './gameLogic.js'; +import { priceTone, mergeProgress, mergeTarget, shopBlockReason, chessLoadout, offerHeader, briefingBondTip, fieldsStandIn, standInOf, standInLoadout, standInLabel, ownDiyRecord, diyGetter } from './gameLogic.js'; import { chessPortraitUrl, itemIconUrl, profIconUrl, uiUrl, skillIconUrl, skillRecordIconUrl, moduleTypeIconUrl } from './assetUrls.js'; import { data } from '../data.js'; +import { t } from '../../../shared/i18n.js'; const cx = (...p) => p.flat().filter(Boolean).join(' '); @@ -64,7 +67,11 @@ function ArmedTag({ reason, free }) { * onTap?:(idx:number)=>void, onBuy:Function, onDetail:Function }} props */ export function ChessCard({ slot, idx, priv, frozen = false, reason = null, free = false, armed = false, onTap = null, onBuy, onDetail, offBonds = null }) { - const c = data.lookup('chess', slot.id); + const c0 = data.lookup('chess', slot.id); + // 0.2.0 自选编队: a DIY slot the player filled is its operator (shared/diy.js, the pick of m.private.diy) + const diyData = { chess: data.get('chess'), backups: data.get('backups') }; + const dr = c0 ? ownDiyRecord(c0, priv, diyData) : null; + const c = dr || c0; const m = data.get('assets'); const tier = c?.tier ?? 1; const prog = mergeProgress(priv, slot.id, (id) => data.lookup('chess', id)); @@ -74,7 +81,7 @@ export function ChessCard({ slot, idx, priv, frozen = false, reason = null, free const disabled = !!reason; // 0.2.0 补位: the player's not-owned chess is deployed as its stand-in — badge + the stand-in's skill const si = c && fieldsStandIn(priv, c) ? standInOf(c, data.get('backups')) : null; - const lo = si ? standInLoadout(si, LOOKUPS.getChess, data.get('backups')) : c ? chessLoadout(c, priv?.loadout, LOOKUPS.getChess) : null; + const lo = si ? standInLoadout(si, LOOKUPS.getChess, data.get('backups')) : c ? chessLoadout(c, priv?.loadout, dr ? diyGetter(LOOKUPS.getChess, priv, diyData) : LOOKUPS.getChess) : null; const tap = () => { if (onTap) onTap(idx); else if (!disabled) onBuy(idx); else onDetail(slot.id, 'chess', hint); }; const card = html`