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}`);