feedback5: merge W5G — 补位 end to end: the 干员持有 tab (drop NORMAL chess), room.ownership, stand-ins in every field, 「替补:X」 badges, the stand-in's body on the board and detail card, golden standins family
Golden regenerated on the merged code (the textual merge had no conflict and equals the regenerated files): against feedback5 only the new match coop2-NORMAL-14-standins and the new standins.json (10 battles); against fb5-standin-game exactly the W5F and W5J scenarios (roster-001/002/007/009/016/026/027/028/038/040/041/042/044, bond-egirShip-low / siracusaShip-high / suntShip-high, boss-boss_2-pair / boss_6-pair, solo-NORMAL-1, coop2-NORMAL-3, coop3-ABYSS-5, coop4-FUNNY-6, coop2-NORMAL-8-serverrun); standins unchanged. test/match 536/536, the stand-in / golden / docs suites 175/175.
This commit is contained in:
commit
ce0d85c78a
51 files changed
+2741
-188
No files matched your search
+34
-4
@@ -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<string, { skill: number, module: string|null }> | null }} Seat
|
||||
* connected: boolean, left: boolean, loadout?: Record<string, { skill: number, module: string|null }> | 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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<string>} 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 = [];
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+3
-1
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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++) {
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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,
|
||||
|
||||
+5
-2
@@ -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<string, { skill: number, module: string|null }> | 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 */
|
||||
|
||||
@@ -104,8 +104,11 @@ export default {
|
||||
A NORMAL chess whose operator the player does not own fights as its official stand-in (原型干员, data/backups.json,
|
||||
docs/DATA.md §18): the chess's ids, bonds, 特质, tier and price, the stand-in's body — stats, range, trait, talents, the
|
||||
skill `backup.skillIndex` and the module `backup.uniEquipId` the chess names. The sim gets that def from
|
||||
`battle.data.getChess(chessId, { standIn: true })` (a PlayerBattleInput entry with `standIn: true`); `def.charId` is the
|
||||
stand-in's, `def.standInFor` the replaced operator's charId.
|
||||
`battle.data.getChess(chessId, { standIn: true })` (a PlayerBattleInput entry with `standIn: true` — in a match, the
|
||||
player's chess marked 未持有 on the 干员持有 tab, `PlayerState.battleInput`); `def.charId` is the stand-in's,
|
||||
`def.standInFor` the replaced operator's charId. The golden `standins` family (tools/golden.mjs) fields every NORMAL
|
||||
chess record as its stand-in, so a kit change that alters a battle shows there (test/golden-standins.test.js checks that
|
||||
every active backup skill is cast).
|
||||
|
||||
- **File and key**: `ops/standin-<codename>.js`, the code name being the stand-in's charId without `char_<n>_`
|
||||
(`standin-acguad.js` = Sharp, `char_609_acguad`); the default export has exactly one key, that charId:
|
||||
@@ -161,8 +164,9 @@ stand-in's, `def.standInFor` the replaced operator's charId.
|
||||
- Assert that the unit is the stand-in (`u.def.charId`, `u.def.standInFor`, `u.skill.id`) and that its kit is yours, not
|
||||
the generic one (`!u.kit.generic`); then the checklist below as for any operator. Examples:
|
||||
`test/content/standin.test.js` (the plumbing and the eight 预备干员).
|
||||
- A stand-in kit is new gameplay for stand-ins only: the golden corpus fields no stand-in, so its digests stay as they
|
||||
are.
|
||||
- A stand-in kit is gameplay for stand-ins only: of the golden corpus only the `standins` family (and the 补位 match
|
||||
`coop2-NORMAL-14-standins`) fields stand-ins, so a kit change moves those digests and no other — `npm run
|
||||
golden:update`, then review that the change is confined to the battles that field the stand-in.
|
||||
|
||||
## The fidelity rule
|
||||
|
||||
|
||||
Reference in new issue
Block a user