diff --git a/server/lobby.js b/server/lobby.js index 4aea8ebd..ad5c9be7 100644 --- a/server/lobby.js +++ b/server/lobby.js @@ -64,6 +64,13 @@ // (or outside a room) it simply replaces the stored one; while the room's match runs it is also handed to // match.setLoadout(playerId, loadout), which accepts it only during INFO_CHECK (the 干员调配 entry of the briefing) // and refuses it afterwards (WRONG_PHASE: the match's loadout is locked, the stored one applies to the next match). +// * Operator ownership (干员持有, 0.2.0 补位, owner's decision 2026-10-05): room.ownership { notOwned } — the base chess +// ids the player marked as not owned — is checked leniently (shared/protocol.js checkNotOwned: anything that is not +// a droppable NORMAL chess is dropped, never the whole list; only a malformed list is BAD_MSG) and stored on the +// session and the seat like the loadout. The match receives seats[].notOwned when it starts (bots: none — they own +// every operator) and keeps it for its whole length: the setting is out of match ("局外设置,下一局生效"), so while +// the room's match runs a new list is only stored for the next match (ROOM_STARTED 'stored for the next match', +// never handed to the match). A spectator's list stays on its session. // * Spectator seats (community report #26, owner's decision 2026-10-04 — a remake feature, the official room has none): // room.spectate { code } takes one of a co-op room's MAX_SPECTATORS (2) spectator seats, in its lobby or while its // match runs (▸ solo rooms: ROOM_FULL). A spectator is not a player: never in `seats`, never counted for the 1–4 players @@ -71,15 +78,15 @@ // {empty} for its spectators). It receives room.state (`spectators: [{ playerId, name, connected }]`) and every match // broadcast (m.public, m.ticker, m.emote, b.pool — public data); the match registers it (opts.spectators / // addSpectator) and shows it fields like an eliminated player (b.start watch / m.field), never an m.private. It may -// only g.watch (the heavy bucket, like every watcher), g.leave / room.leave, and room.loadout (stored for its session, -// never handed to the match); anything else → SPECTATOR (▸ emotes too). Host: room.removeSpectator { playerId } any +// only g.watch (the heavy bucket, like every watcher), g.leave / room.leave, and room.loadout / room.ownership (stored +// for its session, never handed to the match); anything else → SPECTATOR (▸ emotes too). Host: room.removeSpectator { playerId } any // time → room.closed {kicked} to it. A spectator in a LOBBY room may take a free player seat with room.join of the same // code; a player never switches to spectating in place (ALREADY). Disconnect / grace / reconnect / expiry work as for // a player seat (the seat is kept and given back on resume). import { randomBytes, randomInt } from 'node:crypto'; import { ERR, MAX_SEATS, MAX_SPECTATORS, ROOM_CODE_LEN, modeIdFor } from '../shared/constants.js'; -import { checkLoadout } from '../shared/protocol.js'; +import { checkLoadout, checkNotOwned } from '../shared/protocol.js'; import { encode, isDroppable, isErrCode, sendRaw, sendSession } from './net.js'; import { getData as defaultGetData, lookup } from './data.js'; import { Match as DefaultMatch } from './match/Match.js'; @@ -109,7 +116,8 @@ const noopLog = { info() {}, warn() {}, error() {}, debug() {} }; /** * @typedef {{ seat: number, playerId: string, name: string, isBot: boolean, ready: boolean, - * connected: boolean, left: boolean, loadout?: Record | null }} Seat + * connected: boolean, left: boolean, loadout?: Record | null, + * notOwned?: readonly string[] | null }} Seat */ /** Deep-frozen copy of a checked loadout (shared by the session, the seat and the match's PlayerState). */ @@ -289,6 +297,7 @@ export class Lobby { case 'room.kick': return this.kick(session, msg); case 'room.start': return this.start(session); case 'room.loadout': return this.loadout(session, msg); + case 'room.ownership': return this.ownership(session, msg); case 'room.spectate': return this.spectate(session, msg); case 'room.removeSpectator': return this.removeSpectator(session, msg); default: @@ -575,6 +584,24 @@ export class Lobby { return OK; } + /** + * room.ownership (0.2.0 补位): keep the droppable chess of the not-owned list, store it on the session and the seat + * (see the header). A running match never takes it: it keeps the list its seat had at its start. + */ + ownership(session, { notOwned }) { + const data = this.safeData(); + const res = checkNotOwned(notOwned, (id) => lookup('chess', id, data)); + if (!res || res.error) return fail(ERR.BAD_MSG, res && res.detail); + const list = Object.freeze(res.notOwned.slice()); + session.notOwned = list; + const room = this.roomOf(session); + if (!room) return OK; + const seat = room.seatOf(session.playerId); + if (seat) seat.notOwned = list; + if (room.match && seat) return fail(ERR.ROOM_STARTED, 'stored for the next match'); + return OK; + } + // --------------------------------------------------------------------------------------------------- // Match wiring // --------------------------------------------------------------------------------------------------- @@ -587,6 +614,8 @@ export class Lobby { seat: s.seat, playerId: s.playerId, name: s.name, isBot: s.isBot, connected: s.connected, // DESIGN §16: the human's checked operator loadout (bots fight with the defaults) loadout: s.isBot ? null : s.loadout || null, + // 0.2.0 补位: the chess the human marked as not owned (bots own every operator) + notOwned: s.isBot ? null : s.notOwned || null, })); // lastPublic / results: the latest m.public broadcast and the m.result frames (encoded), kept for the replay. const ctx = { live: true, ended: false, disposed: false, match: null, lastPublic: null, sharedResult: null, results: new Map() }; @@ -845,6 +874,7 @@ export class Lobby { return { seat: idx, playerId: session.playerId, name: session.name, isBot: false, ready: false, connected: session.connected, left: false, loadout: session.loadout || null, + notOwned: session.notOwned || null, }; } diff --git a/server/match/Match.js b/server/match/Match.js index b9c33537..215e2954 100644 --- a/server/match/Match.js +++ b/server/match/Match.js @@ -13,11 +13,14 @@ // opts.difficulty 'FUNNY'|'NORMAL'|'HARD'|'ABYSS' // opts.modeId string modeIdFor(mode, difficulty), e.g. 'mode_multi_hard' // opts.seats Array<{ seat: 0..3, playerId: string, name: string, isBot: boolean, connected: boolean, -// loadout?: { [baseChessId]: { skill: index, module: uniEquipId|'none'|null } } | null }> +// loadout?: { [baseChessId]: { skill: index, module: uniEquipId|'none'|null } } | null, +// notOwned?: string[] | null }> // sorted by seat, 1–4 entries, ≥ 1 human; solo ⇒ exactly 1 human and no bots. // Bot playerIds start with 'ai_'. Seat indexes may have gaps (e.g. seats 0 and 2). // `loadout` (DESIGN §16, optional): the human's operator loadout, already checked by the lobby // (shared/protocol.js checkLoadout); PlayerState re-checks it against opts.data and ignores it for bots. +// `notOwned` (0.2.0 补位, optional): the base chess ids the human marked as not owned (干员持有) — fixed +// for the match; those chess fight as their stand-ins (PlayerState setNotOwned; bots own everything). // opts.spectators string[] (optional) the room's spectator seats (remake feature, community report #26; // server/lobby.js): never players — see addSpectator below // opts.seed uint32 master seed for all match randomness diff --git a/server/match/PlayerState.js b/server/match/PlayerState.js index 9a39c05b..20738af6 100644 --- a/server/match/PlayerState.js +++ b/server/match/PlayerState.js @@ -72,6 +72,15 @@ // mismatch falls back to the defaults) and kept frozen; bots always use the defaults. Match.setLoadout may replace // it during INFO_CHECK only. battleInput() resolves every chess unit to `skillIndex` + `moduleId` (resolveLoadout: // normal chess → moduleId null, elite → uniEquipId | 'none'); m.private exposes `loadout`. +// * Operator ownership (0.2.0 补位, the approved plan — owner's decision 2026-10-05): the human's `seat.notOwned` (base +// chess ids marked 未持有 on the 干员持有 screen) is re-checked against this match's data (shared/protocol.js +// checkNotOwned + a stand-in record in data/backups.json) and fixed for the match (`standIns`; bots own every +// operator). A piece of such a chess — normal or elite, wherever it is — keeps the chess's identity (name, bonds, +// 特质, tier, price, sell price, merge: every rule that reads gd.chess) and fights as its official stand-in +// (`fieldRecord` = gd.standIn: body, stats, range, position, skills / talents / module — the backup selection, the +// loadout never applies to it, "对于补位干员其技能不可更改"): battleInput marks it `standIn: true`, placeClass / +// summon range / the bots' range read the stand-in record, it makes no summons (none of the 17 stand-ins has one), +// and the scouting views draw it with the stand-in's art on the board. m.private exposes `standIns`. // // Code layout: this file keeps the constructor (the per-player state fields); the methods live in // server/match/player/, one module per concern, and are installed on PlayerState.prototype below in a fixed order, @@ -131,6 +140,14 @@ export class PlayerState { /** operator loadout (DESIGN §16): frozen { [baseChessId]: { skill, module } }, {} = every chess on its defaults */ this.loadout = Object.freeze({}); if (!this.isBot && seat.loadout) this.setLoadout(seat.loadout); + /** + * 补位 (0.2.0): the base chess ids this player fields as their stand-ins — the seat's not-owned list when the match + * started, re-checked against this match's data; frozen, sorted; [] = every operator owned (bots always) + */ + this.standIns = Object.freeze([]); + /** @type {Set} lookup set of `standIns` */ + this._standInSet = new Set(); + if (!this.isBot && seat.notOwned) this.setNotOwned(seat.notOwned); this.shop = { level: 1, upgradePrice: this.gd.upgradeBase(1) ?? 0, slots: [], frozen: false, freeRefreshes: 0 }; /** reward offers queue (merge rewards, special refreshes): { tier, source, label, slots: [{ kind, id, price, sold }] } */ this.offers = []; diff --git a/server/match/board.js b/server/match/board.js index 5bd8067f..1a0ffc07 100644 --- a/server/match/board.js +++ b/server/match/board.js @@ -147,19 +147,23 @@ export function positionClass(rec, moduleId = null) { /** * Placement class of a record under a player's loadout (the module `ps.loadoutFor` resolves, defaults included). - * Tokens and records without a chess id ignore the loadout. - * @param {{ loadoutFor?: (rec: object) => { moduleId?: string|null }|null }|null} ps + * Tokens and records without a chess id ignore the loadout. A chess the player fields as its stand-in (0.2.0 补位, + * `ps.fieldRecord`) is placed by the stand-in's position — the body that is deployed (圣约送葬人's 预备干员-先锋 stands on + * the ground, 塞雷娅's Touch anywhere) [ASSUMED: the official deploy check reads the fielded character]. + * @param {{ loadoutFor?: (rec: object) => { moduleId?: string|null }|null, fieldRecord?: (rec: object) => object }|null} ps */ export function placeClass(ps, rec) { if (!rec || !rec.chessId || !ps || typeof ps.loadoutFor !== 'function') return positionClass(rec); let moduleId = null; + let body = rec; try { const lo = ps.loadoutFor(rec); moduleId = lo && typeof lo.moduleId === 'string' ? lo.moduleId : null; + if (typeof ps.fieldRecord === 'function') body = ps.fieldRecord(rec) || rec; } catch { moduleId = null; } - return positionClass(rec, moduleId); + return positionClass(body, moduleId); } /** diff --git a/server/match/bot.js b/server/match/bot.js index f2c08750..b2ce2587 100644 --- a/server/match/bot.js +++ b/server/match/bot.js @@ -818,7 +818,9 @@ export function rangeRec(ps, rec) { if (cache.has(key)) return cache.get(key); let out = rec; try { - const g = attackRangeGrid(loadoutRecord(rec, resolveRecordLoadout(rec, lo))); + // 0.2.0 补位: a human's not-owned chess (AI 托管) is deployed as its stand-in — that body's range + const body = typeof ps.fieldRecord === 'function' ? ps.fieldRecord(rec) || rec : rec; + const g = attackRangeGrid(loadoutRecord(body, resolveRecordLoadout(body, lo))); if (Array.isArray(g) && g.length && JSON.stringify(g) !== JSON.stringify(rec.rangeGrid)) out = Object.freeze({ ...rec, rangeGrid: g }); } catch { out = rec; } cache.set(key, out); diff --git a/server/match/gamedata.js b/server/match/gamedata.js index cfb0d5aa..35ce1624 100644 --- a/server/match/gamedata.js +++ b/server/match/gamedata.js @@ -13,6 +13,7 @@ import { getConfig, getMode } from '../data.js'; import { isShopItem } from '../sim/simdata.js'; +import { standInRecord } from '../../shared/standIn.js'; const own = (map, id) => (map && typeof map === 'object' && typeof id === 'string' && Object.hasOwn(map, id) && map[id] && typeof map[id] === 'object' ? map[id] : null); const numOr = (v, d) => (typeof v === 'number' && Number.isFinite(v) ? v : d); @@ -95,6 +96,8 @@ export class GameData { this.inactiveEnemies = new Set(Array.isArray(this.mode.inactiveEnemyKeys) ? this.mode.inactiveEnemyKeys : []); /** data/tuning.json (titles only, see the header) */ this.tuning = this.raw.tuning && typeof this.raw.tuning === 'object' ? this.raw.tuning : {}; + /** standIn memo: chess id → composed 补位 record | null */ + this._standIns = new Map(); } /** @@ -175,6 +178,26 @@ export class GameData { get choices() { return this.raw.choices && typeof this.raw.choices === 'object' ? this.raw.choices : {}; } get factions() { return this.raw.factions && typeof this.raw.factions === 'object' ? this.raw.factions : {}; } + /** + * The 补位 record of chess `id` (normal or elite; DATA.md §18): shared/standIn.js standInRecord over data/backups.json + * — the chess's identity (ids, tier, bonds, 特质, price, merge) with its official stand-in's body (stats, range, skills + * with the backup skill as the default, talents, module, art, `standInFor`). Null for a PRESET / 自选 chess, an unknown + * id or data without backups.json. Memoized (frozen records). + * @param {string} id + * @returns {object|null} + */ + standIn(id) { + if (typeof id !== 'string') return null; + if (this._standIns.has(id)) return this._standIns.get(id); + const c = this.chess(id); + const backups = this.raw.backups && typeof this.raw.backups === 'object' ? this.raw.backups : null; + let rec; + try { rec = c && backups ? standInRecord(c, backups) : null; } catch { rec = null; } + if (rec) Object.freeze(rec); + this._standIns.set(id, rec); + return rec; + } + /** Normal (base) chess id of a chess id (golden → base). */ baseIdOf(id) { const c = this.chess(id); diff --git a/server/match/match/views.js b/server/match/match/views.js index 5336006a..d720b9d2 100644 --- a/server/match/match/views.js +++ b/server/match/match/views.js @@ -187,11 +187,14 @@ export class MatchViews { prepFieldMeta(ps) { const units = []; for (const { r, c, piece } of boardOrder(ps.board)) { - const rec = piece.kind === 'token' ? this.gd.token(piece.id) : this.gd.chess(piece.id); + const chess = piece.kind === 'token' ? null : this.gd.chess(piece.id); + // 0.2.0 补位: a chess this player fields as its stand-in is deployed with the stand-in's body — name, art, max HP, + // skill, like the sim's UnitInfo (`standInFor` = the replaced operator's charId) + const rec = piece.kind === 'token' ? this.gd.token(piece.id) : ps.fieldRecord(chess); const assets = (rec && rec.assets) || {}; // DESIGN §16: the skill / module THIS player's operator fights with (the scout's detail card shows it, like the // sim's UnitInfo in a shared field); moduleId only for an elite - const lo = piece.kind === 'chess' && rec ? ps.loadoutFor(rec) : null; + const lo = piece.kind === 'chess' && chess ? ps.loadoutFor(chess) : null; units.push({ id: piece.uid, uid: piece.uid, kind: piece.kind === 'token' ? 'token' : 'op', side: 'ally', ownerId: ps.playerId, defId: piece.id, name: rec ? rec.name : piece.id, tier: rec && Number.isInteger(rec.tier) ? rec.tier : 1, golden: !!(rec && rec.isGolden), @@ -201,6 +204,7 @@ export class MatchViews { moduleId: lo && typeof lo.moduleId === 'string' ? lo.moduleId : undefined, // the equipped items (like the sim's UnitInfo): a 变形同构体 wearer shows as a member of the bond it grants items: piece.kind === 'chess' && Array.isArray(piece.items) && piece.items.length ? piece.items.map((it) => it.id) : undefined, + standInFor: rec && rec.standInFor ? rec.standInFor : undefined, }); } // the hand (整备区) and the 临时整备区 scout exactly like the own prep bench renders them: pieces as units on @@ -208,10 +212,13 @@ export class MatchViews { // items included (the client draws their floating plates). PRTS 帮助 counts the temp area with the hand (review of // PR #129). Part of the meta for every watcher alike — the spectator seat's copy equals a teammate's // (test/match/spectator.test.js). User playtest #2 item 1 (GitHub #44). + // (a held chess the player fields as its stand-in keeps the chess's own art — the hand shows the original operator, + // the approved 0.2.0 补位 plan — and carries `standInFor`: its card shows the stand-in it deploys as) const benchUnit = (piece, i, y) => { const rec = piece.kind === 'item' ? this.gd.item(piece.id) : piece.kind === 'token' ? this.gd.token(piece.id) : this.gd.chess(piece.id); const assets = (rec && rec.assets) || {}; const lo = piece.kind === 'chess' && rec ? ps.loadoutFor(rec) : null; + const standIn = piece.kind === 'chess' && rec && ps.fieldsStandIn(rec) ? this.gd.standIn(rec.chessId) : null; units.push({ id: piece.uid, uid: piece.uid, kind: piece.kind === 'token' ? 'token' : piece.kind === 'item' ? 'item' : 'op', side: 'ally', ownerId: ps.playerId, defId: piece.id, @@ -221,6 +228,7 @@ export class MatchViews { skillIndex: lo && Number.isInteger(lo.skillIndex) ? lo.skillIndex : undefined, moduleId: lo && typeof lo.moduleId === 'string' ? lo.moduleId : undefined, items: piece.kind === 'chess' && Array.isArray(piece.items) && piece.items.length ? piece.items.map((it) => it.id) : undefined, + standInFor: standIn && standIn.standInFor ? standIn.standInFor : undefined, }); }; for (let i = 0; i < ps.hand.length; i++) { diff --git a/server/match/player/basics.js b/server/match/player/basics.js index 8603d50c..2d2e509e 100644 --- a/server/match/player/basics.js +++ b/server/match/player/basics.js @@ -1,13 +1,14 @@ // server/match/player/basics.js — PlayerState methods: the basics — seat flags and counts (isHumanActive, // botControlled, deployCap, deployCount, tempEmpty), the prep that resolves a temp piece (tempDue, _putTemp: every -// write into a temp slot), the operator loadout (DESIGN §16: setLoadout, loadoutFor), the deploy map on the field the +// write into a temp slot), the operator loadout (DESIGN §16: setLoadout, loadoutFor), the not-owned operators fielded +// as their stand-ins (0.2.0 补位: setNotOwned, fieldsStandIn, fieldRecord), the deploy map on the field the // player deploys on (Match.deployFieldOf) and the withdrawal of pieces a terrain / deploy-field change left on tiles // they may no longer occupy (_evictIllegal), dirty. // Installed on PlayerState.prototype by server/match/PlayerState.js (a method container: never instantiated; `this` is // the player state). import { PHASE } from '../../../shared/constants.js'; -import { checkLoadout, resolveLoadout } from '../../../shared/protocol.js'; +import { checkLoadout, checkNotOwned, resolveLoadout } from '../../../shared/protocol.js'; import { tileKey, boardOrder } from '../board.js'; export class PlayerBasics { @@ -73,11 +74,59 @@ export class PlayerBasics { return true; } - /** The skill index / module a chess record fights with under this player's loadout (DESIGN §16). */ + /** + * The skill index / module a chess record fights with under this player's loadout (DESIGN §16) — for a chess this + * player fields as its stand-in (0.2.0 补位) the stand-in's backup selection, whatever the loadout says. + */ loadoutFor(chessRecord) { + if (this.fieldsStandIn(chessRecord)) { + return resolveLoadout(null, this.gd.standIn(chessRecord.chessId), (id) => this.gd.standIn(id) || this.gd.chess(id)); + } return resolveLoadout(this.loadout, chessRecord, (id) => this.gd.chess(id)); } + /** + * Replace the not-owned list (0.2.0 补位; the seat's list at the match start — the setting never changes during a + * match): keeps the droppable chess (checkNotOwned) whose stand-in this match's data has. Bots own every operator. + * @param {any} list base chess ids + * @returns {boolean} false when the list is malformed (nothing changes) or the player is a bot + */ + setNotOwned(list) { + if (this.isBot) return false; + const res = checkNotOwned(list, (id) => this.gd.chess(id)); + if (!res || !res.ok) { + this.m.log?.warn?.(`[match ${this.m.roomCode}] not-owned list of ${this.playerId} ignored: ${res && res.detail}`); + return false; + } + const ids = res.notOwned.filter((id) => !!this.gd.standIn(id)); + this.standIns = Object.freeze(ids); + this._standInSet = new Set(ids); + return true; + } + + /** + * Whether this player's piece of chess record (or id) `rec` fights as its stand-in (0.2.0 补位): its base chess is + * in `standIns` and the data has the stand-in. Normal and elite alike (the elite uses the same backup). + * @param {object|string|null} rec + */ + fieldsStandIn(rec) { + if (!this._standInSet || this._standInSet.size === 0 || !rec) return false; + const r = typeof rec === 'string' ? this.gd.chess(rec) : rec; + if (!r || typeof r.chessId !== 'string') return false; + return this._standInSet.has(r.baseId || r.chessId) && !!this.gd.standIn(r.chessId); + } + + /** + * The record this player's piece of chess record `rec` fights with (0.2.0 补位): the stand-in record + * (gd.standIn — the chess's identity, the stand-in's body) when the player fields its stand-in, else `rec` itself. + * Rules about the unit's body read it (placement class, summon / bot ranges, the scouting art); rules about the chess + * (price, bonds, 特质, merges, pools) keep reading gd.chess. + * @param {object|null} rec + */ + fieldRecord(rec) { + return rec && this.fieldsStandIn(rec) ? this.gd.standIn(rec.chessId) || rec : rec; + } + /** * Deploy classes of the board tiles (server/match/board.js buildDeployMap) on the field the player deploys on now * (Match.deployFieldOf: the own board, or its half of the boss field in a boss round — user playtest #5 item 7). diff --git a/server/match/player/pieces.js b/server/match/player/pieces.js index acc048d1..2c18bb41 100644 --- a/server/match/player/pieces.js +++ b/server/match/player/pieces.js @@ -181,6 +181,8 @@ export class PlayerPieces { grantTokensFor(owner) { const rec = owner && owner.kind === 'chess' ? this.gd.chess(owner.id) : null; if (!rec) return; + // 0.2.0 补位: a chess fielded as its stand-in makes the stand-in's summons — none of the 17 has one (DATA.md §18) + if (this.fieldsStandIn(rec)) return; for (const { tokenId, count } of this.gd.placeableTokens(owner.id, this.loadoutFor(rec))) { const missing = count - this._tokenCountOf(owner.uid, tokenId); if (missing <= 0) continue; diff --git a/server/match/player/placement.js b/server/match/player/placement.js index 25263187..97d5ba71 100644 --- a/server/match/player/placement.js +++ b/server/match/player/placement.js @@ -66,9 +66,10 @@ export class PlayerPlacement { if (!piece || piece.kind !== 'token' || this.gd.token(piece.id)?.ownerRange !== true) return null; let at = owner; if (!at) for (const [key, p] of this.board) if (p.uid === piece.ownerUid && p.kind === 'chess') { at = { key, piece: p, dir: pieceDir(p) }; break; } - const rec = at && this.gd.chess(at.piece.id); - if (!rec) return null; - const grid = attackRangeGrid(loadoutRecord(rec, resolveRecordLoadout(rec, this.loadoutFor(rec)))) || rec.rangeGrid; + const chess = at && this.gd.chess(at.piece.id); + if (!chess) return null; + const rec = this.fieldRecord(chess); // 0.2.0 补位: a stand-in's own range (its backup selection) + const grid = attackRangeGrid(loadoutRecord(rec, resolveRecordLoadout(rec, this.loadoutFor(chess)))) || rec.rangeGrid; const [r, c] = parseKey(at.key); return ownerRangeKeys(grid, r, c, at.dir); } diff --git a/server/match/player/round.js b/server/match/player/round.js index 28d3870e..9f353f32 100644 --- a/server/match/player/round.js +++ b/server/match/player/round.js @@ -1,7 +1,7 @@ // server/match/player/round.js — PlayerState methods: the round lifecycle the match calls — startRound (income, pending // coins, the shop, summon stacks topped up), endPrep, eliminate (every copy back to the pool), recompute (legality, // out-of-range summons, temp pieces into free hand slots, bonds), the bond views — and battleInput (the player's -// PlayerBattleInput: board units with their loadout, carried 联防 state, the reached layers). +// PlayerBattleInput: board units with their loadout or their 补位 mark, carried 联防 state, the reached layers). // Installed on PlayerState.prototype by server/match/PlayerState.js (a method container: never instantiated; `this` is // the player state). @@ -102,10 +102,16 @@ export class PlayerRound { for (const { r, c, piece } of boardOrder(this.board)) { if (piece.kind === 'chess') { const u = { uid: piece.uid, kind: 'chess', chessId: piece.id, row: r, col: c, dir: pieceDir(piece), items: (piece.items || []).map((i) => i.id) }; - // DESIGN §16: the equipped skill / module (elite only) from the loadout (defaults when absent) - const lo = this.loadoutFor(this.gd.chess(piece.id)); - u.skillIndex = lo.skillIndex; - u.moduleId = lo.moduleId; + if (this.fieldsStandIn(piece.id)) { + // 0.2.0 补位: a chess this player does not own fights as its stand-in (sim getChess(id, { standIn: true }): the + // backup skill / module — no loadout fields, docs/SIM.md §12) + u.standIn = true; + } else { + // DESIGN §16: the equipped skill / module (elite only) from the loadout (defaults when absent) + const lo = this.loadoutFor(this.gd.chess(piece.id)); + u.skillIndex = lo.skillIndex; + u.moduleId = lo.moduleId; + } if (carry && carry.has(piece.uid)) u.carryState = carry.get(piece.uid); units.push(u); } else if (piece.kind === 'token') { diff --git a/server/match/player/views.js b/server/match/player/views.js index 8a97408a..f006800a 100644 --- a/server/match/player/views.js +++ b/server/match/player/views.js @@ -88,6 +88,9 @@ export class PlayerViews { nextEnemies: this.m.nextEnemiesFor(this), // DESIGN §16: the effective operator loadout ({ [baseChessId]: { skill, module } }; chess not listed use defaults) loadout: this.loadout, + // 0.2.0 补位: the base chess ids this player fields as their stand-ins in this match (the not-owned list the seat had + // at the match start; [] = every operator owned) — the client's 「替补:X」 badges, the stand-in's art and card + standIns: this.standIns, stats: { dmgDealt: Math.round(this.stats.dmgDealt), kills: this.stats.kills, leaks: this.stats.leaks, gold: this.stats.gold, refreshes: this.stats.refreshes, merges: this.stats.merges, diff --git a/server/net.js b/server/net.js index ed022df8..8189376c 100644 --- a/server/net.js +++ b/server/net.js @@ -60,10 +60,11 @@ export const NET_DEFAULTS = Object.freeze({ /** * Intents that also draw from the per-connection heavy bucket: g.watch (its reply is a large state resend, m.field), - * room.loadout (a ≤ 160-entry map validated against the game data; the client debounces its edits) and room.spectate + * room.loadout (a ≤ 160-entry map validated against the game data; the client debounces its edits), room.ownership + * (a ≤ 160-id list, the same way) and room.spectate * (taking a spectator seat in a running match resends its state like a watcher's g.watch — server/lobby.js spectate). */ -export const HEAVY_TYPES = new Set(['g.watch', 'room.loadout', 'room.spectate']); +export const HEAVY_TYPES = new Set(['g.watch', 'room.loadout', 'room.ownership', 'room.spectate']); /** Close codes (see header). */ export const CLOSE = Object.freeze({ REPLACED: 4001, HELLO_TIMEOUT: 4002, POLICY: 1008, SHUTDOWN: 1001 }); @@ -106,6 +107,8 @@ export class Session { this.resyncAt = -Infinity; /** @type {Record | null} checked operator loadout (lobby-owned, DESIGN §16) */ this.loadout = null; + /** @type {readonly string[] | null} checked not-owned chess ids (干员持有, lobby-owned, 0.2.0 补位) */ + this.notOwned = null; /** @type {string} client address of the latest connection (logging) */ this.addr = '?'; /** @type {string | null} per-network limit key of the latest connection (null = not limited), see clientAddress */ diff --git a/shared/protocol.js b/shared/protocol.js index 73bbb0ab..0634e46c 100644 --- a/shared/protocol.js +++ b/shared/protocol.js @@ -2,6 +2,7 @@ // Every client→server message is `{ t, rid?, ...fields }`. Unknown `t` or invalid fields ⇒ ERR.BAD_MSG. import { DIFFICULTIES, NAME_MAX_LEN, ROOM_CODE_LEN, MAX_SEATS, EMOTES, GEO } from './constants.js'; +import { isDroppableChess } from './standIn.js'; // ---- tiny validators ------------------------------------------------------- const isInt = (v, lo = -Infinity, hi = Infinity) => Number.isInteger(v) && v >= lo && v <= hi; @@ -171,6 +172,38 @@ export function resolveLoadout(loadout, chess, getChess) { return { skillIndex, moduleId }; } +// ---- operator ownership (干员持有, 0.2.0 补位): room.ownership { notOwned } ------------------------------------------- + +/** + * `room.ownership { notOwned }`: the base chess ids the player marked as not owned on the 干员持有 screen (the + * per-browser setting next to 干员调配; default: every operator owned ⇒ []). Such a chess keeps its identity (name, + * bonds, 特质, tier, price, merge) and fights as its official stand-in (shared/standIn.js standInRecord). Structural + * limit below; the semantic check (`checkNotOwned`) is LENIENT, unlike checkLoadout: an id that is not a droppable chess + * (unknown, elite, PRESET / 自选, a stale id of another build) is dropped, never the whole list. + */ +export const OWNERSHIP_LIMITS = Object.freeze({ notOwned: 160 }); +/** Structural check of `room.ownership.notOwned`: an array of ≤ 160 ids. */ +export const isNotOwnedList = (v) => isList(v, OWNERSHIP_LIMITS.notOwned, isId); + +/** + * Semantic check + normalisation of a not-owned list against the game data: keeps the ids of droppable chess + * (shared/standIn.js isDroppableChess — NORMAL base chess with a stand-in), deduplicated and sorted; drops the rest. + * Used by the server (lobby, match) and by the client before it sends or imports a list. + * @param {any} list `room.ownership.notOwned` + * @param {(id: string) => any} getChess chess record lookup + * @returns {{ ok: true, notOwned: string[], dropped: number } | { error: 'BAD_MSG', detail: string }} + */ +export function checkNotOwned(list, getChess) { + if (!isNotOwnedList(list)) return { error: 'BAD_MSG', detail: 'bad notOwned list' }; + const keep = new Set(); + for (const id of list) { + const c = typeof getChess === 'function' ? getChess(id) : null; + if (c && c.chessId === id && isDroppableChess(c)) keep.add(id); + } + const notOwned = [...keep].sort(); + return { ok: true, notOwned, dropped: list.length - notOwned.length }; +} + // ---- unit stats (user playtest #4 item 7): m.unitStats units and the browser battle's live stats --------------------- const fin = (v, d = 0) => (typeof v === 'number' && Number.isFinite(v) ? v : d); @@ -253,6 +286,9 @@ export const C2S = { 'room.start': {}, // operator loadout (DESIGN §16): stored per session/seat; accepted until the match leaves INFO_CHECK 'room.loadout': { entries: isLoadoutEntries }, + // operator ownership (干员持有, 0.2.0 补位): stored per session / seat; a match takes the list its seat had when it + // started (an out-of-match setting — during a match it is stored for the next one: ROOM_STARTED) + 'room.ownership': { notOwned: isNotOwnedList }, // spectator seats (remake feature, community report #26; MAX_SPECTATORS): take one of a co-op room's spectator seats — // in its lobby or while its match runs — never a player seat; the host frees one by playerId (the spectator gets // room.closed { reason: 'kicked' }). room.leave / g.leave leave a spectator seat like a player seat. diff --git a/shared/standIn.js b/shared/standIn.js index d5979158..366b9660 100644 --- a/shared/standIn.js +++ b/shared/standIn.js @@ -101,6 +101,20 @@ export function composeUnitRecord(identity, unit, form, { skillIndex, moduleId = return out; } +/** + * Whether a player may mark chess record `chess` as not owned (干员持有 → 下掉, the approved 0.2.0 补位 plan, owner's + * decision 2026-10-05): a NORMAL base chess (not the elite, not a 自选 slot) whose official stand-in is another + * character — the 55 chess of DATA.md §18 (53 of them in this season's shop; the hidden 百炼嘉维尔 t5 / 妮芙 t6 forms are + * retired). A PRESET (特许) chess always fields its own operator ("预设棋子…无论是否持有都由本人上场"). + * @param {any} chess data/chess.json record + */ +export function isDroppableChess(chess) { + const b = chess && typeof chess === 'object' ? chess.backup : null; + return !!(b && !chess.isGolden && !chess.isDiy && chess.chessType === 'NORMAL' + && (!chess.baseId || chess.baseId === chess.chessId) + && typeof b.charId === 'string' && b.charId && b.charId !== chess.charId); +} + /** * The 补位 record of a chess: a NORMAL chess (normal or elite) fielded as its official stand-in — `backup.charId` at * the chess's own status, skill `backup.skillIndex`, module `backup.uniEquipId` (none when null), potential 0 — with diff --git a/test/lobby-ownership.test.js b/test/lobby-ownership.test.js new file mode 100644 index 00000000..6d3fde58 --- /dev/null +++ b/test/lobby-ownership.test.js @@ -0,0 +1,135 @@ +// test/lobby-ownership.test.js — room.ownership end to end over WebSocket (0.2.0 补位, 干员持有): lenient validation +// (only a malformed list is refused; non-droppable ids are dropped), storage on the session (follows the player into +// rooms, survives a resume) and on the seat, seats[].notOwned handed to the Match when it starts (bots: none), never to a +// running match (ROOM_STARTED: stored for the next one — the setting is out of match), a spectator's kept on its session, +// and the real match exposing it as m.private.standIns. +import { describe, test, before, after, afterEach } from 'node:test'; +import assert from 'node:assert/strict'; + +import { startServer } from '../server/index.js'; +import { StubMatch } from '../server/match/StubMatch.js'; +import { TestClient } from './helpers/wsClient.js'; +import { ERR, PHASE } from '../shared/constants.js'; + +const SILVER = 'chess_char_4_22_a'; // 银灰 (NORMAL) +const SARIA = 'chess_char_5_11_a'; // 塞雷娅 (NORMAL) +const CATHY = 'chess_char_4_11_a'; // 凯瑟琳 (PRESET) + +class RecordingStub extends StubMatch { + static instances = []; + constructor(opts) { super(opts); this.opts = opts; RecordingStub.instances.push(this); } +} + +function clientPool(getUrl) { + const open = new Set(); + return { + async player(name, token) { + const c = await TestClient.connect(getUrl()); + open.add(c); + const w = await c.hello(name, token); + c.id = w.playerId; + c.token = w.token; + return c; + }, + async closeAll() { + await Promise.all([...open].map((c) => c.terminate().catch(() => {}))); + open.clear(); + }, + }; +} +const quietLog = () => { + const errors = []; + return { errors, log: { info() {}, warn() {}, debug() {}, error: (...a) => errors.push(a.map(String).join(' ')) } }; +}; +const ok = async (c, msg) => { const r = await c.request(msg); assert.equal(r.t, 'ok', `${msg.t}: ${JSON.stringify(r)}`); return r; }; +const err = async (c, msg, code) => { const r = await c.request(msg); assert.equal(r.t, 'error', JSON.stringify(r)); assert.equal(r.code, code, JSON.stringify(r)); return r; }; +async function createRoom(c, mode = 'coop', difficulty = 'NORMAL') { + await ok(c, { t: 'room.create', mode, difficulty }); + return c.waitFor('room.state', (s) => s.hostId === c.id && s.mode === mode); +} + +describe('room.ownership (lobby, stub match)', () => { + let srv; + let pool; + const cap = quietLog(); + before(async () => { + srv = await startServer({ port: 0, host: '127.0.0.1', log: cap.log, MatchClass: RecordingStub, heavyBurst: 12, heavyPerSec: 4 }); + pool = clientPool(() => `ws://127.0.0.1:${srv.port}/ws`); + }); + afterEach(async () => { RecordingStub.instances = []; await pool.closeAll(); }); + after(async () => { await srv?.close(); }); + + test('lenient: a malformed list is BAD_MSG (nothing stored); other ids are dropped; stored per session and seat; bots none', async () => { + const host = await pool.player('Host'); + await err(host, { t: 'room.ownership', notOwned: { [SILVER]: true } }, ERR.BAD_MSG); + await err(host, { t: 'room.ownership', notOwned: ['bad id'] }, ERR.BAD_MSG); + // outside a room: accepted (PRESET / elite / unknown ids dropped), kept on the session + await ok(host, { t: 'room.ownership', notOwned: [SARIA, CATHY, 'chess_char_9_99_a', `${SILVER.slice(0, -1)}b`, SILVER, SILVER] }); + const st = await createRoom(host); + const guest = await pool.player('Guest'); + await ok(guest, { t: 'room.join', code: st.code }); + await guest.waitFor('room.state', (s) => s.seats.some((x) => x && x.playerId === guest.id)); + await ok(guest, { t: 'room.ownership', notOwned: [SARIA] }); + await ok(guest, { t: 'room.ownership', notOwned: [SILVER] }); // re-sent: the latest wins + await ok(host, { t: 'room.addBot' }); + // the guest drops and resumes: the session keeps its list + await guest.terminate(); + const back = await TestClient.connect(`ws://127.0.0.1:${srv.port}/ws`); + await back.hello('Guest', guest.token); + await ok(back, { t: 'room.ready', ready: true }); + await ok(host, { t: 'room.start' }); + await host.waitFor('m.public', (p) => p.phase === 'INFO_CHECK'); + const seats = RecordingStub.instances.at(-1).opts.seats; + const by = (id) => seats.find((s) => s.playerId === id); + assert.deepEqual(by(host.id).notOwned, [SILVER, SARIA].sort()); + assert.ok(Object.isFrozen(by(host.id).notOwned)); + assert.deepEqual(by(guest.id).notOwned, [SILVER]); + assert.equal(seats.find((s) => s.isBot).notOwned, null, 'bots own every operator'); + // during the match: refused for this match (ROOM_STARTED) but stored for the next one + const r = await err(host, { t: 'room.ownership', notOwned: [] }, ERR.ROOM_STARTED); + assert.match(r.detail || '', /next match/); + await back.terminate(); + assert.deepEqual(cap.errors, []); + }); + + test('a spectator\'s list stays on its session (never on a seat, never handed to the match)', async () => { + const host = await pool.player('Host2'); + const st = await createRoom(host); + const spec = await pool.player('Watcher'); + await ok(spec, { t: 'room.spectate', code: st.code }); + await spec.waitFor('room.state', (s) => (s.spectators || []).some((x) => x.playerId === spec.id)); + await ok(spec, { t: 'room.ownership', notOwned: [SILVER] }); + await ok(host, { t: 'room.start' }); + await host.waitFor('m.public', (p) => p.phase === 'INFO_CHECK'); + const seats = RecordingStub.instances.at(-1).opts.seats; + assert.ok(!seats.some((s) => s.playerId === spec.id)); + assert.equal(seats[0].notOwned, null); + }); +}); + +describe('room.ownership (lobby + real match)', () => { + let srv; + let pool; + const cap = quietLog(); + before(async () => { + srv = await startServer({ port: 0, host: '127.0.0.1', log: cap.log, seedFn: () => 4242 }); + pool = clientPool(() => `ws://127.0.0.1:${srv.port}/ws`); + }); + afterEach(async () => { await pool.closeAll(); }); + after(async () => { await srv?.close(); }); + + test('the list the seat had at the start is the match\'s m.private.standIns; a change during the briefing waits for the next match', async () => { + const a = await pool.player('Solo'); + await ok(a, { t: 'room.ownership', notOwned: [SILVER] }); + await createRoom(a, 'solo'); + await ok(a, { t: 'room.start' }); + await a.waitFor('m.public', (p) => p.phase === PHASE.INFO_CHECK, 5000); + const priv = await a.waitFor('m.private', (p) => Array.isArray(p.standIns), 3000); + assert.deepEqual(priv.standIns, [SILVER]); + // stored for the next match only: this match keeps [SILVER] (PlayerState.standIns is fixed; test/match/standin-ownership) + await err(a, { t: 'room.ownership', notOwned: [SARIA] }, ERR.ROOM_STARTED); + await ok(a, { t: 'g.infoReady' }); + await a.waitFor('m.public', (p) => p.phase === PHASE.BAND_DRAFT, 3000); + assert.deepEqual(cap.errors, []); + }); +}); diff --git a/test/match/standin-ownership.test.js b/test/match/standin-ownership.test.js new file mode 100644 index 00000000..2a0da69b --- /dev/null +++ b/test/match/standin-ownership.test.js @@ -0,0 +1,230 @@ +// test/match/standin-ownership.test.js — 0.2.0 补位 end to end on the server (the approved plan, owner's decision +// 2026-10-05): the not-owned list (room.ownership → seats[].notOwned) is checked leniently, fixed for the match and +// applied to that player's own pieces only — the chess keeps its identity (name, bonds, price, merge) and its battle +// unit is the official stand-in (PlayerBattleInput `standIn: true`: the stand-in's body, skill, talents, module, range, +// position), in the player's own field, the 联防 field and the boss fields alike, client-run and server-run identical. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { C2S, validateC2S, checkNotOwned, OWNERSHIP_LIMITS, checkLoadout } from '../../shared/protocol.js'; +import { isDroppableChess, standInRecord } from '../../shared/standIn.js'; +import { buildBattleSpec, createBattleFromSpec } from '../../server/sim/spec.js'; +import { DataSource, hasGeneratedData } from '../../server/sim/simdata.js'; +import { uniteBattleOpts } from '../../server/match/unite.js'; +import { placeClass } from '../../server/match/board.js'; +import { PHASE, GEO } from '../../shared/constants.js'; +import { DATA, makeMatch, give, legalTileFor } from './harness.js'; + +const REAL = { skip: !hasGeneratedData() }; +const chess = (id) => (Object.hasOwn(DATA.chess, id) ? DATA.chess[id] : null); +const wire = (msg) => JSON.parse(JSON.stringify(msg)); + +const SILVER = 'chess_char_4_22_a'; // 银灰 (NORMAL) → Sharp S2, melee +const MLYSS = 'chess_char_6_11_a'; // 缪尔赛思 (NORMAL, RANGED, summons 流形) → 郁金香 S3 + SOL-X, melee +const SARIA = 'chess_char_5_11_a'; // 塞雷娅 (NORMAL, MELEE) → Touch S3 + PHY-X, ranged +const CATHY = 'chess_char_4_11_a'; // 凯瑟琳 (PRESET): never droppable +const INSIDE = 'chess_char_1_01_a'; // 隐现 (PRESET) with a loadout choice + +test('droppable chess = the 55 NORMAL base chess; checkNotOwned keeps those, drops the rest; the wire validator is structural', () => { + const all = Object.values(DATA.chess); + const drop = all.filter(isDroppableChess); + assert.equal(drop.length, 55); + assert.ok(drop.every((c) => c.chessType === 'NORMAL' && !c.isGolden && c.backup.charId !== c.charId)); + assert.equal(all.filter((c) => c.chessType === 'PRESET' && isDroppableChess(c)).length, 0, 'PRESET always fields its own operator'); + assert.equal(drop.filter((c) => c.visible).length, 53, '53 in this season\'s shop (2 retired forms are hidden)'); + const res = checkNotOwned([SARIA, SILVER, SILVER, CATHY, chess(SILVER).goldenId, 'chess_char_9_99_a', 'chess_diy_5_1_a', MLYSS], chess); + assert.deepEqual(res, { ok: true, notOwned: [SILVER, SARIA, MLYSS].sort(), dropped: 5 }); + assert.deepEqual(checkNotOwned([], chess), { ok: true, notOwned: [], dropped: 0 }); + for (const bad of [null, {}, 'x', [1], ['a b'], new Array(OWNERSHIP_LIMITS.notOwned + 1).fill(SILVER)]) { + assert.equal(checkNotOwned(bad, chess).error, 'BAD_MSG', JSON.stringify(bad)?.slice(0, 40)); + } + assert.ok(C2S['room.ownership']); + assert.equal(validateC2S({ t: 'room.ownership', notOwned: [SILVER, 'whatever_id'] }), null); + assert.match(validateC2S({ t: 'room.ownership', notOwned: { a: 1 } }), /bad field notOwned/); + assert.match(validateC2S({ t: 'room.ownership' }), /bad field notOwned/); +}); + +/** A co-op match: p_0 does not own 银灰 / 缪尔赛思 / 塞雷娅 (+ junk), p_1 owns everything, the bot never drops. */ +function ownershipMatch(extra = {}) { + const loadout = checkLoadout({ [INSIDE]: { skill: 0 } }, chess).loadout; + const seats = [ + { seat: 0, playerId: 'p_0', name: 'P0', isBot: false, connected: true, loadout, notOwned: [SILVER, MLYSS, SARIA, CATHY, 'junk_id'] }, + { seat: 1, playerId: 'p_1', name: 'P1', isBot: false, connected: true }, + { seat: 2, playerId: 'ai_0', name: 'AI0', isBot: true, connected: true, notOwned: [SILVER] }, + ]; + return makeMatch({ mode: 'coop', seats, seed: 11, ...extra }); +} +function clearBoard(ps) { + for (const p of [...ps.board.values()]) ps.returnCopies(p); + ps.board.clear(); + for (let i = 0; i < ps.hand.length; i++) if (ps.hand[i]) { ps.returnCopies(ps.hand[i]); ps.hand[i] = null; } + ps.recompute(); +} + +test('seats[].notOwned → PlayerState.standIns (re-checked, frozen; bots own everything); m.private.standIns', REAL, () => { + const h = ownershipMatch().start(); + const p0 = h.ps('p_0'); + assert.deepEqual([...p0.standIns], [SILVER, SARIA, MLYSS].sort()); + assert.ok(Object.isFrozen(p0.standIns)); + assert.deepEqual([...h.ps('p_1').standIns], []); + assert.deepEqual([...h.ps('ai_0').standIns], [], 'bots own every operator'); + h.m.flush(true); + assert.deepEqual(h.lastTo('p_0', 'm.private').standIns, [SILVER, SARIA, MLYSS].sort()); + assert.deepEqual(h.lastTo('p_1', 'm.private').standIns, []); + // a malformed list (stale client) changes nothing; a later call cannot happen through the protocol during a match + assert.equal(p0.setNotOwned({ bad: true }), false); + assert.equal(p0.standIns.length, 3); + h.m.dispose(); +}); + +test('a not-owned chess keeps its identity and fights as the stand-in: backup selection over the loadout, the stand-in\'s position, no summons', REAL, () => { + const h = ownershipMatch().start(); + const m = h.m; + h.toPrep(1); + const p0 = h.ps('p_0'); + const p1 = h.ps('p_1'); + for (const [id, body, skill] of [[SILVER, 'char_609_acguad', 1], [chess(SILVER).goldenId, 'char_609_acguad', 1], [MLYSS, 'char_608_acpion', 2]]) { + const rec = chess(id); + assert.ok(p0.fieldsStandIn(rec) && p0.fieldsStandIn(id), id); + assert.ok(!p1.fieldsStandIn(rec), 'only the player who dropped it'); + const f = p0.fieldRecord(rec); + assert.equal(f.charId, body); + assert.equal(f.standInFor, rec.charId); + assert.deepEqual([f.chessId, f.tier, f.price, f.bonds], [rec.chessId, rec.tier, rec.price, rec.bonds], 'identity kept'); + assert.equal(p0.loadoutFor(rec).skillIndex, skill, 'the backup skill'); + assert.equal(p1.fieldRecord(rec), rec); + } + // the elite's module is the backup's (郁金香 SOL-X on 缪尔赛思's elite), never the player's loadout + assert.equal(p0.loadoutFor(chess(chess(MLYSS).goldenId)).moduleId, chess(chess(MLYSS).goldenId).backup.uniEquipId); + assert.equal(p0.loadoutFor(chess(INSIDE)).skillIndex, 0, 'an owned chess keeps its loadout'); + // placement class = the deployed body's position (缪尔赛思 RANGED → 郁金香 melee; 塞雷娅 MELEE → Touch ranged) + assert.equal(placeClass(p1, chess(MLYSS)), 'ranged'); + assert.equal(placeClass(p0, chess(MLYSS)), 'melee'); + assert.equal(placeClass(p1, chess(SARIA)), 'melee'); + assert.equal(placeClass(p0, chess(SARIA)), 'ranged'); + // summons: an owned 缪尔赛思 on the board sends her 流形 to the hand; a stand-in makes none (none of the 17 has one) + clearBoard(p0); + clearBoard(p1); + const t0 = legalTileFor(m, p0, MLYSS); + const t1 = legalTileFor(m, p1, MLYSS); + give(m, p0, MLYSS, 'board', t0); + give(m, p1, MLYSS, 'board', t1); + const tokens = (ps) => [...ps.hand, ...ps.temp].filter((p) => p && p.kind === 'token').map((p) => p.id); + assert.deepEqual(tokens(p1), ['token_10030_mlyss_wtrman']); + assert.deepEqual(tokens(p0), []); + // identity rules still read the chess: sell price, bonds + assert.equal(p0.gd.sellPrice(MLYSS), chess(MLYSS).sellPrice); + for (const b of chess(MLYSS).bonds) assert.ok(p0.bonds[b] && p0.bonds[b].count >= 1, `bond ${b} counts the stand-in`); + h.invariants(); + m.dispose(); +}); + +/** The browser's DataSource (battle/runner.js loadBrowserSim): own JSON copies of the data files, backups included. */ +function browserSource() { + const copy = (v) => JSON.parse(JSON.stringify(v)); + return new DataSource({ chess: copy(DATA.chess), enemies: copy(DATA.enemies), tokens: copy(DATA.tokens), stages: copy(DATA.stages), waves: copy(DATA.waves), backups: copy(DATA.backups) }, null); +} + +test('battleInput: the player\'s not-owned chess carries standIn: true (no loadout fields); client-run and server-run battles are identical', REAL, () => { + const h = ownershipMatch().start(); + const m = h.m; + h.toPrep(1); + const p0 = h.ps('p_0'); + clearBoard(p0); + const sg = chess(SILVER).goldenId; + const a = give(m, p0, sg, 'board', legalTileFor(m, p0, sg)); + const b = give(m, p0, INSIDE, 'board', legalTileFor(m, p0, INSIDE)); + const c = give(m, p0, SARIA, 'board', legalTileFor(m, p0, SARIA)); + const input = p0.battleInput(); + const by = new Map(input.units.map((u) => [u.uid, u])); + assert.equal(by.get(a.uid).standIn, true); + assert.ok(!('skillIndex' in by.get(a.uid)) && !('moduleId' in by.get(a.uid)), 'a stand-in carries no loadout fields'); + assert.equal(by.get(c.uid).standIn, true); + assert.ok(!('standIn' in by.get(b.uid))); + assert.equal(by.get(b.uid).skillIndex, 0, 'the owned chess keeps its loadout'); + // the production spec path keeps the mark; both data sources field the same stand-in and fight the same battle + const wave = m.wave; + const spec = wire(buildBattleSpec({ + battleId: 't.1', fieldId: 'n:p_0', kind: 'normal', seed: 77, modeId: m.modeId, round: 1, stageId: m.stageId, + rect: { ...GEO.NORMAL_RECT }, timeLimit: 40, players: [input], spawns: wave.spawns.map((s) => ({ ...s, ownerPlayerId: 'p_0' })), + routes: wave.routes, flags: { layerGainsEnabled: true, ...m.gd.dp }, enemyOverrides: wave.overrides ?? {}, content: 'full', + })); + assert.equal(spec.players[0].units.find((u) => u.uid === a.uid).standIn, true); + const run = (ds) => { + const bt = createBattleFromSpec(wire(spec), ds, { quiet: true, recordEvents: false }); + const u = bt.allyUnits.find((x) => x.uid === a.uid); + const def = { charId: u.def.charId, standInFor: u.def.standInFor, skill: u.def.skill?.id, bonds: u.def.bonds, name: u.name }; + let n = 0; + while (!bt.finished && n++ < 30 * 45) bt.step(); + const res = bt.result(); + return { def, time: res.time, killed: res.killed, units: bt.allyUnits.map((x) => [x.uid, x.defId, Math.round(x.hp * 100), x.stats?.dmg | 0, x.skill?.activations ?? null]), rng: bt.rng.state() }; + }; + const server = run(m.ds); + const client = run(browserSource()); + assert.equal(server.def.charId, 'char_609_acguad'); + assert.equal(server.def.standInFor, chess(sg).charId); + assert.equal(server.def.skill, standInRecord(chess(sg), DATA.backups).skill.skillId); + assert.deepEqual(server.def.bonds, chess(sg).bonds); + assert.deepEqual(client, server, 'the browser\'s battle equals the server\'s'); + m.dispose(); +}); + +test('联防 and the Final Assault field carry each player\'s own marks (p_0 stand-in, p_1 the real operator)', REAL, () => { + const h = ownershipMatch({ fake: true, clientCombat: true, clients: false }).start(); + const m = h.m; + // 联防: the helpers' inputs (unite.js uniteBattleOpts → PlayerState.battleInput with carry / reached) + h.toPrep(1); + const p0 = h.ps('p_0'); + const p1 = h.ps('p_1'); + clearBoard(p0); + clearBoard(p1); + const a0 = give(m, p0, SILVER, 'board', legalTileFor(m, p0, SILVER)); + const a1 = give(m, p1, SILVER, 'board', legalTileFor(m, p1, SILVER)); + const { players } = uniteBattleOpts(m, { helpers: [p0, p1], leaked: [] }, 60); + const u0 = players.find((p) => p.playerId === 'p_0').units.find((u) => u.uid === a0.uid); + const u1 = players.find((p) => p.playerId === 'p_1').units.find((u) => u.uid === a1.uid); + assert.equal(u0.standIn, true); + assert.ok(!('standIn' in u1) && Number.isInteger(u1.skillIndex)); + m.dispose(); + + // the boss field: jump to the Final Assault prep, deploy, and read the field's BattleSpec + const hb = ownershipMatch({ fake: true, clientCombat: true, clients: false }); + const mb = hb.m; + const orig = mb.startRound.bind(mb); + let jumped = false; + mb.startRound = (r) => { if (!jumped) { jumped = true; return orig(mb.gd.bossRound); } return orig(r); }; + hb.start(); + hb.toPrep(mb.gd.bossRound); + const b0 = hb.ps('p_0'); + const b1 = hb.ps('p_1'); + clearBoard(b0); + clearBoard(b1); + const s0 = give(mb, b0, SARIA, 'board', legalTileFor(mb, b0, SARIA)); + const s1 = give(mb, b1, SARIA, 'board', legalTileFor(mb, b1, SARIA)); + hb.drive(() => mb.phase === PHASE.FINAL_ASSAULT); + assert.equal(mb.phase, PHASE.FINAL_ASSAULT); + const units = mb.fields.flatMap((f) => (f.spec ? f.spec.players : [])).flatMap((p) => p.units.map((u) => ({ ...u, owner: p.playerId }))); + const f0 = units.find((u) => u.uid === s0.uid); + const f1 = units.find((u) => u.uid === s1.uid); + assert.ok(f0 && f1, 'both deployed on the boss field'); + assert.equal(f0.standIn, true); + assert.ok(!('standIn' in f1)); + mb.dispose(); +}); + +test('prep scouting (m.field): a teammate\'s stand-in shows the stand-in\'s art on the board, the original in the hand (standInFor on both)', REAL, () => { + const h = ownershipMatch().start(); + const m = h.m; + h.toPrep(1); + const p0 = h.ps('p_0'); + clearBoard(p0); + const onBoard = give(m, p0, SILVER, 'board', legalTileFor(m, p0, SILVER)); + const held = give(m, p0, MLYSS, 'hand'); + assert.deepEqual(m.handle('p_1', { t: 'g.watch', fieldId: 'n:p_0' }), { ok: true }); + const meta = wire(h.lastTo('p_1', 'm.field')); + const by = new Map(meta.units.map((u) => [u.uid, u])); + const bu = by.get(onBoard.uid); + assert.deepEqual([bu.defId, bu.name, bu.spine, bu.avatar, bu.standInFor, bu.skillIndex], [SILVER, 'Sharp', 'char_609_acguad', 'char_609_acguad', chess(SILVER).charId, 1]); + const hu = by.get(held.uid); + assert.deepEqual([hu.defId, hu.name, hu.spine, hu.standInFor], [MLYSS, chess(MLYSS).name, chess(MLYSS).assets.spine, chess(MLYSS).charId]); + m.dispose(); +});