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 {
|
||||
|
||||
+5
-2
@@ -55,7 +55,9 @@ function diyOf(data) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The 自选 slot a chess id names: its base (normal) id, its tier and both records. Null for any other id.
|
||||
* The 自选 slot a chess id names: its base (normal) id, its tier, the 调度中心 level whose shop lists it (`shopLevel`:
|
||||
* activity_table shopLevelDisplayDataDict — the tier-5 slots at level 5, the tier-6 slots at level 6; the tier when the
|
||||
* data lacks it) and both records. Null for any other id.
|
||||
* @param {string} slotId a slot's normal (`_a`) or elite (`_b`) id
|
||||
* @param {DiyData} data
|
||||
*/
|
||||
@@ -65,7 +67,8 @@ export function diySlot(slotId, data) {
|
||||
const baseId = own(diy.slots, slotId) ? slotId : Object.keys(diy.slots).find((id) => diy.slots[id]?.goldenId === slotId);
|
||||
const s = baseId ? diy.slots[baseId] : null;
|
||||
if (!s) return null;
|
||||
return { baseId, goldenId: s.goldenId, tier: s.tier, elite: slotId !== baseId, normal: chessOf(data, baseId), golden: chessOf(data, s.goldenId) };
|
||||
const shopLevel = Number.isInteger(s.shopLevel) && s.shopLevel > 0 ? s.shopLevel : s.tier;
|
||||
return { baseId, goldenId: s.goldenId, tier: s.tier, shopLevel, elite: slotId !== baseId, normal: chessOf(data, baseId), golden: chessOf(data, s.goldenId) };
|
||||
}
|
||||
|
||||
/** The base ids of the 自选 slots (data order: tier 5 then tier 6). @param {DiyData} data @returns {string[]} */
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
import { DIFFICULTIES, NAME_MAX_LEN, ROOM_CODE_LEN, MAX_SEATS, EMOTES, GEO } from './constants.js';
|
||||
import { isDroppableChess } from './standIn.js';
|
||||
import { diySlotIds, validateDiyPicks } from './diy.js';
|
||||
|
||||
// ---- tiny validators -------------------------------------------------------
|
||||
const isInt = (v, lo = -Infinity, hi = Infinity) => Number.isInteger(v) && v >= lo && v <= hi;
|
||||
@@ -204,6 +205,52 @@ export function checkNotOwned(list, getChess) {
|
||||
return { ok: true, notOwned, dropped: list.length - notOwned.length };
|
||||
}
|
||||
|
||||
// ---- 自选编队 (0.2.0 DIY): room.diy { picks } ------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* `room.diy { picks }`: the player's 自选编队 — `{ [slotBaseId]: { charId, skillIndex?, uniEquipId? } | null }` for the
|
||||
* four DIY slots (data/backups.json `diy.slots`: two at tier 5, two at tier 6; shared/diy.js). An out-of-match setting
|
||||
* like 干员持有: stored per session / seat, a match takes the picks its seat had at its start. Structural limit below
|
||||
* (room for more slots in a later season); the semantic check (`checkDiyPicks`) is LENIENT, like checkNotOwned: an
|
||||
* illegal pick — not a pick of the slot's tier, an operator without a kit, a prototype off its locked skill, an unknown
|
||||
* skill / module, the same operator twice in a tier, an owned operator in a second slot, an unknown slot — is dropped,
|
||||
* never the whole roster; only malformed input is BAD_MSG.
|
||||
*/
|
||||
export const DIY_LIMITS = Object.freeze({ slots: 8 });
|
||||
const isDiyPickWire = (p) => p === null || (isPlain(p) && isId(p.charId)
|
||||
&& nullable((v) => isInt(v, 0, 9))(p.skillIndex) && nullable(isId)(p.uniEquipId));
|
||||
/** Structural check of `room.diy.picks`: a map of ≤ 8 slot ids → a pick `{ charId, skillIndex?, uniEquipId? }` or null. */
|
||||
export const isDiyPicks = (v) => isMap(v, DIY_LIMITS.slots, isId, isDiyPickWire);
|
||||
|
||||
/**
|
||||
* Semantic check + normalisation of a 自选 roster against the game data (`{ chess, backups }` or a sim DataSource) and
|
||||
* the kit registry (`kitted`: server/sim/content/kits/index.js KITTED_CHARS — an operator without a kit is never fielded):
|
||||
* the slots are taken in data order (tier 5, then tier 6), and each pick is kept when the roster so far plus it still
|
||||
* passes shared/diy.js validateDiyPicks — so the result always passes it, and of two picks that clash (one owned operator
|
||||
* in two slots, one operator twice in a tier) the first slot keeps it. Kept picks are complete
|
||||
* (`{ charId, skillIndex, uniEquipId }`: a prototype's locked selection, uniEquipId null = no module).
|
||||
* @param {any} picks `room.diy.picks`
|
||||
* @param {{ data: any, kitted?: Iterable<string>|((id: string) => boolean)|null }} opts
|
||||
* @returns {{ ok: true, picks: Record<string, { charId: string, skillIndex: number, uniEquipId: string|null }>, dropped: number }
|
||||
* | { error: 'BAD_MSG', detail: string }}
|
||||
*/
|
||||
export function checkDiyPicks(picks, { data, kitted = null } = { data: null }) {
|
||||
if (!isDiyPicks(picks)) return { error: 'BAD_MSG', detail: 'bad 自选 picks' };
|
||||
const slots = diySlotIds(data);
|
||||
/** @type {Record<string, { charId: string, skillIndex: number, uniEquipId: string|null }>} */
|
||||
const kept = {};
|
||||
let dropped = 0;
|
||||
for (const id of Object.keys(picks)) if (picks[id] != null && !slots.includes(id)) dropped++;
|
||||
for (const slotId of slots) {
|
||||
const pick = Object.hasOwn(picks, slotId) ? picks[slotId] : null;
|
||||
if (pick == null) continue;
|
||||
const res = validateDiyPicks({ ...kept, [slotId]: pick }, { data, kitted });
|
||||
if ('ok' in res) kept[slotId] = res.picks[slotId];
|
||||
else dropped++;
|
||||
}
|
||||
return { ok: true, picks: kept, dropped };
|
||||
}
|
||||
|
||||
// ---- unit stats (user playtest #4 item 7): m.unitStats units and the browser battle's live stats ---------------------
|
||||
|
||||
const fin = (v, d = 0) => (typeof v === 'number' && Number.isFinite(v) ? v : d);
|
||||
@@ -289,6 +336,9 @@ export const C2S = {
|
||||
// operator ownership (干员持有, 0.2.0 补位): stored per session / seat; a match takes the list its seat had when it
|
||||
// started (an out-of-match setting — during a match it is stored for the next one: ROOM_STARTED)
|
||||
'room.ownership': { notOwned: isNotOwnedList },
|
||||
// 自选编队 (0.2.0 DIY): the player's DIY slot picks; stored per session / seat like room.ownership (a match takes the
|
||||
// picks its seat had when it started; during a match they are stored for the next one: ROOM_STARTED)
|
||||
'room.diy': { picks: isDiyPicks },
|
||||
// spectator seats (remake feature, community report #26; MAX_SPECTATORS): take one of a co-op room's spectator seats —
|
||||
// in its lobby or while its match runs — never a player seat; the host frees one by playerId (the spectator gets
|
||||
// room.closed { reason: 'kicked' }). room.leave / g.leave leave a spectator seat like a player seat.
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
// test/lobby-diy.test.js — room.diy end to end over WebSocket (0.2.0 自选编队): lenient validation (only malformed picks
|
||||
// are refused; illegal ones are dropped), storage on the session (follows the player into rooms, survives a resume) and
|
||||
// on the seat, seats[].diy handed to the Match when it starts (bots: none), never to a running match (ROOM_STARTED:
|
||||
// stored for the next one — the next match takes it), every `welcome` carrying `diyKitted`, and the real match exposing
|
||||
// the picks as m.private.diy.
|
||||
import { describe, test, before, after, afterEach } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { startServer } from '../server/index.js';
|
||||
import { StubMatch } from '../server/match/StubMatch.js';
|
||||
import { KITTED_CHARS } from '../server/sim/content/kits/index.js';
|
||||
import { TestClient } from './helpers/wsClient.js';
|
||||
import { ERR, PHASE } from '../shared/constants.js';
|
||||
|
||||
const T5A = 'chess_char_5_diy1_a';
|
||||
const T6A = 'chess_char_6_diy1_a';
|
||||
const T6B = 'chess_char_6_diy2_a';
|
||||
const SIEGE = { charId: 'char_112_siege', skillIndex: 2, uniEquipId: 'uniequip_002_siege' };
|
||||
const SHARP = { charId: 'char_609_acguad' };
|
||||
const SHARP_FULL = { charId: 'char_609_acguad', skillIndex: 2, uniEquipId: 'uniequip_002_acguad' };
|
||||
|
||||
class RecordingStub extends StubMatch {
|
||||
static instances = [];
|
||||
constructor(opts) { super(opts); this.opts = opts; RecordingStub.instances.push(this); }
|
||||
}
|
||||
|
||||
function clientPool(getUrl) {
|
||||
const open = new Set();
|
||||
return {
|
||||
async player(name, token) {
|
||||
const c = await TestClient.connect(getUrl());
|
||||
open.add(c);
|
||||
const w = await c.hello(name, token);
|
||||
c.id = w.playerId;
|
||||
c.token = w.token;
|
||||
c.welcome = w;
|
||||
return c;
|
||||
},
|
||||
async closeAll() {
|
||||
await Promise.all([...open].map((c) => c.terminate().catch(() => {})));
|
||||
open.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
const quietLog = () => {
|
||||
const errors = [];
|
||||
return { errors, log: { info() {}, warn() {}, debug() {}, error: (...a) => errors.push(a.map(String).join(' ')) } };
|
||||
};
|
||||
const ok = async (c, msg) => { const r = await c.request(msg); assert.equal(r.t, 'ok', `${msg.t}: ${JSON.stringify(r)}`); return r; };
|
||||
const err = async (c, msg, code) => { const r = await c.request(msg); assert.equal(r.t, 'error', JSON.stringify(r)); assert.equal(r.code, code, JSON.stringify(r)); return r; };
|
||||
async function createRoom(c, mode = 'coop', difficulty = 'NORMAL') {
|
||||
await ok(c, { t: 'room.create', mode, difficulty });
|
||||
return c.waitFor('room.state', (s) => s.hostId === c.id && s.mode === mode);
|
||||
}
|
||||
|
||||
describe('room.diy (lobby, stub match)', () => {
|
||||
let srv;
|
||||
let pool;
|
||||
const cap = quietLog();
|
||||
before(async () => {
|
||||
srv = await startServer({ port: 0, host: '127.0.0.1', log: cap.log, MatchClass: RecordingStub, heavyBurst: 12, heavyPerSec: 4 });
|
||||
pool = clientPool(() => `ws://127.0.0.1:${srv.port}/ws`);
|
||||
});
|
||||
afterEach(async () => { RecordingStub.instances = []; await pool.closeAll(); });
|
||||
after(async () => { await srv?.close(); });
|
||||
|
||||
test('welcome lists the operators a 自选 slot may field (the kit registry)', async () => {
|
||||
const c = await pool.player('Kit');
|
||||
assert.deepEqual(c.welcome.diyKitted, [...KITTED_CHARS]);
|
||||
assert.ok(c.welcome.diyKitted.includes(SIEGE.charId));
|
||||
});
|
||||
|
||||
test('lenient: malformed picks are BAD_MSG; illegal ones dropped; stored per session and seat; bots none; the next match takes a change', async () => {
|
||||
const host = await pool.player('Host');
|
||||
await err(host, { t: 'room.diy', picks: [SIEGE] }, ERR.BAD_MSG);
|
||||
await err(host, { t: 'room.diy', picks: { [T5A]: { skillIndex: 1 } } }, ERR.BAD_MSG);
|
||||
// outside a room: accepted (a prototype off its locked skill, a second slot of the owned operator, junk dropped)
|
||||
await ok(host, { t: 'room.diy', picks: { [T5A]: SIEGE, [T6A]: { charId: 'char_609_acguad', skillIndex: 0 }, [T6B]: { charId: SIEGE.charId, skillIndex: 1 }, nope: SHARP } });
|
||||
const st = await createRoom(host);
|
||||
const guest = await pool.player('Guest');
|
||||
await ok(guest, { t: 'room.join', code: st.code });
|
||||
await guest.waitFor('room.state', (s) => s.seats.some((x) => x && x.playerId === guest.id));
|
||||
await ok(guest, { t: 'room.diy', picks: { [T6A]: SHARP } });
|
||||
await ok(guest, { t: 'room.diy', picks: { [T6A]: SHARP, [T5A]: SHARP } }); // re-sent: the latest wins
|
||||
await ok(host, { t: 'room.addBot' });
|
||||
// the guest drops and resumes: the session keeps its picks
|
||||
await guest.terminate();
|
||||
const back = await TestClient.connect(`ws://127.0.0.1:${srv.port}/ws`);
|
||||
await back.hello('Guest', guest.token);
|
||||
await ok(back, { t: 'room.ready', ready: true });
|
||||
await ok(host, { t: 'room.start' });
|
||||
await host.waitFor('m.public', (p) => p.phase === 'INFO_CHECK');
|
||||
let seats = RecordingStub.instances.at(-1).opts.seats;
|
||||
const by = (id) => seats.find((s) => s.playerId === id);
|
||||
assert.deepEqual(by(host.id).diy, { [T5A]: SIEGE });
|
||||
assert.ok(Object.isFrozen(by(host.id).diy) && Object.isFrozen(by(host.id).diy[T5A]));
|
||||
assert.deepEqual(by(guest.id).diy, { [T5A]: SHARP_FULL, [T6A]: SHARP_FULL }, 'a prototype may fill a tier-5 and a tier-6 slot');
|
||||
assert.equal(seats.find((s) => s.isBot).diy, null, 'bots field no 自选 piece');
|
||||
// during the match: refused for this match (ROOM_STARTED) but stored for the next one
|
||||
const r = await err(host, { t: 'room.diy', picks: { [T6B]: SIEGE } }, ERR.ROOM_STARTED);
|
||||
assert.match(r.detail || '', /next match/);
|
||||
// the stub ends once every human confirmed the briefing; the room is back in its lobby — the next match takes it
|
||||
await ok(host, { t: 'g.infoReady' });
|
||||
await ok(back, { t: 'g.infoReady' });
|
||||
await host.waitFor('room.state', (s) => !s.inMatch, 5000);
|
||||
await ok(back, { t: 'room.ready', ready: true });
|
||||
await ok(host, { t: 'room.start' });
|
||||
await host.waitFor('m.public', (p) => p.phase === 'INFO_CHECK');
|
||||
assert.equal(RecordingStub.instances.length, 2);
|
||||
seats = RecordingStub.instances.at(-1).opts.seats;
|
||||
assert.deepEqual(by(host.id).diy, { [T6B]: SIEGE });
|
||||
await back.terminate();
|
||||
assert.deepEqual(cap.errors, []);
|
||||
});
|
||||
|
||||
test('a spectator\'s picks stay on its session (never on a seat, never handed to the match)', async () => {
|
||||
const host = await pool.player('Host2');
|
||||
const st = await createRoom(host);
|
||||
const spec = await pool.player('Watcher');
|
||||
await ok(spec, { t: 'room.spectate', code: st.code });
|
||||
await spec.waitFor('room.state', (s) => (s.spectators || []).some((x) => x.playerId === spec.id));
|
||||
await ok(spec, { t: 'room.diy', picks: { [T5A]: SIEGE } });
|
||||
await ok(host, { t: 'room.start' });
|
||||
await host.waitFor('m.public', (p) => p.phase === 'INFO_CHECK');
|
||||
const seats = RecordingStub.instances.at(-1).opts.seats;
|
||||
assert.ok(!seats.some((s) => s.playerId === spec.id));
|
||||
assert.equal(seats[0].diy, null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('room.diy (lobby + real match)', () => {
|
||||
let srv;
|
||||
let pool;
|
||||
const cap = quietLog();
|
||||
before(async () => {
|
||||
srv = await startServer({ port: 0, host: '127.0.0.1', log: cap.log, seedFn: () => 4242 });
|
||||
pool = clientPool(() => `ws://127.0.0.1:${srv.port}/ws`);
|
||||
});
|
||||
afterEach(async () => { await pool.closeAll(); });
|
||||
after(async () => { await srv?.close(); });
|
||||
|
||||
test('the picks the seat had at the start are the match\'s m.private.diy; a change during the briefing waits for the next match', async () => {
|
||||
const a = await pool.player('Solo');
|
||||
await ok(a, { t: 'room.diy', picks: { [T5A]: SIEGE, [T6A]: SHARP } });
|
||||
await createRoom(a, 'solo');
|
||||
await ok(a, { t: 'room.start' });
|
||||
await a.waitFor('m.public', (p) => p.phase === PHASE.INFO_CHECK, 5000);
|
||||
const priv = await a.waitFor('m.private', (p) => p.diy && typeof p.diy === 'object', 3000);
|
||||
assert.deepEqual(priv.diy, { [T5A]: SIEGE, [T6A]: SHARP_FULL });
|
||||
assert.deepEqual(priv.diyBanned, []);
|
||||
await err(a, { t: 'room.diy', picks: {} }, ERR.ROOM_STARTED);
|
||||
await ok(a, { t: 'g.infoReady' });
|
||||
await a.waitFor('m.public', (p) => p.phase === PHASE.BAND_DRAFT, 3000);
|
||||
assert.deepEqual(cap.errors, []);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,323 @@
|
||||
// test/match/diy-shop.test.js — 0.2.0 自选编队 on the server (research 0.2.0 §2; the owner's decisions of 2026-10-05):
|
||||
// the picks (room.diy → seats[].diy) are checked leniently, fixed for the match and applied to that player only — the
|
||||
// player's data view makes a slotted slot the operator (name, class, bonds from its factions, no 特质, the pick's skill
|
||||
// and module; the slot's tier, price and merge), each slotted piece has its own stock (8 at tier 5, 5 at tier 6), never
|
||||
// enters the shared pool, joins only its player's shop once the 调度中心 reaches the slot's level, leaves the shop when
|
||||
// every bond of it is banned, merges 3 → elite, and fields its operator in battle (PlayerBattleInput `diy`), client-run
|
||||
// and server-run identical; prep scouting and the result name the operator.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { C2S, validateC2S, checkDiyPicks, DIY_LIMITS } from '../../shared/protocol.js';
|
||||
import { diyRecord } from '../../shared/diy.js';
|
||||
import { KITTED_CHARS } from '../../server/sim/content/kits/index.js';
|
||||
import { buildBattleSpec, createBattleFromSpec } from '../../server/sim/spec.js';
|
||||
import { DataSource, hasGeneratedData } from '../../server/sim/simdata.js';
|
||||
import { placeClass } from '../../server/match/board.js';
|
||||
import { buildResult } from '../../server/match/results.js';
|
||||
import { GEO } from '../../shared/constants.js';
|
||||
import { DATA, makeMatch, legalTileFor } from './harness.js';
|
||||
|
||||
const REAL = { skip: !hasGeneratedData() };
|
||||
const wire = (msg) => JSON.parse(JSON.stringify(msg));
|
||||
|
||||
const T5A = 'chess_char_5_diy1_a';
|
||||
const T5B = 'chess_char_5_diy2_a';
|
||||
const T6A = 'chess_char_6_diy1_a';
|
||||
const T6B = 'chess_char_6_diy2_a';
|
||||
const SIEGE = 'char_112_siege'; // 推进之王 (owned 6★, the first operator kit; 维多利亚)
|
||||
const SHARP = 'char_609_acguad'; // Sharp (6★ prototype: S3 + SOL-X at both tiers, 协防)
|
||||
const CGUARD = 'char_601_cguard'; // 预备干员-近卫 (4★ reserve: tier 5 only)
|
||||
const SIEGE_PICK = { charId: SIEGE, skillIndex: 2, uniEquipId: 'uniequip_002_siege' };
|
||||
const PICKS = { [T5A]: SIEGE_PICK, [T5B]: { charId: SHARP }, [T6A]: { charId: SHARP } };
|
||||
/** An owned 6★ without a kit (not offered while it has none). */
|
||||
const UNKITTED = (DATA.backups?.diy?.ownedPool || []).find((id) => !KITTED_CHARS.includes(id));
|
||||
|
||||
test('room.diy: structural wire check; checkDiyPicks keeps the legal picks, drops the rest (lenient), BAD_MSG only for malformed input', () => {
|
||||
assert.ok(C2S['room.diy']);
|
||||
assert.equal(validateC2S({ t: 'room.diy', picks: {} }), null);
|
||||
assert.equal(validateC2S({ t: 'room.diy', picks: { [T5A]: SIEGE_PICK, [T6A]: null } }), null);
|
||||
for (const bad of [[], 'x', { [T5A]: { skillIndex: 1 } }, { [T5A]: { charId: 'a b' } }, { [T5A]: { charId: SIEGE, skillIndex: 12 } }, { [T5A]: 3 }]) {
|
||||
assert.match(validateC2S({ t: 'room.diy', picks: bad }) || '', /bad field picks/, JSON.stringify(bad));
|
||||
assert.equal(checkDiyPicks(bad, { data: DATA, kitted: KITTED_CHARS }).error, 'BAD_MSG');
|
||||
}
|
||||
const many = Object.fromEntries(Array.from({ length: DIY_LIMITS.slots + 1 }, (_, i) => [`slot_${i}`, null]));
|
||||
assert.match(validateC2S({ t: 'room.diy', picks: many }) || '', /bad field picks/);
|
||||
|
||||
const res = checkDiyPicks({
|
||||
[T5A]: SIEGE_PICK,
|
||||
[T5B]: { charId: SHARP, skillIndex: 0 }, // a prototype off its locked skill: dropped
|
||||
[T6A]: { charId: SHARP }, // the same prototype at the other tier: fine
|
||||
[T6B]: { charId: SIEGE, skillIndex: 0 }, // an owned operator in a second slot: dropped
|
||||
chess_char_4_99_a: { charId: SIEGE, skillIndex: 0 }, // not a slot: dropped
|
||||
}, { data: DATA, kitted: KITTED_CHARS });
|
||||
assert.deepEqual(res, {
|
||||
ok: true, dropped: 3,
|
||||
picks: { [T5A]: SIEGE_PICK, [T6A]: { charId: SHARP, skillIndex: 2, uniEquipId: 'uniequip_002_acguad' } },
|
||||
});
|
||||
// a 4★ reserve is a tier-5 pick only; an operator without a kit is never offered
|
||||
const r2 = checkDiyPicks({ [T5A]: { charId: CGUARD }, [T6A]: { charId: CGUARD }, [T6B]: UNKITTED ? { charId: UNKITTED, skillIndex: 0 } : null }, { data: DATA, kitted: KITTED_CHARS });
|
||||
assert.deepEqual(Object.keys(r2.picks), [T5A]);
|
||||
assert.equal(r2.picks[T5A].skillIndex, 2, 'the reserve\'s locked S3');
|
||||
// one operator twice in a tier: the first slot keeps it
|
||||
const r3 = checkDiyPicks({ [T5B]: { charId: SHARP }, [T5A]: { charId: SHARP } }, { data: DATA, kitted: KITTED_CHARS });
|
||||
assert.deepEqual(Object.keys(r3.picks), [T5A]);
|
||||
assert.equal(r3.dropped, 1);
|
||||
});
|
||||
|
||||
/** A co-op match: p_0 slots 推进之王 + Sharp (×2 tiers) + junk, p_1 slots 推进之王 in the same slot, the bot nothing. */
|
||||
function diyMatch(extra = {}) {
|
||||
const seats = [
|
||||
{ seat: 0, playerId: 'p_0', name: 'P0', isBot: false, connected: true, diy: { ...PICKS, [T6B]: { charId: SIEGE, skillIndex: 1 }, junk: null } },
|
||||
{ seat: 1, playerId: 'p_1', name: 'P1', isBot: false, connected: true, diy: { [T5A]: { charId: SIEGE, skillIndex: 0, uniEquipId: null } } },
|
||||
{ seat: 2, playerId: 'ai_0', name: 'AI0', isBot: true, connected: true, diy: PICKS },
|
||||
];
|
||||
return makeMatch({ mode: 'coop', seats, seed: 11, ...extra });
|
||||
}
|
||||
/** Gain a chess the way the shop does (acquireChess: the player's copy accounting, merges). */
|
||||
const gain = (ps, id) => ps.acquireChess(id, { source: 'buy' });
|
||||
function clear(ps) {
|
||||
for (const p of [...ps.board.values()]) ps.returnCopies(p);
|
||||
ps.board.clear();
|
||||
for (let i = 0; i < ps.hand.length; i++) if (ps.hand[i]) { ps.returnCopies(ps.hand[i]); ps.hand[i] = null; }
|
||||
ps.recompute();
|
||||
}
|
||||
|
||||
test('seats[].diy → PlayerState.diy (re-checked, frozen; bots none); the player\'s data view is the operator, everyone else sees the slot', REAL, () => {
|
||||
const h = diyMatch().start();
|
||||
const p0 = h.ps('p_0');
|
||||
const p1 = h.ps('p_1');
|
||||
const bot = h.ps('ai_0');
|
||||
assert.deepEqual(Object.keys(p0.diy), [T5A, T5B, T6A], 'the owned operator\'s second slot dropped');
|
||||
assert.ok(Object.isFrozen(p0.diy) && Object.isFrozen(p0.diy[T5A]));
|
||||
assert.deepEqual(bot.diy, {}, 'bots field no 自选 piece');
|
||||
assert.equal(bot.gd, h.m.gd, 'a player without picks keeps the match\'s data');
|
||||
for (const [id, elite] of [[T5A, false], ['chess_char_5_diy1_b', true]]) {
|
||||
const rec = p0.gd.chess(id);
|
||||
const want = diyRecord(T5A, SIEGE_PICK, { elite, data: DATA });
|
||||
assert.deepEqual([rec.name, rec.charId, rec.diyFor, rec.bonds, rec.garrisonIds, rec.tier, rec.price, rec.sellPrice], [want.name, SIEGE, T5A, ['victoriaShip'], [], 5, 4, 1]);
|
||||
assert.equal(rec.skill.skillId, 'skchr_siege_3');
|
||||
assert.deepEqual(p0.loadoutFor(h.m.gd.chess(id)), { skillIndex: 2, moduleId: elite ? 'uniequip_002_siege' : null }, 'the pick, never the loadout');
|
||||
assert.equal(h.m.gd.chess(id).name, DATA.chess[id].name, 'the match\'s own record is the slot');
|
||||
}
|
||||
const s1 = DATA.backups.units[SIEGE].forms['2/1/4/0'].skills.find((x) => x.index === 0).skillId;
|
||||
assert.equal(p1.gd.chess(T5A).skill.skillId, s1, 'p_1\'s own pick of the same slot (S1)');
|
||||
assert.equal(placeClass(p0, h.m.gd.chess(T5A)), 'melee');
|
||||
assert.equal(p0.gd.chess(T5B).charId, SHARP);
|
||||
h.m.flush(true);
|
||||
const priv = h.lastTo('p_0', 'm.private');
|
||||
assert.deepEqual(priv.diy, wire(p0.diy));
|
||||
assert.deepEqual(priv.diyBanned, []);
|
||||
assert.deepEqual(h.lastTo('p_1', 'm.private').diy, { [T5A]: { charId: SIEGE, skillIndex: 0, uniEquipId: null } });
|
||||
// a malformed list (stale client) changes nothing
|
||||
assert.equal(p0.setDiy([]), false);
|
||||
assert.equal(Object.keys(p0.diy).length, 3);
|
||||
h.m.dispose();
|
||||
});
|
||||
|
||||
test('per-player stock: 8 at tier 5, 5 at tier 6, never in the shared pool; only its player\'s shop, from the slot\'s 调度中心 level', REAL, () => {
|
||||
const h = diyMatch().start();
|
||||
const m = h.m;
|
||||
h.toPrep(1);
|
||||
const p0 = h.ps('p_0');
|
||||
const p1 = h.ps('p_1');
|
||||
assert.deepEqual(p0.diyStock.snapshot(), { [T5A]: 8, [T5B]: 8, [T6A]: 5 });
|
||||
assert.deepEqual(p1.diyStock.snapshot(), { [T5A]: 8 }, 'a stock of its own for the same slot');
|
||||
for (const id of [T5A, T5B, T6A, T6B]) assert.ok(!m.pool.has(id), 'never in the shared pool');
|
||||
const seen = (ps, level, n = 300) => {
|
||||
ps.shop.level = level;
|
||||
const ids = new Set();
|
||||
for (let i = 0; i < n; i++) { ps.rollShop(); for (const s of ps.shop.slots) if (s && s.kind === 'chess' && /_diy/.test(s.id)) ids.add(s.id); }
|
||||
return [...ids].sort();
|
||||
};
|
||||
assert.deepEqual(seen(p0, 4), [], 'level 4: no tier-5 slot yet');
|
||||
assert.deepEqual(seen(p0, 5), [T5A, T5B], 'level 5: the tier-5 slots');
|
||||
assert.deepEqual(seen(p0, 6, 600), [T5A, T5B, T6A], 'level 6: both tiers');
|
||||
assert.deepEqual(seen(h.ps('ai_0'), 6), [], 'the bot\'s shop never sells another player\'s 自选 piece');
|
||||
// the price of any chess of the tier
|
||||
const slot = p0.shop.slots.find((s) => s && s.id === T6A) || { kind: 'chess', id: T6A, basePrice: m.gd.chessPrice(T6A), frozen: false, sold: false };
|
||||
assert.equal(slot.basePrice, 4);
|
||||
h.invariants();
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
test('buy → 3 copies merge into the elite (the stock pays the copies); sell / elimination give them back; a reward offer may hold it', REAL, () => {
|
||||
const h = diyMatch().start();
|
||||
const m = h.m;
|
||||
h.toPrep(1);
|
||||
const p0 = h.ps('p_0');
|
||||
clear(p0);
|
||||
p0.shop.level = 5;
|
||||
p0.funds = 40;
|
||||
// three purchases through g.buy (a shop slot stocked with the DIY slot)
|
||||
for (let i = 0; i < 3; i++) {
|
||||
p0.shop.slots[0] = { kind: 'chess', id: T5A, basePrice: m.gd.chessPrice(T5A), frozen: false, sold: false };
|
||||
assert.deepEqual(m.handle('p_0', { t: 'g.buy', slot: 0 }), { ok: true });
|
||||
}
|
||||
const elite = p0.allChess().find((p) => p.id === 'chess_char_5_diy1_b');
|
||||
assert.ok(elite, 'the elite');
|
||||
assert.equal(p0.allChess().filter((p) => p.id === T5A).length, 0);
|
||||
assert.equal(elite.poolCopies, 3);
|
||||
assert.equal(p0.diyStock.left(T5A), 5);
|
||||
assert.ok(p0.offers.length >= 1, 'the merge queued a reward offer');
|
||||
assert.equal(p0.gd.chess(elite.id).module.id, 'uniequip_002_siege');
|
||||
// a slot the player has not filled is no piece: another player's slot cannot be bought or granted
|
||||
p0.shop.slots[1] = { kind: 'chess', id: T6B, basePrice: 4, frozen: false, sold: false };
|
||||
assert.equal(m.handle('p_0', { t: 'g.buy', slot: 1 }).error, 'BAD_TARGET');
|
||||
assert.equal(p0.acquireChess(T6B), null);
|
||||
p0.shop.slots[1] = null;
|
||||
// sell returns the copies to the player's stock
|
||||
assert.deepEqual(m.handle('p_0', { t: 'g.sell', uid: elite.uid }), { ok: true });
|
||||
assert.equal(p0.diyStock.left(T5A), 8);
|
||||
// the stock runs out like a pool entry
|
||||
for (let i = 0; i < 8; i++) gain(p0, T5A);
|
||||
assert.equal(p0.diyStock.left(T5A), 0);
|
||||
p0.shop.slots[0] = { kind: 'chess', id: T5A, basePrice: 4, frozen: false, sold: false };
|
||||
assert.equal(m.handle('p_0', { t: 'g.buy', slot: 0 }).error, 'SOLD_OUT');
|
||||
h.invariants();
|
||||
// reward offers (the temporary refresh) draw it once the 调度中心 is at the slot's level
|
||||
clear(p0);
|
||||
p0.offers = [];
|
||||
let hit = 0;
|
||||
p0.shop.level = 4;
|
||||
for (let i = 0; i < 200; i++) { const o = p0.pushRewardOffer('merge'); if (o && o.slots.some((s) => /_diy/.test(s.id))) hit++; p0.offers = []; }
|
||||
assert.equal(hit, 0, 'a level-4 promotion reward (tier 5) never holds a tier-5 自选 piece');
|
||||
p0.shop.level = 6;
|
||||
for (let i = 0; i < 300; i++) { const o = p0.pushRewardOffer('merge'); if (o && o.slots.some((s) => s.id === T6A)) hit++; p0.offers = []; }
|
||||
assert.ok(hit > 0, 'a level-6 reward can hold the tier-6 piece');
|
||||
// elimination returns everything
|
||||
gain(p0, T6A);
|
||||
p0.eliminate(m.round);
|
||||
assert.deepEqual(p0.diyStock.snapshot(), { [T5A]: 8, [T5B]: 8, [T6A]: 5 });
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
test('bans: a 自选 piece whose every bond is off this match has no stock (out of the shop); 协防 is never banned', REAL, () => {
|
||||
// a seed whose drawn bans include 维多利亚 (推进之王's only bond)
|
||||
let h = null;
|
||||
for (let seed = 1; seed < 400 && !h; seed++) {
|
||||
const t = diyMatch({ seed });
|
||||
if (t.m.disabledBonds.includes('victoriaShip')) h = t; else t.m.dispose();
|
||||
}
|
||||
assert.ok(h, 'a seed banning 维多利亚');
|
||||
h.start();
|
||||
const p0 = h.ps('p_0');
|
||||
assert.deepEqual([...p0.diyBanned], [T5A]);
|
||||
assert.ok(!p0.diyStock.has(T5A));
|
||||
assert.deepEqual(Object.keys(p0.diyStock.snapshot()).sort(), [T5B, T6A], 'the prototypes (协防) stay');
|
||||
assert.ok(!h.m.disabledBonds.includes('emptyShip'));
|
||||
h.toPrep(1);
|
||||
p0.shop.level = 5;
|
||||
for (let i = 0; i < 300; i++) { p0.rollShop(); assert.ok(!p0.shop.slots.some((s) => s && s.id === T5A)); }
|
||||
h.m.flush(true);
|
||||
assert.deepEqual(h.lastTo('p_0', 'm.private').diyBanned, [T5A]);
|
||||
h.invariants();
|
||||
h.m.dispose();
|
||||
});
|
||||
|
||||
test('bonds, placement and summons read the operator; the record\'s identity (tier, price, merge) is the slot\'s', REAL, () => {
|
||||
const h = diyMatch().start();
|
||||
const m = h.m;
|
||||
h.toPrep(1);
|
||||
const p0 = h.ps('p_0');
|
||||
clear(p0);
|
||||
const a = gain(p0, T5A);
|
||||
const tile = legalTileFor(m, p0, T5A);
|
||||
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: a.uid, to: { area: 'board', row: tile[0], col: tile[1] } }), { ok: true });
|
||||
assert.ok(p0.bonds.victoriaShip && p0.bonds.victoriaShip.count >= 1, '推进之王 counts for 维多利亚');
|
||||
const b = gain(p0, T5B);
|
||||
const t2 = legalTileFor(m, p0, T5B);
|
||||
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: b.uid, to: { area: 'board', row: t2[0], col: t2[1] } }), { ok: true });
|
||||
assert.ok(p0.bonds.emptyShip && p0.bonds.emptyShip.count >= 1, 'a prototype counts for 协防');
|
||||
assert.equal(m.gd.mergeCount(T5A), 3);
|
||||
assert.deepEqual(p0.gd.placeableTokens(T5A, p0.loadoutFor(p0.gd.chess(T5A))), [], '推进之王 makes no summon');
|
||||
h.invariants();
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
/** The browser's DataSource (battle/runner.js loadBrowserSim): own JSON copies of the data files, backups included. */
|
||||
function browserSource() {
|
||||
const copy = (v) => JSON.parse(JSON.stringify(v));
|
||||
return new DataSource({ chess: copy(DATA.chess), enemies: copy(DATA.enemies), tokens: copy(DATA.tokens), stages: copy(DATA.stages), waves: copy(DATA.waves), backups: copy(DATA.backups) }, null);
|
||||
}
|
||||
|
||||
test('battleInput: a 自选 piece carries its pick (`diy`, no loadout fields); the sim fields the operator, client-run and server-run alike', REAL, () => {
|
||||
const h = diyMatch().start();
|
||||
const m = h.m;
|
||||
h.toPrep(1);
|
||||
const p0 = h.ps('p_0');
|
||||
clear(p0);
|
||||
const a = gain(p0, T5A);
|
||||
for (let i = 0; i < 2; i++) gain(p0, T5A); // → the elite
|
||||
const elite = p0.allChess().find((p) => p.id === 'chess_char_5_diy1_b');
|
||||
const tile = legalTileFor(m, p0, elite.id);
|
||||
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: elite.uid, to: { area: 'board', row: tile[0], col: tile[1] } }), { ok: true });
|
||||
const s = gain(p0, T6A);
|
||||
const t2 = legalTileFor(m, p0, T6A);
|
||||
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: s.uid, to: { area: 'board', row: t2[0], col: t2[1] } }), { ok: true });
|
||||
assert.ok(a);
|
||||
const input = p0.battleInput();
|
||||
const by = new Map(input.units.map((u) => [u.uid, u]));
|
||||
assert.deepEqual(by.get(elite.uid).diy, SIEGE_PICK);
|
||||
assert.ok(!('skillIndex' in by.get(elite.uid)) && !('moduleId' in by.get(elite.uid)) && !('standIn' in by.get(elite.uid)));
|
||||
assert.deepEqual(by.get(s.uid).diy, { charId: SHARP, skillIndex: 2, uniEquipId: 'uniequip_002_acguad' });
|
||||
const wave = m.wave;
|
||||
const spec = wire(buildBattleSpec({
|
||||
battleId: 't.1', fieldId: 'n:p_0', kind: 'normal', seed: 77, modeId: m.modeId, round: 1, stageId: m.stageId,
|
||||
rect: { ...GEO.NORMAL_RECT }, timeLimit: 40, players: [input], spawns: wave.spawns.map((x) => ({ ...x, ownerPlayerId: 'p_0' })),
|
||||
routes: wave.routes, flags: { layerGainsEnabled: true, ...m.gd.dp }, enemyOverrides: wave.overrides ?? {}, content: 'full',
|
||||
}));
|
||||
assert.deepEqual(spec.players[0].units.find((u) => u.uid === elite.uid).diy, SIEGE_PICK);
|
||||
const run = (ds) => {
|
||||
const bt = createBattleFromSpec(wire(spec), ds, { quiet: true, recordEvents: false });
|
||||
const u = bt.allyUnits.find((x) => x.uid === elite.uid);
|
||||
const def = { charId: u.def.charId, diyFor: u.def.diyFor, skill: u.def.skill?.id, bonds: u.def.bonds, name: u.name, generic: !!u.kit?.generic };
|
||||
let n = 0;
|
||||
while (!bt.finished && n++ < 30 * 45) bt.step();
|
||||
const res = bt.result();
|
||||
return { def, time: res.time, killed: res.killed, units: bt.allyUnits.map((x) => [x.uid, x.defId, Math.round(x.hp * 100), x.stats?.dmg | 0, x.skill?.activations ?? null]), rng: bt.rng.state() };
|
||||
};
|
||||
const server = run(m.ds);
|
||||
const client = run(browserSource());
|
||||
assert.deepEqual(server.def, { charId: SIEGE, diyFor: T5A, skill: 'skchr_siege_3', bonds: ['victoriaShip'], name: DATA.backups.units[SIEGE].name, generic: false });
|
||||
assert.deepEqual(client, server, 'the browser\'s battle equals the server\'s');
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
test('prep scouting (m.field) and the result name the operator: a teammate\'s 自选 piece carries its pick and the operator\'s art', REAL, () => {
|
||||
const h = diyMatch().start();
|
||||
const m = h.m;
|
||||
h.toPrep(1);
|
||||
const p0 = h.ps('p_0');
|
||||
clear(p0);
|
||||
const a = gain(p0, T5A);
|
||||
const tile = legalTileFor(m, p0, T5A);
|
||||
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: a.uid, to: { area: 'board', row: tile[0], col: tile[1] } }), { ok: true });
|
||||
const held = gain(p0, T6A);
|
||||
assert.deepEqual(m.handle('p_1', { t: 'g.watch', fieldId: 'n:p_0' }), { ok: true });
|
||||
const meta = wire(h.lastTo('p_1', 'm.field'));
|
||||
const by = new Map(meta.units.map((u) => [u.uid, u]));
|
||||
const u = by.get(a.uid);
|
||||
// (the pick as slotted: its module applies on the elite only — the normal piece fights without one)
|
||||
assert.deepEqual([u.defId, u.name, u.spine, u.skillIndex, u.moduleId, u.diy], [T5A, DATA.backups.units[SIEGE].name, SIEGE, 2, undefined, SIEGE_PICK]);
|
||||
const hu = by.get(held.uid);
|
||||
assert.deepEqual([hu.defId, hu.name, hu.diy?.charId], [T6A, DATA.backups.units[SHARP].name, SHARP]);
|
||||
const res = buildResult(m, { victory: false, hiddenReached: false, hiddenCleared: false, reason: 'test' });
|
||||
const row = res.players.find((p) => p.playerId === 'p_0');
|
||||
assert.deepEqual(row.lineup.find((x) => x.id === T5A).diy, SIEGE_PICK);
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
test('a match keeps the picks its seat had at its start (the next match takes a change: test/lobby-diy.test.js)', REAL, () => {
|
||||
const h = diyMatch().start();
|
||||
const p1 = h.ps('p_1');
|
||||
const before = p1.diy;
|
||||
h.toPrep(1);
|
||||
assert.equal(p1.diy, before);
|
||||
assert.equal(typeof h.m.setDiy, 'undefined', 'the match has no way to change them');
|
||||
h.m.dispose();
|
||||
// the next match: new seats, new picks
|
||||
const seats = [{ seat: 0, playerId: 'p_1', name: 'P1', isBot: false, connected: true, diy: { [T6A]: { charId: SIEGE, skillIndex: 1 } } }];
|
||||
const h2 = makeMatch({ mode: 'solo', seats, seed: 12 }).start();
|
||||
assert.deepEqual(h2.ps('p_1').diy, { [T6A]: { charId: SIEGE, skillIndex: 1, uniEquipId: null } });
|
||||
h2.m.dispose();
|
||||
});
|
||||
@@ -170,6 +170,7 @@ function legacyInvariants(m) {
|
||||
assert.ok(ps.hand.every((x) => x == null) && ps.temp.every((x) => x == null), 'eliminated player keeps nothing');
|
||||
}
|
||||
const chessUids = new Set();
|
||||
const diyHeld = new Map();
|
||||
const all = [...ps.board.values(), ...ps.hand.filter(Boolean), ...ps.temp.filter(Boolean)];
|
||||
for (const p of all) if (p.kind === 'chess') chessUids.add(p.uid);
|
||||
let deployed = 0;
|
||||
@@ -195,7 +196,9 @@ function legacyInvariants(m) {
|
||||
for (const it of p.items) { note(it); assert.equal(it.kind, 'item'); assert.ok(m.gd.item(it.id), `unknown item ${it.id}`); }
|
||||
assert.ok(Number.isInteger(p.poolCopies) && p.poolCopies >= 0);
|
||||
const base = m.gd.baseIdOf(p.id);
|
||||
held.set(base, (held.get(base) || 0) + p.poolCopies);
|
||||
// a 自选 piece's copies are its player's own stock (0.2.0, server/match/player/diy.js)
|
||||
const tally = ps.diyStock && ps.diyStock.has(base) ? diyHeld : held;
|
||||
tally.set(base, (tally.get(base) || 0) + p.poolCopies);
|
||||
} else if (p.kind === 'item') {
|
||||
assert.ok(m.gd.item(p.id), `unknown item ${p.id}`);
|
||||
} else if (p.kind === 'token') {
|
||||
@@ -210,6 +213,9 @@ function legacyInvariants(m) {
|
||||
const need = m.gd.mergeCount(b);
|
||||
if (need > 1 && m.gd.goldenIdOf(b)) assert.ok(n < need, `${ps.playerId} owns ${n} copies of ${b} (merge ${need})`);
|
||||
}
|
||||
for (const [base, e] of ps.diyStock ? ps.diyStock.entries : []) {
|
||||
assert.equal(e.left + (diyHeld.get(base) || 0), e.cap, `${ps.playerId} 自选 stock ${base}: left ${e.left} + held ${diyHeld.get(base) || 0} != cap ${e.cap}`);
|
||||
}
|
||||
}
|
||||
for (const [base, e] of pool.entries) {
|
||||
assert.ok(e.left >= 0 && e.left <= e.cap, `pool ${base} left ${e.left} cap ${e.cap}`);
|
||||
|
||||
Reference in new issue
Block a user