feedback5 WK: 自选编队 on the server — room.diy { picks } (lenient: illegal picks dropped, only malformed input is BAD_MSG; stored per session / seat; a match takes the picks its seat had at the start, a change during a match is for the next one; welcome lists diyKitted), PlayerState.diy with the player's data view (a slotted DIY slot is its operator for every rule: bonds from its factions, no 特质, the pick's skill and module, the slot's tier / price / merge), a per-player stock per slotted piece (8 at tier 5, 5 at tier 6 [ASSUMED]; never in the shared pool; none when every bond of it is banned), DIY pieces in that player's shop rolls and reward offers once the 调度中心 reaches the slot's level, battleInput diy, m.private.diy / diyBanned, prep scouting and results name the operator, 信标 never sends a 自选 piece to a teammate [ASSUMED]
The owner's decisions of 2026-10-05 (picks, slots, shop per player, bans); research 0.2.0 §2. Golden unchanged (no scenario slots a DIY pick). Tests: test/match/diy-shop.test.js (9), test/lobby-diy.test.js (4).
This commit is contained in:
1 parent
742f478526
commit
4bee542e61
26 files changed
+1026
-105
No files matched your search
+50
-4
@@ -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<string, { skill: number, module: string|null }> | null,
|
||||
* notOwned?: readonly string[] | null }} Seat
|
||||
* notOwned?: readonly string[] | null, diy?: Readonly<Record<string, DiyLoadout>> | null }} Seat
|
||||
* @typedef {{ charId: string, skillIndex: number, uniEquipId: string|null }} DiyLoadout
|
||||
*/
|
||||
|
||||
/** Deep-frozen copy of a checked loadout (shared by the session, the seat and the match's PlayerState). */
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<string>} 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<string, object>} slot id (normal / elite) → composed 自选 record (setDiy) */
|
||||
this._diyRecords = new Map();
|
||||
/** the copies of the slotted pieces (initDiyStock, once the match's bans are drawn) */
|
||||
this.diyStock = new DiyStock();
|
||||
/** slotted slots without stock: every bond of the operator is switched off this match */
|
||||
this.diyBanned = Object.freeze([]);
|
||||
if (!this.isBot && seat.diy) this.setDiy(seat.diy);
|
||||
this.shop = { level: 1, upgradePrice: this.gd.upgradeBase(1) ?? 0, slots: [], frozen: false, freeRefreshes: 0 };
|
||||
/** 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`);
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
+37
-34
@@ -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;
|
||||
|
||||
@@ -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 } });
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 (`<charId>@<statusKey>`, shared/diy.js diyTokenOwner).
|
||||
*/
|
||||
function diySummonsOf(gd, slotId, pick) {
|
||||
const tokens = gd.raw && gd.raw.backups && gd.raw.backups.tokens && typeof gd.raw.backups.tokens === 'object' ? gd.raw.backups.tokens : null;
|
||||
const slot = typeof gd.chess === 'function' && typeof slotId === 'string' ? gd.chess(slotId) : null;
|
||||
if (!tokens || !slot || !pick || typeof pick.charId !== 'string') return [];
|
||||
const owner = diyTokenOwner(pick.charId, slot.status);
|
||||
return Object.keys(tokens).filter((id) => Array.isArray(tokens[id] && tokens[id].owners) && tokens[id].owners.includes(owner));
|
||||
}
|
||||
/** Summons no chess owns (bond / band units every player may field: 炎佑, 预备干员-医疗, Touch). */
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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++) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 (`<charId>@<statusKey>`, shared/diy.js
|
||||
// diyTokenOwner), so a placeable 自选 summon comes to the hand like any operator's (PRTS 卫戍协议/帮助 §战斗部署).
|
||||
// * the per-player stock (initDiyStock, called by the match once its bans are drawn): each slotted DIY piece has copies
|
||||
// of its own — the tier's pool copies, 8 at tier 5 and 5 at tier 6 [ASSUMED: research 0.2.0 §2.5, the excel has no
|
||||
// stock field] — drawn by this player only; it never enters the shared pool (PR #71's shared shop was the mistake to
|
||||
// avoid). A piece all of whose bonds are switched off this match (本局禁用: the drawn bans and the mode's static list)
|
||||
// gets no stock and so leaves the shop, like a preset chess whose bonds are all off (PRTS 卫戍协议:盟约 "禁用盟约有可能
|
||||
// 影响自选的支援干员"); 协防 (emptyShip) is never banned, so a prototype always stays. poolOf(baseId) routes every copy
|
||||
// taken or returned for a slotted slot to that stock (buy, reward picks, merges, promotions, sells, temp, elimination).
|
||||
// * the shop's draws (diyRollEntries): the stock of each slotted slot joins this player's copy-weighted rolls — the
|
||||
// shop's chess slots and the reward offers' temporary refreshes [ASSUMED] — weighted like any chess of its tier, once
|
||||
// the 调度中心 has reached the slot's shopLevel (activity_table shopLevelDisplayDataDict lists the tier-5 slots at
|
||||
// level 5 and the tier-6 slots at level 6; PRTS 帮助 "仅在调度中心等级 ≥ 干员所在等阶"). The price is the slot's (any
|
||||
// chess of its tier: 4). Effects that grant a random operator from the shared pool never draw it.
|
||||
// Installed on PlayerState.prototype by server/match/PlayerState.js (a method container: never instantiated; `this` is
|
||||
// the player state).
|
||||
|
||||
import { KITTED_CHARS } from '../../sim/content/kits/index.js';
|
||||
import { checkDiyPicks } from '../../../shared/protocol.js';
|
||||
import { diyRecord, diySlot, diyTokenOwner } from '../../../shared/diy.js';
|
||||
|
||||
const posIntOr = (v, d) => (Number.isInteger(v) && v > 0 ? v : d);
|
||||
const isObj = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
||||
|
||||
/**
|
||||
* The copies of a player's slotted DIY pieces — the shared pool's copy interface (has / cap / left / take / give) for
|
||||
* those base ids only. `entries`: baseId → { cap, left, tier, shopLevel }.
|
||||
*/
|
||||
export class DiyStock {
|
||||
constructor() {
|
||||
/** @type {Map<string, { cap: number, left: number, tier: number, shopLevel: number }>} */
|
||||
this.entries = new Map();
|
||||
}
|
||||
|
||||
has(baseId) { return this.entries.has(baseId); }
|
||||
cap(baseId) { return this.entries.get(baseId)?.cap ?? 0; }
|
||||
left(baseId) { return this.entries.get(baseId)?.left ?? 0; }
|
||||
|
||||
/** Take up to n copies; returns the number taken (0 for a base id without stock). */
|
||||
take(baseId, n = 1) {
|
||||
const e = this.entries.get(baseId);
|
||||
if (!e || !(n > 0)) return 0;
|
||||
const k = Math.min(e.left, Math.floor(n));
|
||||
e.left -= k;
|
||||
return k;
|
||||
}
|
||||
|
||||
/** Return n copies (clamped at the cap); returns the number returned. */
|
||||
give(baseId, n = 1) {
|
||||
const e = this.entries.get(baseId);
|
||||
if (!e || !(n > 0)) return 0;
|
||||
const k = Math.min(e.cap - e.left, Math.floor(n));
|
||||
e.left += k;
|
||||
return k;
|
||||
}
|
||||
|
||||
/** { baseId: left } (tests / diagnostics). */
|
||||
snapshot() {
|
||||
const o = {};
|
||||
for (const [id, e] of this.entries) o[id] = e.left;
|
||||
return o;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The player's view of the match's GameData (see the header): `records` maps the ids of the slotted slots (normal and
|
||||
* elite) to their composed 自选 records; every other lookup is the match's own.
|
||||
* @param {import('../gamedata.js').GameData} gd
|
||||
* @param {Map<string, object>} records
|
||||
* @returns {import('../gamedata.js').GameData}
|
||||
*/
|
||||
export function diyGameData(gd, records) {
|
||||
const view = Object.create(gd);
|
||||
const backups = isObj(gd.raw && gd.raw.backups) ? gd.raw.backups : null;
|
||||
const diyTokens = backups && isObj(backups.tokens) ? backups.tokens : {};
|
||||
const tokenOf = (id) => (typeof id === 'string' && Object.hasOwn(diyTokens, id) && isObj(diyTokens[id]) ? diyTokens[id] : null);
|
||||
Object.defineProperties(view, {
|
||||
/** the match's own GameData (the view is per player) */
|
||||
matchData: { value: gd },
|
||||
chess: { value: (id) => (typeof id === 'string' && records.has(id) ? records.get(id) : gd.chess(id)) },
|
||||
token: { value: (id) => gd.token(id) || tokenOf(id) },
|
||||
/**
|
||||
* GameData.placeableTokens for a slotted slot: the summons its record lists (the pick's skill and talents) that are
|
||||
* placeable, by the variant of the owner form (`bySkill[skillIndex]` sources) — the deploy limit as the count.
|
||||
*/
|
||||
placeableTokens: {
|
||||
value: (chessId, loadout = null) => {
|
||||
const rec = typeof chessId === 'string' && records.has(chessId) ? records.get(chessId) : null;
|
||||
if (!rec) return gd.placeableTokens(chessId, loadout);
|
||||
const owner = diyTokenOwner(rec.charId, rec.status);
|
||||
const out = [];
|
||||
for (const tid of Array.isArray(rec.tokens) ? rec.tokens : []) {
|
||||
const t = tokenOf(tid);
|
||||
if (!t || t.kind !== 'summon' || t.placeable !== true) continue;
|
||||
const v = isObj(t.variants) ? t.variants[owner] ?? null : null;
|
||||
if (v) {
|
||||
const alt = loadout && Number.isInteger(loadout.skillIndex) && v.bySkill ? v.bySkill[loadout.skillIndex] : null;
|
||||
const src = Array.isArray(alt?.sources) ? alt.sources : Array.isArray(v.sources) ? v.sources : [];
|
||||
if (!src.includes('talent') && !src.includes('skill')) continue;
|
||||
}
|
||||
out.push({ tokenId: tid, count: Math.min(posIntOr(v?.stats?.deployLimit, posIntOr(t.deployLimit, 1)), 9) });
|
||||
}
|
||||
return out;
|
||||
},
|
||||
},
|
||||
});
|
||||
return view;
|
||||
}
|
||||
|
||||
export class PlayerDiy {
|
||||
/**
|
||||
* Fix the 自选 picks for the match (see the header): the seat's picks re-checked against this match's data and kits;
|
||||
* a slot whose record cannot be composed is left empty. With picks, `this.gd` becomes the player's data view.
|
||||
* @param {any} picks `{ [slotBaseId]: { charId, skillIndex?, uniEquipId? } | null }`
|
||||
* @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<string>} off
|
||||
*/
|
||||
initDiyStock(off = new Set()) {
|
||||
this.diyStock = new DiyStock();
|
||||
const banned = [];
|
||||
const gd = this.m.gd;
|
||||
for (const slotId of Object.keys(this.diy || {})) {
|
||||
const rec = this._diyRecords.get(slotId);
|
||||
const slot = diySlot(slotId, gd.raw);
|
||||
if (!rec || !slot) continue;
|
||||
const bonds = Array.isArray(rec.bonds) ? rec.bonds : [];
|
||||
if (bonds.length > 0 && bonds.every((b) => off.has(b))) { banned.push(slotId); continue; }
|
||||
const cap = gd.poolCopies(slotId);
|
||||
if (!(cap > 0)) continue;
|
||||
this.diyStock.entries.set(slotId, { cap, left: cap, tier: gd.tierOf(slotId), shopLevel: slot.shopLevel });
|
||||
}
|
||||
this.diyBanned = Object.freeze(banned);
|
||||
}
|
||||
|
||||
/** The copy accounting of base chess `baseId` for this player: its own stock for a slotted DIY slot, else the shared pool. */
|
||||
poolOf(baseId) {
|
||||
return this.diyStock && this.diyStock.has(baseId) ? this.diyStock : this.m.pool;
|
||||
}
|
||||
|
||||
/**
|
||||
* The stock entries this player's shop draws from now ([baseId, entry] with copies left and the 调度中心 at the slot's
|
||||
* shopLevel), or null (none — the shared pool's roll alone, exactly as without picks).
|
||||
* @returns {Array<[string, { cap: number, left: number, tier: number, shopLevel: number }]> | null}
|
||||
*/
|
||||
diyRollEntries() {
|
||||
if (!this.diyStock || !this.diyStock.entries.size) return null;
|
||||
const out = [];
|
||||
for (const [id, e] of this.diyStock.entries) if (e.left > 0 && this.shop.level >= e.shopLevel) out.push([id, e]);
|
||||
return out.length ? out : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The 自选 pick a chess id (normal or elite) of this player fields — its slot's — or null (not a slotted slot).
|
||||
* @param {string} id
|
||||
* @returns {{ charId: string, skillIndex: number, uniEquipId: string|null } | null}
|
||||
*/
|
||||
diyPickOf(id) {
|
||||
const rec = this._diyRecords && typeof id === 'string' ? this._diyRecords.get(id) : null;
|
||||
return rec ? this.diy[rec.diyFor] ?? null : null;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
+20
-10
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+10
-4
@@ -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<Record<string, { charId: string, skillIndex: number, uniEquipId: string|null }>> | null} checked 自选 picks (lobby-owned, 0.2.0 自选编队) */
|
||||
this.diy = null;
|
||||
/** @type {string} client address of the latest connection (logging) */
|
||||
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<typeof NET_DEFAULTS>,
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user