- #1 lost operator models: the Spine store no longer hands out a skeleton whose unload is in flight (PIXI.Assets.unload is async); eviction runs 1 s after a release and the empty-scene budget only after 3 s - #7 picking: one shared rule (render/pick.js, pixel probe where bodies overlap) for prep/battle clicks, hover, the enemy pen and equipment drops; the drag ghost stands on its drop tile; hover probes capped at one per frame - #2 the top-bar LP drops live as the own battle's enemies enter the blue gate (−N, 联防中), teammates' pending LP in the team panel - #3 temp overflow pieces that arrive after a prep deadline survive through the next prep (tempDue); temp-row warning and ready-button reason - #4 蕾缪安 S3: locks every 0.5 s, then one shell every 0.3 s in lock order (PRTS notes), bombardShell fx - #5 回环射手: real boomerang (out 15, back 3.75 tiles/s, attack only when every boomerang is caught) - #6 visible projectiles, muzzle flashes, slashes, skill bursts and auras at unchanged frame cost - #8 特质 right under the detail card header, with its official type icon - #9 no spinning busy cursors / spinners next to texts; game data warmed before the match - docs, recordings and tests updated (2414 unit tests, all browser suites green)
1410 lines
64 KiB
JavaScript
1410 lines
64 KiB
JavaScript
// server/match/PlayerState.js — authoritative per-player state + every prep intent handler (DESIGN §6.2).
|
||
//
|
||
// Handlers validate → mutate → recompute bonds → mark the private view dirty. They never throw on bad input; they
|
||
// return `{ ok: true }` or `{ error: ERR.*, detail? }`. Rules (research 00-INDEX §3–§4, 01 A1, 04 §2):
|
||
// * Hand (整备区) 10 slots filled right→left, temp (临时整备区) 5 slots. A full hand refuses buys / withdrawals,
|
||
// except a purchase that completes a merge and a withdrawal whose own summon stack frees a slot. Passive gains (merge
|
||
// results, grants, returned equipment) overflow into temp; temp blocks Ready ("直到溢出情况排除才可开始进行作战").
|
||
// A temp piece is resolved (chess sold back to the pool, items / summon stacks destroyed) at the deadline of the
|
||
// first prep in which the player could act on it (tempDue): a piece that overflowed during a prep before Ready
|
||
// expires at that prep's end; one that arrived after Ready, at the prep end (<休整期结束时> grants), in COMBAT /
|
||
// SETTLE (battle-result grants, merges, returned equipment) or at the next round start / 机变 stays visible and
|
||
// usable (move to a free hand slot, place, equip, destroy, sell) through the NEXT prep — nothing is destroyed before
|
||
// the player saw it in a prep ("处于临时整备区的调度资源,在进入下一回合后会自动销毁").
|
||
// * Board: own region rows 9–12 × cols 2–10, legality from the stage legend (board.js); deploy cap 8 (+effects);
|
||
// tokens (placeable summons) don't use deploy slots. Board↔hand swaps are always allowed. A terrain change
|
||
// (terrain 机变 cards, content overrides) withdraws the pieces left on tiles they may no longer occupy (hand,
|
||
// overflow temp; summons back onto their stack) — see _evictIllegal.
|
||
// * Shop: per-level chess slots + item slot, copy-weighted rolls from the SHARED pool (pool.js); refresh 1 (free
|
||
// refreshes first), one toggle freezes all unsold slots until the next round start (a manual refresh while frozen
|
||
// rerolls everything and the new slots stay frozen), level-up price = base − rounds elapsed (floor 0).
|
||
// * Merge: 3 normal copies (风丸 2) on board/hand/temp → 1 elite to the hand; equipment returns to the hand; a
|
||
// reward offer of 3 free chess of tier min(level+1, 6) is queued (pick 1, expires at prep end; an offer earned
|
||
// after the prep — SETTLE / Final Assault effects — is kept for the next prep). The elite goes to the hand
|
||
// (research 01 A1 "1 elite goes to the hand, not to a board tile"), overflow temp — also outside PREP (a
|
||
// SETTLE merge's elite waits in temp for the next prep); only with the hand and temp both full does it take a
|
||
// freed board tile of a consumed copy.
|
||
// * Items: equip max 2 (a 3rd replaces the equipped item the player picks — g.equip replaceUid, the oldest when
|
||
// absent; equipped items are otherwise locked: g.destroy refuses them),
|
||
// 2 identical normal items (hand/temp/equipped) merge into the golden item in the hand, items are never sold
|
||
// (destroy for 0). consume-on-equip items resolve through the effect registry and never take a slot.
|
||
// * Tokens: placing an owner with placeable summons (tokens.json displayType DEFAULT) sends one stack
|
||
// (deployLimit copies) to the hand; withdrawing/selling/merging the owner removes its tokens.
|
||
// * Facing (DESIGN §3, research 09 §1.2): every board piece has `dir` ∈ UP|RIGHT|DOWN|LEFT (server/sim/dir.js), set
|
||
// by g.move {…, dir} (absent ⇒ RIGHT) and kept across rounds. g.move onto the piece's OWN tile re-orients it in
|
||
// place; a swap keeps the occupant's dir; pieces put on the board by effects (a merge elite taking a consumed
|
||
// copy's tile, a transformation keeping the tile) keep that tile's dir, anything else defaults to RIGHT
|
||
// (`pieceDir`). g.art {…, dir} rotates the Art's range (画卷 1-1: its tile + the tile in front).
|
||
// * Operator loadout (DESIGN §16): the human's checked `seat.loadout` ({ [baseChessId]: { skill, module } }, entries
|
||
// equal to the defaults dropped) is re-checked against this match's data (shared/protocol.js checkLoadout; a
|
||
// mismatch falls back to the defaults) and kept frozen; bots always use the defaults. Match.setLoadout may replace
|
||
// it during INFO_CHECK only. battleInput() resolves every chess unit to `skillIndex` + `moduleId` (resolveLoadout:
|
||
// normal chess → moduleId null, elite → uniEquipId | 'none'); m.private exposes `loadout`. A placeable summon of a
|
||
// skill (赫默 S2 医疗无人机) is granted only with that skill equipped (grantTokensFor).
|
||
|
||
import { ERR, GEO, PHASE } from '../../shared/constants.js';
|
||
import { checkLoadout, resolveLoadout } from '../../shared/protocol.js';
|
||
import { FIELD, tileKey, parseKey, inField, canPlace, positionClass, boardOrder, freeSlot, pieceDir, parseDir } from './board.js';
|
||
import { offsetTile } from '../sim/dir.js';
|
||
import { computeBonds, bondList, bondSnapshot, activatedLayers } from './bondsMeta.js';
|
||
import { itemKey } from './gamedata.js';
|
||
|
||
const HAND_SIZE = GEO.HAND_SIZE;
|
||
const TEMP_SIZE = GEO.TEMP_SIZE;
|
||
/** g.reward accepts idx 0..5 (shared/protocol.js) */
|
||
const MAX_OFFER_SLOTS = 6;
|
||
const OK = Object.freeze({ ok: true });
|
||
const fail = (error, detail) => (detail ? { error, detail } : { error });
|
||
|
||
export class PlayerState {
|
||
/**
|
||
* @param {import('./Match.js').Match} m owning match
|
||
* @param {{ seat: number, playerId: string, name: string, isBot: boolean, connected: boolean }} seat
|
||
*/
|
||
constructor(m, seat) {
|
||
this.m = m;
|
||
this.gd = m.gd;
|
||
this.playerId = seat.playerId;
|
||
this.seat = seat.seat;
|
||
this.name = seat.name;
|
||
this.isBot = !!seat.isBot;
|
||
this.connected = this.isBot ? true : !!seat.connected;
|
||
this.left = false;
|
||
this.autoplay = false;
|
||
this.alive = true;
|
||
this.lp = 0;
|
||
this.bandId = null;
|
||
this.funds = 0;
|
||
this.pendingFunds = 0;
|
||
this.ready = false;
|
||
this.infoReady = this.isBot;
|
||
this.lastEmoteAt = -Infinity;
|
||
/** operator loadout (DESIGN §16): frozen { [baseChessId]: { skill, module } }, {} = every chess on its defaults */
|
||
this.loadout = Object.freeze({});
|
||
if (!this.isBot && seat.loadout) this.setLoadout(seat.loadout);
|
||
this.shop = { level: 1, upgradePrice: this.gd.upgradeBase(1) ?? 0, slots: [], frozen: false, freeRefreshes: 0 };
|
||
/** reward offers queue (merge rewards, special refreshes): { tier, source, slots: [{ kind, id, price, sold }] } */
|
||
this.offers = [];
|
||
/** @type {Array<any>} */
|
||
this.hand = new Array(HAND_SIZE).fill(null);
|
||
/** @type {Array<any>} */
|
||
this.temp = new Array(TEMP_SIZE).fill(null);
|
||
/** preps of this player that ended so far (endPrep) = index of the current (or next) prep */
|
||
this.prepsEnded = 0;
|
||
/** @type {Map<number, number>} temp piece uid → index of the prep whose deadline resolves it (see tempDue) */
|
||
this._tempDue = new Map();
|
||
/** @type {Map<string, any>} 'r,c' → piece */
|
||
this.board = new Map();
|
||
/** persistent bond layers */
|
||
this.layers = {};
|
||
/** computed bond states */
|
||
this.bonds = {};
|
||
/** optional per-bond count bonus written by effects */
|
||
this.bondCountBonus = {};
|
||
/** EffectRef list: { id, key, name, desc, iconKind, iconId, counter?, battle, params, data } */
|
||
this.effects = [];
|
||
/** active bounties: { id, card, roundsLeft, chooser } */
|
||
this.bounties = [];
|
||
/** free-form counters for content (ctx.counter / setCounter) */
|
||
this.counters = {};
|
||
/** per-round counters (reset at round start) */
|
||
this.round = { refreshes: 0, buys: 0, sells: 0, spent: 0, gainedChess: 0, arts: 0 };
|
||
this.deployCapBonus = 0;
|
||
this.deployCapMin = 0;
|
||
this.deviceOverrides = {};
|
||
this.tileOverrides = {};
|
||
this.stats = {
|
||
dmgDealt: 0, kills: 0, leaks: 0, gold: 0, refreshes: 0, merges: 0, itemMerges: 0, itemsEquipped: 0,
|
||
bossDamage: 0, lpLost: 0, buys: 0, sells: 0, perfectRounds: 0, fundsGained: 0, healing: 0,
|
||
};
|
||
this.eliminatedRound = null;
|
||
this.lpAtFinal = null;
|
||
/** last combat result for this player (unite carry state, bounties) */
|
||
this.lastResult = null;
|
||
this._deployMap = null;
|
||
/** the deploy map changed since the board's legality was last checked (invalidateDeployMap) */
|
||
this._legalityStale = false;
|
||
/** Match.scheduleBotPrep: the latest bot prep of this seat (older sliced rehearsals drop out) */
|
||
this._botPrepToken = 0;
|
||
this.bonds = computeBonds(this.gd, this);
|
||
}
|
||
|
||
// =================================================================================================
|
||
// basics
|
||
|
||
get isHumanActive() { return !this.isBot && !this.left; }
|
||
/** The engine acts for this seat (AI teammate or "AI 托管"; a departed human is eliminated, so nothing is left to do). */
|
||
get botControlled() { return this.isBot || this.left || this.autoplay; }
|
||
|
||
get deployCap() { return Math.max(1, this.gd.deployCap + this.deployCapBonus, this.deployCapMin); }
|
||
get deployCount() { let n = 0; for (const p of this.board.values()) if (p.kind === 'chess') n++; return n; }
|
||
get tempEmpty() { return this.temp.every((x) => x == null); }
|
||
|
||
/**
|
||
* Index of the prep whose deadline resolves a temp piece (compare with `prepsEnded`): recorded when the piece entered
|
||
* temp (_putTemp); a piece put there by other means counts as due at the current (or next) prep.
|
||
*/
|
||
tempDue(piece) {
|
||
const due = piece ? this._tempDue.get(piece.uid) : undefined;
|
||
return Number.isInteger(due) ? due : this.prepsEnded;
|
||
}
|
||
|
||
/**
|
||
* Due prep of a piece entering temp now: the current prep while the player can still act on it (PREP, not ready);
|
||
* after Ready or at the prep end (onPrepEnd grants) the next one; outside PREP (COMBAT, SETTLE, ROUND_START, 机变)
|
||
* the next prep to end — `prepsEnded` then already names it.
|
||
*/
|
||
_tempDueNow() {
|
||
return this.prepsEnded + (this.m.phase === PHASE.PREP && this.ready ? 1 : 0);
|
||
}
|
||
|
||
/** Every write of a piece into a temp slot goes through here (records its due prep). */
|
||
_putTemp(i, piece) {
|
||
this.temp[i] = piece;
|
||
this._tempDue.set(piece.uid, this._tempDueNow());
|
||
}
|
||
|
||
/**
|
||
* Replace the operator loadout (DESIGN §16) after re-checking it against this match's data. Accepts the checked
|
||
* form `{ id: { skill, module|null } }` or raw `room.loadout` entries. Returns false (loadout unchanged) when it does
|
||
* not fit the data; bots keep the defaults.
|
||
* @param {any} loadout
|
||
* @returns {boolean}
|
||
*/
|
||
setLoadout(loadout) {
|
||
if (this.isBot) return false;
|
||
const entries = {};
|
||
if (loadout && typeof loadout === 'object' && !Array.isArray(loadout)) {
|
||
for (const [id, e] of Object.entries(loadout)) {
|
||
if (!e || typeof e !== 'object') continue;
|
||
const x = {};
|
||
if (Number.isInteger(e.skill)) x.skill = e.skill;
|
||
if (typeof e.module === 'string') x.module = e.module;
|
||
if (Object.keys(x).length) entries[id] = x;
|
||
}
|
||
}
|
||
const res = checkLoadout(entries, (id) => this.gd.chess(id));
|
||
if (!res || !res.ok) {
|
||
this.m.log?.warn?.(`[match ${this.m.roomCode}] loadout of ${this.playerId} ignored: ${res && res.detail}`);
|
||
return false;
|
||
}
|
||
const out = {};
|
||
for (const [id, e] of Object.entries(res.loadout)) out[id] = Object.freeze({ skill: e.skill, module: e.module ?? null });
|
||
this.loadout = Object.freeze(out);
|
||
return true;
|
||
}
|
||
|
||
/** The skill index / module a chess record fights with under this player's loadout (DESIGN §16). */
|
||
loadoutFor(chessRecord) {
|
||
return resolveLoadout(this.loadout, chessRecord, (id) => this.gd.chess(id));
|
||
}
|
||
|
||
deployMap() {
|
||
if (!this._deployMap) this._deployMap = this.m.deployMapFor(this);
|
||
return this._deployMap;
|
||
}
|
||
/**
|
||
* The board's terrain changed (terrain 机变 cards, content device / tile overrides): legality is re-checked at the
|
||
* next recompute() / battleInput() — after the whole change, so an intermediate state of a card that toggles several
|
||
* devices never moves a piece.
|
||
*/
|
||
invalidateDeployMap() { this._deployMap = null; this._legalityStale = true; }
|
||
|
||
/**
|
||
* After a terrain change, pieces standing on tiles they may no longer occupy (a melee operator on a tile that became
|
||
* a 射击台, anything on a tile that became undeployable) are withdrawn like 撤退: an operator goes to the hand
|
||
* (overflow temp — a passive move; the player re-places it during the prep), its summons leave the board with it; a
|
||
* summon returns to its owner's stack. Nothing is lost: with the hand and temp both full a piece stays put.
|
||
* @returns {number} pieces moved
|
||
*/
|
||
_evictIllegal() {
|
||
this._legalityStale = false;
|
||
const names = [];
|
||
let moved = 0;
|
||
for (const kind of ['chess', 'token']) {
|
||
for (const { r, c, piece } of boardOrder(this.board)) {
|
||
if (piece.kind !== kind || this._legal(piece, r, c)) continue;
|
||
const key = tileKey(r, c);
|
||
this.board.delete(key);
|
||
const ok = kind === 'chess' ? !!this.stow(piece, { allowTemp: true }) : this._returnToken(piece);
|
||
if (!ok) { this.board.set(key, piece); continue; }
|
||
moved++;
|
||
if (kind === 'chess') {
|
||
this.removeTokensOf(piece.uid);
|
||
const rec = this.gd.chess(piece.id);
|
||
names.push(rec && rec.name ? rec.name : piece.id);
|
||
}
|
||
}
|
||
}
|
||
if (names.length) this.m.toast(this, 'warn', `地形变化:${names.join('、')}无法停留在原位置,已撤回整备区`);
|
||
return moved;
|
||
}
|
||
|
||
dirty() { this.m.markPrivate(this); }
|
||
|
||
// =================================================================================================
|
||
// piece bookkeeping
|
||
|
||
newPiece(kind, id, extra = {}) {
|
||
return { uid: this.m.nextUid(), kind, id, items: kind === 'chess' ? [] : undefined, count: kind === 'token' ? 1 : undefined, ownerUid: undefined, poolCopies: 0, boughtRound: this.m.round, meta: {}, ...extra };
|
||
}
|
||
|
||
/**
|
||
* Locate a piece by uid. Returns { piece, area: 'board'|'hand'|'temp'|'equipped', idx?, key?, holder? } or null.
|
||
*/
|
||
find(uid) {
|
||
if (!Number.isInteger(uid)) return null;
|
||
for (let i = 0; i < this.hand.length; i++) {
|
||
const p = this.hand[i];
|
||
if (!p) continue;
|
||
if (p.uid === uid) return { piece: p, area: 'hand', idx: i };
|
||
if (p.items) for (const it of p.items) if (it.uid === uid) return { piece: it, area: 'equipped', holder: p };
|
||
}
|
||
for (let i = 0; i < this.temp.length; i++) {
|
||
const p = this.temp[i];
|
||
if (!p) continue;
|
||
if (p.uid === uid) return { piece: p, area: 'temp', idx: i };
|
||
if (p.items) for (const it of p.items) if (it.uid === uid) return { piece: it, area: 'equipped', holder: p };
|
||
}
|
||
for (const [key, p] of this.board) {
|
||
if (p.uid === uid) return { piece: p, area: 'board', key };
|
||
if (p.items) for (const it of p.items) if (it.uid === uid) return { piece: it, area: 'equipped', holder: p };
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/** Every owned chess piece: board (deploy order) then hand then temp. */
|
||
allChess() {
|
||
const out = [];
|
||
for (const { piece } of boardOrder(this.board)) if (piece.kind === 'chess') out.push(piece);
|
||
for (const p of this.hand) if (p && p.kind === 'chess') out.push(p);
|
||
for (const p of this.temp) if (p && p.kind === 'chess') out.push(p);
|
||
return out;
|
||
}
|
||
|
||
/** Locations of owned pieces, in merge-consumption preference order: temp, hand (left→right), board (reading order). */
|
||
_chessLocations() {
|
||
const out = [];
|
||
for (let i = 0; i < this.temp.length; i++) if (this.temp[i] && this.temp[i].kind === 'chess') out.push({ piece: this.temp[i], area: 'temp', idx: i });
|
||
for (let i = 0; i < this.hand.length; i++) if (this.hand[i] && this.hand[i].kind === 'chess') out.push({ piece: this.hand[i], area: 'hand', idx: i });
|
||
for (const { r, c, piece } of boardOrder(this.board)) if (piece.kind === 'chess') out.push({ piece, area: 'board', key: tileKey(r, c) });
|
||
return out;
|
||
}
|
||
|
||
/** Remove a located piece from its container (no side effects). */
|
||
_detach(loc) {
|
||
if (!loc) return;
|
||
if (loc.area === 'hand') this.hand[loc.idx] = null;
|
||
else if (loc.area === 'temp') { this.temp[loc.idx] = null; this._tempDue.delete(loc.piece.uid); }
|
||
else if (loc.area === 'board') this.board.delete(loc.key);
|
||
else if (loc.area === 'equipped') {
|
||
const i = loc.holder.items.indexOf(loc.piece);
|
||
if (i >= 0) loc.holder.items.splice(i, 1);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Put a piece into the hand (right→left) or, when `allowTemp`, the temp slots (due at the deadline of the first prep
|
||
* in which the player can act on it, _tempDueNow). Returns 'hand' | 'temp' | null.
|
||
*/
|
||
stow(piece, { allowTemp = true, toTemp = false, preferIdx = null } = {}) {
|
||
if (!toTemp) {
|
||
if (Number.isInteger(preferIdx) && preferIdx >= 0 && preferIdx < this.hand.length && this.hand[preferIdx] == null) {
|
||
this.hand[preferIdx] = piece;
|
||
return 'hand';
|
||
}
|
||
const i = freeSlot(this.hand);
|
||
if (i >= 0) { this.hand[i] = piece; return 'hand'; }
|
||
if (!allowTemp) return null;
|
||
}
|
||
const j = freeSlot(this.temp);
|
||
if (j >= 0) { this._putTemp(j, piece); return 'temp'; }
|
||
return null;
|
||
}
|
||
|
||
/** Return a piece's pool copies (and its equipped items are handled by the caller). */
|
||
returnCopies(piece) {
|
||
if (piece && piece.kind === 'chess' && piece.poolCopies > 0) {
|
||
this.m.pool.give(this.gd.baseIdOf(piece.id), piece.poolCopies);
|
||
piece.poolCopies = 0;
|
||
}
|
||
}
|
||
|
||
/** Remove every token owned by a chess piece (board, hand, temp). */
|
||
removeTokensOf(ownerUid) {
|
||
for (const [k, p] of [...this.board]) if (p.kind === 'token' && p.ownerUid === ownerUid) this.board.delete(k);
|
||
for (let i = 0; i < this.hand.length; i++) if (this.hand[i] && this.hand[i].kind === 'token' && this.hand[i].ownerUid === ownerUid) this.hand[i] = null;
|
||
for (let i = 0; i < this.temp.length; i++) if (this.temp[i] && this.temp[i].kind === 'token' && this.temp[i].ownerUid === ownerUid) this.temp[i] = null;
|
||
}
|
||
|
||
_hasTokensOf(ownerUid, tokenId) {
|
||
for (const p of this.board.values()) if (p.kind === 'token' && p.ownerUid === ownerUid && p.id === tokenId) return true;
|
||
for (const p of this.hand) if (p && p.kind === 'token' && p.ownerUid === ownerUid && p.id === tokenId) return true;
|
||
for (const p of this.temp) if (p && p.kind === 'token' && p.ownerUid === ownerUid && p.id === tokenId) return true;
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* Owner placed on the board: send its placeable summons to the hand (one stack per token type). A summon that comes
|
||
* from a skill (SkillRecord.overrideTokenKey, e.g. 赫默 S2 医疗无人机 "获得一个医疗无人机") is granted only while that
|
||
* skill is the one equipped under the player's loadout (DESIGN §16) — 赫默 on S1 has no drone.
|
||
*/
|
||
grantTokensFor(owner) {
|
||
const rec = this.gd.chess(owner.id);
|
||
const skills = rec && Array.isArray(rec.skills) ? rec.skills.filter(Boolean) : [];
|
||
const sel = skills.length ? this.loadoutFor(rec).skillIndex : null;
|
||
for (const { tokenId, count } of this.gd.placeableTokens(owner.id)) {
|
||
if (this._hasTokensOf(owner.uid, tokenId)) continue;
|
||
const bySkill = skills.filter((s) => s.overrideTokenKey === tokenId);
|
||
if (bySkill.length && !bySkill.some((s) => s.index === sel)) continue;
|
||
const t = this.newPiece('token', tokenId, { count, ownerUid: owner.uid });
|
||
this.stow(t, { allowTemp: true });
|
||
}
|
||
}
|
||
|
||
// =================================================================================================
|
||
// acquisition, merges, promotion
|
||
|
||
/** Normal copies of a base chess currently owned (board/hand/temp). */
|
||
countCopies(baseId) {
|
||
let n = 0;
|
||
for (const loc of this._chessLocations()) {
|
||
const p = loc.piece;
|
||
if (!this.gd.isGolden(p.id) && this.gd.baseIdOf(p.id) === baseId) n++;
|
||
}
|
||
return n;
|
||
}
|
||
|
||
/** Would acquiring one more normal copy of `chessId` complete a merge? */
|
||
completesChessMerge(chessId) {
|
||
const rec = this.gd.chess(chessId);
|
||
if (!rec || rec.isGolden) return false;
|
||
const need = this.gd.mergeCount(chessId);
|
||
if (!(need > 1) || !this.gd.goldenIdOf(chessId)) return false;
|
||
return this.countCopies(this.gd.baseIdOf(chessId)) + 1 >= need;
|
||
}
|
||
|
||
/**
|
||
* Acquire a chess (buy, reward, effect grant). Takes pool copies (normal 1, elite goldenCopies) when available,
|
||
* merges immediately when it completes a set, otherwise stows it (hand, overflow temp). Fires onGain (for the
|
||
* elite when a merge happened, research 01 §7 "1+1+2"). Returns the owned piece (the elite after a merge) or null.
|
||
* @param {string} chessId
|
||
* @param {{ source?: string, toTemp?: boolean, fromPool?: boolean, silent?: boolean }} [opts]
|
||
*/
|
||
acquireChess(chessId, { source = 'grant', toTemp = false, fromPool = true, silent = false } = {}) {
|
||
const rec = this.gd.chess(chessId);
|
||
if (!rec) 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;
|
||
const piece = this.newPiece('chess', chessId, { poolCopies: taken });
|
||
this.round.gainedChess++;
|
||
let owned = piece;
|
||
if (!rec.isGolden && this.completesChessMerge(chessId)) {
|
||
owned = this._mergeChess(base, piece);
|
||
if (!owned) return null;
|
||
} else {
|
||
const where = this.stow(piece, { allowTemp: true, toTemp });
|
||
if (!where) {
|
||
this.returnCopies(piece);
|
||
this.m.toast(this, 'warn', '整备区已满,获得的干员已返还');
|
||
return null;
|
||
}
|
||
}
|
||
this.recompute();
|
||
if (!silent) this.m.dispatch(this, 'onGain', { piece: owned, kind: 'chess', source });
|
||
this.recompute();
|
||
return owned;
|
||
}
|
||
|
||
/**
|
||
* Merge `need` normal copies of `baseId` (the incoming, not yet stowed piece first, then temp, hand, board) into
|
||
* the elite. Returns the elite piece (or null if the elite could not be stored).
|
||
*/
|
||
_mergeChess(baseId, incoming) {
|
||
const need = this.gd.mergeCount(baseId);
|
||
const goldenId = this.gd.goldenIdOf(baseId);
|
||
if (!(need > 1) || !goldenId) return null;
|
||
const locs = this._chessLocations().filter((l) => !this.gd.isGolden(l.piece.id) && this.gd.baseIdOf(l.piece.id) === baseId);
|
||
const consumed = [];
|
||
if (incoming) consumed.push({ piece: incoming, area: 'new' });
|
||
for (const l of locs) { if (consumed.length >= need) break; consumed.push(l); }
|
||
if (consumed.length < need) return null;
|
||
let copies = 0;
|
||
const items = [];
|
||
for (const l of consumed) {
|
||
copies += l.piece.poolCopies || 0;
|
||
if (l.area !== 'new') this._detach(l);
|
||
this.removeTokensOf(l.piece.uid);
|
||
for (const it of l.piece.items || []) items.push(it);
|
||
l.piece.items = [];
|
||
}
|
||
const elite = this.newPiece('chess', goldenId, { poolCopies: copies });
|
||
const boardLoc = consumed.find((l) => l.area === 'board' && !this.board.has(l.key));
|
||
const toBoard = () => {
|
||
// same operator as a consumed copy, so its freed tile is legal; it keeps that copy's facing
|
||
elite.dir = pieceDir(boardLoc.piece);
|
||
this.board.set(boardLoc.key, elite);
|
||
this.grantTokensFor(elite);
|
||
return 'board';
|
||
};
|
||
// the elite goes to the hand, overflow temp — outside PREP too (a merge completed by SETTLE / battle-result effects):
|
||
// a temp piece that arrived after the prep waits there through the next prep (tempDue)
|
||
let where = this.stow(elite, { allowTemp: true });
|
||
if (!where && boardLoc) {
|
||
// hand and temp both full ⇒ every consumed copy stood on the board: the elite takes the first freed tile
|
||
// instead of being lost
|
||
where = toBoard();
|
||
}
|
||
for (const it of items) {
|
||
if (this.stow(it, { allowTemp: true })) continue;
|
||
if (where && elite.items.length < this.gd.equipPerChess) { elite.items.push(it); continue; }
|
||
this.m.log.warn?.(`[match ${this.m.roomCode}] ${this.playerId}: returned item ${it.id} destroyed (no space)`);
|
||
}
|
||
if (!where) {
|
||
this.m.pool.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();
|
||
return null;
|
||
}
|
||
this.stats.merges++;
|
||
this.pushRewardOffer('merge');
|
||
const rec = this.gd.chess(goldenId);
|
||
this.m.tickerFor('GOLDEN_CHAR', [this.name, rec ? rec.name : goldenId], { playerId: this.playerId });
|
||
this.m.dispatch(this, 'onMerge', { kind: 'chess', piece: elite, baseId, consumed: consumed.map((l) => l.piece.uid) });
|
||
return elite;
|
||
}
|
||
|
||
/** Promote a normal chess piece to its elite in place (升华, 博士投影). Takes extra pool copies when available. */
|
||
promote(piece) {
|
||
if (!piece || piece.kind !== 'chess' || this.gd.isGolden(piece.id)) return false;
|
||
const goldenId = this.gd.goldenIdOf(piece.id);
|
||
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.id = goldenId;
|
||
this.recompute();
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* Replace a chess piece by another chess (突变细胞 and similar). The new piece keeps the tile when legal (else goes
|
||
* to the hand/temp) and keeps the equipment; pool copies are swapped; completes a merge when possible.
|
||
*/
|
||
transformChess(piece, newId) {
|
||
const loc = this.find(piece.uid);
|
||
const rec = this.gd.chess(newId);
|
||
if (!loc || loc.piece.kind !== 'chess' || !rec) return null;
|
||
this._detach(loc);
|
||
this.removeTokensOf(piece.uid);
|
||
const items = piece.items || [];
|
||
piece.items = [];
|
||
this.returnCopies(piece);
|
||
const base = this.gd.baseIdOf(newId);
|
||
const taken = this.m.pool.take(base, rec.isGolden ? this.gd.goldenCopies : 1);
|
||
let np = this.newPiece('chess', newId, { poolCopies: taken });
|
||
np.items = items;
|
||
if (!rec.isGolden && this.completesChessMerge(newId)) {
|
||
np = this._mergeChess(base, np);
|
||
if (!np) { this.recompute(); return null; }
|
||
} else if (loc.area === 'board') {
|
||
const [r, c] = parseKey(loc.key);
|
||
if (this._legal(np, r, c)) { np.dir = pieceDir(piece); this.board.set(loc.key, np); this.grantTokensFor(np); } else if (!this.stow(np, { allowTemp: true })) { this.returnCopies(np); np = null; }
|
||
} else if (!this._putBack(loc, np)) { this.returnCopies(np); np = null; }
|
||
this.recompute();
|
||
if (np) this.m.dispatch(this, 'onGain', { piece: np, kind: 'chess', source: 'transform' });
|
||
return np;
|
||
}
|
||
|
||
/** Normal item → golden version in place (整备). */
|
||
upgradeItem(piece) {
|
||
const rec = this.gd.item(piece.id);
|
||
if (!rec || rec.isGolden) return false;
|
||
const gid = rec.upgradeChessId || rec.goldenId;
|
||
if (!gid || !this.gd.item(gid)) return false;
|
||
piece.id = gid;
|
||
this.recompute();
|
||
return true;
|
||
}
|
||
|
||
/** Queue a reward offer: `count` chess of tier min(level + offset, maxTier) at price 0 (pick 1). */
|
||
pushRewardOffer(source = 'merge', { tier = null, ids = null } = {}) {
|
||
const ro = this.gd.rewardOffer();
|
||
const t = Number.isInteger(tier) ? tier : Math.min(this.shop.level + ro.tierOffset, ro.maxTier);
|
||
let list = Array.isArray(ids) ? ids.filter((id) => this.gd.chess(id)) : null;
|
||
if (!list) {
|
||
list = [];
|
||
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 });
|
||
if (id) list.push(id);
|
||
}
|
||
}
|
||
if (!list.length) return null;
|
||
const offer = { tier: t, source, slots: list.slice(0, MAX_OFFER_SLOTS).map((id) => ({ kind: 'chess', id, price: ro.price, sold: false })) };
|
||
this.offers.push(offer);
|
||
this.dirty();
|
||
return offer;
|
||
}
|
||
|
||
/** Queue a free pick-one offer of items (凯瑟琳 定向投放 and similar); shown as shop.rewardOffer with kind 'item'. */
|
||
pushItemOffer(ids, { source = 'effect', tier = null } = {}) {
|
||
const list = (Array.isArray(ids) ? ids : []).filter((id) => this.gd.item(id)).slice(0, MAX_OFFER_SLOTS);
|
||
if (!list.length) return null;
|
||
const offer = { tier: Number.isInteger(tier) ? tier : null, source, slots: list.map((id) => ({ kind: 'item', id, price: 0, sold: false })) };
|
||
this.offers.push(offer);
|
||
this.dirty();
|
||
return offer;
|
||
}
|
||
|
||
/** Item merge candidates: normal, mergeable, with a golden version. */
|
||
_itemMergeable(id) {
|
||
const rec = this.gd.item(id);
|
||
if (!rec || rec.isGolden || rec.itemType !== 'EQUIP' || !rec.mergeable) return false;
|
||
const n = Number.isInteger(rec.upgradeNum) ? rec.upgradeNum : this.gd.itemMergeCount;
|
||
if (!(n > 1 && n < 100)) return false;
|
||
const gid = rec.upgradeChessId || rec.goldenId;
|
||
return !!(gid && this.gd.item(gid));
|
||
}
|
||
|
||
_itemLocations(id) {
|
||
const out = [];
|
||
for (let i = 0; i < this.temp.length; i++) if (this.temp[i] && this.temp[i].kind === 'item' && this.temp[i].id === id) out.push({ piece: this.temp[i], area: 'temp', idx: i });
|
||
for (let i = 0; i < this.hand.length; i++) if (this.hand[i] && this.hand[i].kind === 'item' && this.hand[i].id === id) out.push({ piece: this.hand[i], area: 'hand', idx: i });
|
||
for (const holder of this.allChess()) for (const it of holder.items || []) if (it.id === id) out.push({ piece: it, area: 'equipped', holder });
|
||
return out;
|
||
}
|
||
|
||
completesItemMerge(id) {
|
||
if (!this._itemMergeable(id)) return false;
|
||
const rec = this.gd.item(id);
|
||
const n = Number.isInteger(rec.upgradeNum) ? rec.upgradeNum : this.gd.itemMergeCount;
|
||
return this._itemLocations(id).length + 1 >= n;
|
||
}
|
||
|
||
/**
|
||
* Acquire an item (buy, supply card, grant). Merges with an identical normal copy (hand/temp/equipped) into the
|
||
* golden item (to the hand). Returns the owned piece or null.
|
||
*/
|
||
acquireItem(itemId, { source = 'grant', toTemp = false, silent = false } = {}) {
|
||
const rec = this.gd.item(itemId);
|
||
if (!rec) return null;
|
||
let piece = this.newPiece('item', itemId);
|
||
if (this.completesItemMerge(itemId)) {
|
||
piece = this._mergeItem(itemId, piece);
|
||
if (!piece) return null;
|
||
} else if (!this.stow(piece, { allowTemp: true, toTemp })) {
|
||
this.m.toast(this, 'warn', '整备区已满,获得的装备已销毁');
|
||
return null;
|
||
}
|
||
this.recompute();
|
||
if (!silent) this.m.dispatch(this, 'onGain', { piece, kind: 'item', source });
|
||
return piece;
|
||
}
|
||
|
||
_mergeItem(itemId, incoming = null) {
|
||
const rec = this.gd.item(itemId);
|
||
const need = Number.isInteger(rec.upgradeNum) ? rec.upgradeNum : this.gd.itemMergeCount;
|
||
const locs = this._itemLocations(itemId);
|
||
const consumed = incoming ? [{ piece: incoming, area: 'new' }] : [];
|
||
for (const l of locs) { if (consumed.length >= need) break; consumed.push(l); }
|
||
if (consumed.length < need) return null;
|
||
// remember where equipped twins sat: with a full hand AND a full temp the golden item takes the first one's slot
|
||
const slotOf = consumed.filter((l) => l.area === 'equipped').map((l) => ({ holder: l.holder, idx: l.holder.items.indexOf(l.piece) }));
|
||
for (const l of consumed) if (l.area !== 'new') this._detach(l);
|
||
const golden = this.newPiece('item', rec.upgradeChessId || rec.goldenId);
|
||
if (!this.stow(golden, { allowTemp: true })) {
|
||
const at = slotOf[0];
|
||
if (!at || !this.find(at.holder.uid)) {
|
||
this.m.toast(this, 'warn', '整备区已满,合成的装备已销毁');
|
||
return null;
|
||
}
|
||
at.holder.items.splice(Math.max(0, Math.min(at.idx, at.holder.items.length)), 0, golden);
|
||
}
|
||
this.stats.itemMerges++;
|
||
this.m.dispatch(this, 'onMerge', { kind: 'item', piece: golden, itemId, consumed: consumed.map((l) => l.piece.uid) });
|
||
return golden;
|
||
}
|
||
|
||
/** Merge every identical normal item pair currently owned (after equips / returns). */
|
||
checkItemMerges() {
|
||
for (let guard = 0; guard < 20; guard++) {
|
||
const counts = new Map();
|
||
const scan = (p) => { if (p && p.kind === 'item' && this._itemMergeable(p.id)) counts.set(p.id, (counts.get(p.id) || 0) + 1); };
|
||
for (const p of this.hand) scan(p);
|
||
for (const p of this.temp) scan(p);
|
||
for (const holder of this.allChess()) for (const it of holder.items || []) scan(it);
|
||
let did = false;
|
||
for (const [id, n] of counts) {
|
||
const rec = this.gd.item(id);
|
||
const need = Number.isInteger(rec.upgradeNum) ? rec.upgradeNum : this.gd.itemMergeCount;
|
||
if (n >= need) { if (this._mergeItem(id, null)) did = true; break; }
|
||
}
|
||
if (!did) return;
|
||
}
|
||
}
|
||
|
||
// =================================================================================================
|
||
// economy
|
||
|
||
addFunds(n, { reason = '' } = {}) {
|
||
if (!Number.isFinite(n) || n === 0) return 0;
|
||
const v = Math.trunc(n);
|
||
const before = this.funds;
|
||
this.funds = Math.max(0, this.funds + v);
|
||
if (v > 0) this.stats.fundsGained += v;
|
||
this.dirty();
|
||
return this.funds - before;
|
||
}
|
||
|
||
spend(n) {
|
||
const v = Number.isFinite(n) ? Math.max(0, Math.trunc(n)) : 0;
|
||
if (v > this.funds) return false;
|
||
this.funds -= v;
|
||
this.stats.gold += v;
|
||
this.round.spent += v;
|
||
this.dirty();
|
||
return true;
|
||
}
|
||
|
||
/** onSpend, dispatched once a payment's action is complete (buy / refresh / levelUp / reward / effect). */
|
||
_afterSpend(amount, reason) {
|
||
if (!(amount > 0)) return;
|
||
this.m.dispatch(this, 'onSpend', { amount, reason, total: this.stats.gold });
|
||
}
|
||
|
||
/** Bond layer gain (prep-side). `requireActive` = "使已激活的【X】层数+N". Returns layers added. */
|
||
addLayers(bondId, n, { requireActive = false, reason = '' } = {}) {
|
||
if (!this.gd.bond(bondId) || !Number.isFinite(n) || n <= 0) return 0;
|
||
if (requireActive && !(this.bonds[bondId] && this.bonds[bondId].active)) return 0;
|
||
const add = Math.floor(n);
|
||
if (add <= 0) return 0;
|
||
const before = this.layers[bondId] || 0;
|
||
this.layers[bondId] = before + add;
|
||
this.recompute();
|
||
this.m.dispatch(this, 'onLayers', { bondId, from: before, to: before + add, reason });
|
||
return add;
|
||
}
|
||
|
||
// =================================================================================================
|
||
// shop
|
||
|
||
/** Effective price of a shop slot after onPrice modifiers (never negative). */
|
||
priceOf(slot) {
|
||
if (!slot) return 0;
|
||
const ev = { slot, kind: slot.kind, id: slot.id, price: slot.basePrice };
|
||
this.m.dispatch(this, 'onPrice', ev, { quiet: true });
|
||
const p = Number(ev.price);
|
||
return Number.isFinite(p) ? Math.max(0, Math.round(p)) : slot.basePrice;
|
||
}
|
||
|
||
_rollChessSlot() {
|
||
const id = this.m.pool.roll(this.m.rngShop, { maxTier: this.shop.level });
|
||
return id ? { kind: 'chess', id, basePrice: this.gd.chessPrice(id), frozen: false, sold: false } : null;
|
||
}
|
||
|
||
_rollItemSlot() {
|
||
const id = this.m.pool.rollItem(this.m.rngShop, this.shop.level);
|
||
return id ? { kind: 'item', id, basePrice: this.gd.itemPrice(id), frozen: false, sold: false } : null;
|
||
}
|
||
|
||
/**
|
||
* Reroll the shop. keepFrozen → frozen unsold slots survive (round start); otherwise everything is rerolled
|
||
* (manual refresh). Slot counts follow the current level.
|
||
*/
|
||
rollShop({ keepFrozen = false } = {}) {
|
||
const { chess: nChess, item: nItem } = this.gd.shopSlots(this.shop.level);
|
||
const old = this.shop.slots;
|
||
const layout = this.shop.layout || { chess: old.length, item: 0 };
|
||
const oldChess = old.slice(0, layout.chess);
|
||
const oldItems = old.slice(layout.chess);
|
||
const keep = (s, kind) => (keepFrozen && s && s.kind === kind && !s.sold && s.frozen ? { ...s } : null);
|
||
// frozen slots keep their position; sold / empty / unfrozen positions are rerolled
|
||
const slots = [];
|
||
for (let i = 0; i < nChess; i++) slots.push(keep(oldChess[i], 'chess') ?? this._rollChessSlot());
|
||
for (let i = 0; i < nItem; i++) slots.push(keep(oldItems[i], 'item') ?? this._rollItemSlot());
|
||
this.shop.slots = slots;
|
||
this.shop.layout = { chess: nChess, item: nItem };
|
||
for (const s of slots) if (s) s.frozen = this.shop.frozen;
|
||
this.dirty();
|
||
}
|
||
|
||
/** Combat start: unfrozen slots are emptied (research 01 A1). */
|
||
clearUnfrozenShop() {
|
||
this.shop.slots = this.shop.slots.map((s) => (s && s.frozen && !s.sold ? s : null));
|
||
this.dirty();
|
||
}
|
||
|
||
// =================================================================================================
|
||
// gating
|
||
|
||
_gate({ allowWhenReady = false } = {}) {
|
||
if (!this.alive) return fail(ERR.ELIMINATED);
|
||
if (this.m.phase !== PHASE.PREP) return fail(ERR.WRONG_PHASE);
|
||
if (this.ready && !allowWhenReady) return fail(ERR.WRONG_PHASE, 'ready');
|
||
return null;
|
||
}
|
||
|
||
// =================================================================================================
|
||
// intent handlers
|
||
|
||
buy(slotIdx) {
|
||
const g = this._gate(); if (g) return g;
|
||
if (!Number.isInteger(slotIdx) || slotIdx < 0 || slotIdx >= this.shop.slots.length) return fail(ERR.BAD_TARGET);
|
||
const slot = this.shop.slots[slotIdx];
|
||
if (!slot) return fail(ERR.BAD_TARGET);
|
||
if (slot.sold) return fail(ERR.SOLD_OUT);
|
||
const price = this.priceOf(slot);
|
||
if (this.funds < price) return fail(ERR.NO_FUNDS);
|
||
const handFull = freeSlot(this.hand) < 0;
|
||
let piece;
|
||
if (slot.kind === 'chess') {
|
||
const rec = this.gd.chess(slot.id);
|
||
if (!rec) 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);
|
||
if (handFull && !this.completesChessMerge(slot.id)) return fail(ERR.HAND_FULL);
|
||
this.spend(price);
|
||
slot.sold = true;
|
||
piece = this.acquireChess(slot.id, { source: 'buy' });
|
||
} else {
|
||
if (!this.gd.item(slot.id)) return fail(ERR.BAD_TARGET);
|
||
if (handFull && !this.completesItemMerge(slot.id)) return fail(ERR.HAND_FULL);
|
||
this.spend(price);
|
||
slot.sold = true;
|
||
piece = this.acquireItem(slot.id, { source: 'buy' });
|
||
}
|
||
this.stats.buys++;
|
||
this.round.buys++;
|
||
this.m.dispatch(this, 'onBuy', { piece, slot, price, kind: slot.kind });
|
||
this._afterSpend(price, 'buy');
|
||
this.recompute();
|
||
return OK;
|
||
}
|
||
|
||
refresh() {
|
||
const g = this._gate(); if (g) return g;
|
||
const free = this.shop.freeRefreshes > 0;
|
||
const price = free ? 0 : this.gd.refreshPrice;
|
||
if (!free && this.funds < price) return fail(ERR.NO_FUNDS);
|
||
if (free) this.shop.freeRefreshes--;
|
||
else this.spend(price);
|
||
this.rollShop({ keepFrozen: false });
|
||
this.stats.refreshes++;
|
||
this.round.refreshes++;
|
||
this.m.dispatch(this, 'onRefresh', { slots: this.shop.slots, free, price });
|
||
this._afterSpend(price, 'refresh');
|
||
this.dirty();
|
||
return OK;
|
||
}
|
||
|
||
freeze() {
|
||
const g = this._gate(); if (g) return g;
|
||
this.shop.frozen = !this.shop.frozen;
|
||
for (const s of this.shop.slots) if (s && !s.sold) s.frozen = this.shop.frozen;
|
||
this.dirty();
|
||
return OK;
|
||
}
|
||
|
||
levelUp() {
|
||
const g = this._gate(); if (g) return g;
|
||
if (this.shop.level >= this.gd.maxShopLevel) return fail(ERR.MAX_LEVEL);
|
||
const price = Math.max(0, this.shop.upgradePrice);
|
||
if (this.funds < price) return fail(ERR.NO_FUNDS);
|
||
this.spend(price);
|
||
this.shop.level++;
|
||
this.shop.upgradePrice = this.gd.upgradeBase(this.shop.level) ?? 0;
|
||
this.m.tickerFor('SHOP_LEVEL', [this.name, String(this.shop.level)], { playerId: this.playerId, param: String(this.shop.level) });
|
||
this.m.dispatch(this, 'onLevelUp', { level: this.shop.level, price });
|
||
this._afterSpend(price, 'levelUp');
|
||
this.dirty();
|
||
return OK;
|
||
}
|
||
|
||
sell(uid) {
|
||
const g = this._gate(); if (g) return g;
|
||
const loc = this.find(uid);
|
||
if (!loc) return fail(ERR.BAD_TARGET);
|
||
if (loc.piece.kind !== 'chess') return fail(ERR.BAD_TARGET, loc.piece.kind === 'item' ? 'items cannot be sold' : 'tokens cannot be sold');
|
||
const piece = loc.piece;
|
||
// its equipment returns to the hand (overflow temp): refuse rather than destroy it when there is no room
|
||
const room = this.hand.filter((x) => x == null).length + this.temp.filter((x) => x == null).length + (loc.area === 'hand' || loc.area === 'temp' ? 1 : 0);
|
||
if ((piece.items || []).length > room) return fail(ERR.HAND_FULL, 'no room for the equipment');
|
||
this._detach(loc);
|
||
this.removeTokensOf(piece.uid);
|
||
for (const it of piece.items || []) {
|
||
if (!this.stow(it, { allowTemp: true })) this.m.log.warn?.(`[match ${this.m.roomCode}] ${this.playerId}: item ${it.id} lost on sell (no space)`);
|
||
}
|
||
piece.items = [];
|
||
this.returnCopies(piece);
|
||
const ev = { piece, gain: this.gd.sellPrice(piece.id) };
|
||
this.m.dispatch(this, 'onSold', ev);
|
||
const gain = Number.isFinite(ev.gain) ? Math.max(0, Math.trunc(ev.gain)) : 1;
|
||
this.addFunds(gain, { reason: 'sell' });
|
||
this.stats.sells++;
|
||
this.round.sells++;
|
||
this.checkItemMerges();
|
||
this.recompute();
|
||
return OK;
|
||
}
|
||
|
||
/**
|
||
* g.move {uid, to, dir?}. Pieces: chess / token between board, hand and temp; items only within the hand. `dir`
|
||
* (UP|RIGHT|DOWN|LEFT, absent ⇒ RIGHT; `to.dir` is read when the top-level one is absent) is the facing of a piece
|
||
* moved onto the board — onto its own tile it re-orients the piece in place.
|
||
*/
|
||
move(uid, to, dir) {
|
||
const g = this._gate(); if (g) return g;
|
||
const loc = this.find(uid);
|
||
if (!loc || loc.area === 'equipped') return fail(ERR.BAD_TARGET);
|
||
if (!to || typeof to !== 'object') return fail(ERR.BAD_TARGET);
|
||
const piece = loc.piece;
|
||
if (to.area === 'board') {
|
||
if (piece.kind === 'item') return fail(ERR.BAD_TARGET, 'items are equipped, not placed');
|
||
const d = parseDir(dir !== undefined ? dir : to.dir);
|
||
if (!d) return fail(ERR.BAD_TARGET, 'bad dir');
|
||
return piece.kind === 'token' ? this._moveTokenToBoard(loc, to.row, to.col, d) : this._moveChessToBoard(loc, to.row, to.col, d);
|
||
}
|
||
if (to.area === 'hand') {
|
||
if (!Number.isInteger(to.idx) || to.idx < 0 || to.idx >= this.hand.length) return fail(ERR.BAD_TARGET);
|
||
return this._moveToHand(loc, to.idx);
|
||
}
|
||
return fail(ERR.BAD_TARGET);
|
||
}
|
||
|
||
_placementOf(piece) {
|
||
const rec = piece.kind === 'token' ? this.gd.token(piece.id) : this.gd.chess(piece.id);
|
||
return positionClass(rec);
|
||
}
|
||
|
||
_legal(piece, r, c) { return canPlace(this.deployMap(), this._placementOf(piece), r, c); }
|
||
|
||
_moveChessToBoard(loc, r, c, dir = 'RIGHT') {
|
||
const piece = loc.piece;
|
||
if (!inField(r, c) || !this._legal(piece, r, c)) return fail(ERR.BAD_TILE);
|
||
const key = tileKey(r, c);
|
||
const occ = this.board.get(key) || null;
|
||
if (occ === piece) return this._reorient(piece, dir);
|
||
if (loc.area === 'board') {
|
||
// board → board: move or swap (the occupant must be legal on the source tile and keeps its own facing)
|
||
if (occ) {
|
||
const [sr, sc] = parseKey(loc.key);
|
||
if (!this._legal(occ, sr, sc)) return fail(ERR.BAD_TILE);
|
||
this.board.set(loc.key, occ);
|
||
} else {
|
||
this.board.delete(loc.key);
|
||
}
|
||
piece.dir = dir;
|
||
this.board.set(key, piece);
|
||
this.recompute();
|
||
return OK;
|
||
}
|
||
// hand/temp → board
|
||
if (!occ || occ.kind !== 'chess') {
|
||
if (this.deployCount >= this.deployCap) return fail(ERR.BOARD_FULL);
|
||
}
|
||
this._detach(loc);
|
||
if (occ) {
|
||
this.board.delete(key);
|
||
if (occ.kind === 'chess') {
|
||
this.removeTokensOf(occ.uid);
|
||
this._putBack(loc, occ);
|
||
} else {
|
||
this._returnToken(occ, loc);
|
||
}
|
||
}
|
||
piece.dir = dir;
|
||
this.board.set(key, piece);
|
||
this.grantTokensFor(piece);
|
||
this.recompute();
|
||
return OK;
|
||
}
|
||
|
||
/** In-place re-orientation (g.move onto the piece's own tile with a new direction). */
|
||
_reorient(piece, dir) {
|
||
if (pieceDir(piece) !== dir) { piece.dir = dir; this.dirty(); }
|
||
return OK;
|
||
}
|
||
|
||
/** Put a piece into the container slot described by `loc` (hand/temp idx), or anywhere free. */
|
||
_putBack(loc, piece) {
|
||
if (loc.area === 'hand' && this.hand[loc.idx] == null) { this.hand[loc.idx] = piece; return true; }
|
||
if (loc.area === 'temp' && this.temp[loc.idx] == null) { this._putTemp(loc.idx, piece); return true; }
|
||
return !!this.stow(piece, { allowTemp: true });
|
||
}
|
||
|
||
/**
|
||
* A board token goes back to the hand: merge into its owner's stack, else occupy a free slot. `allowTemp: false`
|
||
* (the player's own withdrawal): a summon stack is a card, so with a full hand and no stack to join it is refused
|
||
* like any other card (research 01 A1) instead of overflowing into temp.
|
||
*/
|
||
_returnToken(tok, preferLoc = null, { allowTemp = true } = {}) {
|
||
const stack = [...this.hand, ...this.temp].find((p) => p && p.kind === 'token' && p.ownerUid === tok.ownerUid && p.id === tok.id);
|
||
if (stack) { stack.count = (stack.count || 1) + (tok.count || 1); return true; }
|
||
tok.count = tok.count || 1;
|
||
if (preferLoc && preferLoc.area === 'hand' && this.hand[preferLoc.idx] == null) { this.hand[preferLoc.idx] = tok; return true; }
|
||
return !!this.stow(tok, { allowTemp });
|
||
}
|
||
|
||
_moveTokenToBoard(loc, r, c, dir = 'RIGHT') {
|
||
const piece = loc.piece;
|
||
if (!inField(r, c) || !this._legal(piece, r, c)) return fail(ERR.BAD_TILE);
|
||
const key = tileKey(r, c);
|
||
const occ = this.board.get(key) || null;
|
||
if (occ === piece) return this._reorient(piece, dir);
|
||
if (loc.area === 'board') {
|
||
if (occ) {
|
||
const [sr, sc] = parseKey(loc.key);
|
||
if (!this._legal(occ, sr, sc)) return fail(ERR.BAD_TILE);
|
||
this.board.set(loc.key, occ);
|
||
} else {
|
||
this.board.delete(loc.key);
|
||
}
|
||
piece.dir = dir;
|
||
this.board.set(key, piece);
|
||
this.recompute();
|
||
return OK;
|
||
}
|
||
if (occ) return fail(ERR.BAD_TILE, 'occupied');
|
||
// owner must be on the board for its summons to be deployable
|
||
const owner = [...this.board.values()].find((p) => p.uid === piece.ownerUid);
|
||
if (!owner) return fail(ERR.BAD_TARGET, 'owner not deployed');
|
||
if ((piece.count || 1) > 1) {
|
||
piece.count -= 1;
|
||
const one = this.newPiece('token', piece.id, { count: 1, ownerUid: piece.ownerUid, dir });
|
||
this.board.set(key, one);
|
||
} else {
|
||
this._detach(loc);
|
||
piece.count = 1;
|
||
piece.dir = dir;
|
||
this.board.set(key, piece);
|
||
}
|
||
this.recompute();
|
||
return OK;
|
||
}
|
||
|
||
_moveToHand(loc, idx) {
|
||
const piece = loc.piece;
|
||
const occ = this.hand[idx];
|
||
if (occ === piece) return OK;
|
||
if (piece.kind === 'token') {
|
||
if (loc.area !== 'board') {
|
||
// hand/temp token stack: plain slot move / swap inside the containers
|
||
return this._swapContainers(loc, idx);
|
||
}
|
||
this.board.delete(loc.key);
|
||
if (!this._returnToken(piece, occ == null ? { area: 'hand', idx } : null, { allowTemp: false })) {
|
||
this.board.set(loc.key, piece);
|
||
return fail(ERR.HAND_FULL);
|
||
}
|
||
this.recompute();
|
||
return OK;
|
||
}
|
||
if (loc.area === 'board') {
|
||
// withdraw (撤退): to an empty slot, or swap with a chess occupant (which takes the board tile). The piece's own
|
||
// summon stacks leave the hand with it, so a slot holding one counts as free (a full hand nets zero cards)
|
||
const ownStack = (p) => !!p && p.kind === 'token' && p.ownerUid === piece.uid;
|
||
if (occ == null || ownStack(occ)) {
|
||
this.board.delete(loc.key);
|
||
this.removeTokensOf(piece.uid);
|
||
this.hand[idx] = piece;
|
||
} else if (occ.kind === 'chess') {
|
||
const [sr, sc] = parseKey(loc.key);
|
||
if (!this._legal(occ, sr, sc)) return fail(ERR.BAD_TILE);
|
||
// the bench card takes the withdrawn piece's tile with that tile's facing
|
||
occ.dir = pieceDir(piece);
|
||
this.board.set(loc.key, occ);
|
||
this.hand[idx] = piece;
|
||
this.removeTokensOf(piece.uid);
|
||
this.grantTokensFor(occ);
|
||
} else {
|
||
let j = freeSlot(this.hand);
|
||
if (j < 0) j = this.hand.findIndex(ownStack);
|
||
if (j < 0) return fail(ERR.HAND_FULL);
|
||
this.board.delete(loc.key);
|
||
this.removeTokensOf(piece.uid);
|
||
this.hand[j] = piece;
|
||
}
|
||
this.recompute();
|
||
return OK;
|
||
}
|
||
return this._swapContainers(loc, idx);
|
||
}
|
||
|
||
/** hand/temp → hand slot: move into an empty slot or swap with the occupant. */
|
||
_swapContainers(loc, idx) {
|
||
const piece = loc.piece;
|
||
const occ = this.hand[idx];
|
||
this._detach(loc);
|
||
this.hand[idx] = piece;
|
||
if (occ) {
|
||
if (loc.area === 'hand') this.hand[loc.idx] = occ;
|
||
else this._putTemp(loc.idx, occ);
|
||
}
|
||
this.recompute();
|
||
return OK;
|
||
}
|
||
|
||
/**
|
||
* g.equip {itemUid, targetUid, replaceUid?}. A third item on a carrier with both slots used replaces the equipped item
|
||
* the player picked in the replace dialog (`replaceUid`, research 09 §1.2 UseEquipUp.unloadInstId; absent ⇒ the
|
||
* oldest); the replaced item is destroyed. A `replaceUid` that is not one of the target's equipped items is refused.
|
||
*/
|
||
equip(itemUid, targetUid, replaceUid = null) {
|
||
const g = this._gate(); if (g) return g;
|
||
const iloc = this.find(itemUid);
|
||
if (!iloc || iloc.piece.kind !== 'item' || (iloc.area !== 'hand' && iloc.area !== 'temp')) return fail(ERR.BAD_TARGET);
|
||
const rec = this.gd.item(iloc.piece.id);
|
||
if (!rec || rec.itemType !== 'EQUIP') return fail(ERR.BAD_TARGET, 'not equipment');
|
||
const tloc = this.find(targetUid);
|
||
if (!tloc || tloc.piece.kind !== 'chess' || tloc.area === 'equipped') return fail(ERR.BAD_TARGET);
|
||
const item = iloc.piece;
|
||
const target = tloc.piece;
|
||
if (replaceUid != null && !(Number.isInteger(replaceUid) && (target.items || []).some((x) => x.uid === replaceUid))) return fail(ERR.BAD_TARGET, 'replace: not equipped on the target');
|
||
const consume = typeof rec.kind === 'string' && rec.kind.startsWith('consume_on_equip');
|
||
if (consume) {
|
||
const key = 'item:' + itemKey(item.id);
|
||
if (!this.m.registry.has(key)) return fail(ERR.BAD_TARGET, 'effect not available');
|
||
const ev = { item, target, golden: !!rec.isGolden, keep: false, error: null, consumed: true };
|
||
this.m.dispatchItem(this, item, target, 'onEquip', ev);
|
||
if (ev.error) return fail(ERR[ev.error] ? ev.error : ERR.BAD_TARGET, typeof ev.detail === 'string' ? ev.detail : undefined);
|
||
// the handler may have destroyed the target (信标) — resolve the item again
|
||
const again = this.find(item.uid);
|
||
if (again && again.area !== 'equipped') this._detach(again);
|
||
if (ev.keep) {
|
||
const holder = this.find(target.uid);
|
||
if (holder && holder.piece.kind === 'chess') this._attach(holder.piece, item);
|
||
}
|
||
this.stats.itemsEquipped++;
|
||
this.checkItemMerges();
|
||
this.recompute();
|
||
return OK;
|
||
}
|
||
this._detach(iloc);
|
||
if (this.completesItemMerge(item.id)) {
|
||
// an identical normal copy is already owned (equipped somewhere): merge instead of equipping — the golden
|
||
// item goes to the hand (research 04 §2 "copies in hand and on operators both count")
|
||
if (this._mergeItem(item.id, item)) { this.recompute(); return OK; }
|
||
if (!this.find(item.uid)) this.stow(item, { allowTemp: true });
|
||
}
|
||
this._attach(target, item, replaceUid);
|
||
this.stats.itemsEquipped++;
|
||
this.m.dispatchItem(this, item, target, 'onEquip', { item, target, golden: !!rec.isGolden, consumed: false });
|
||
this.checkItemMerges();
|
||
this.recompute();
|
||
return OK;
|
||
}
|
||
|
||
/**
|
||
* Attach an item. With both slots used the item `replaceUid` names (the player's pick) is replaced, else the oldest;
|
||
* the replaced item is destroyed.
|
||
*/
|
||
_attach(target, item, replaceUid = null) {
|
||
target.items = target.items || [];
|
||
while (target.items.length >= this.gd.equipPerChess) {
|
||
const i = replaceUid != null ? target.items.findIndex((x) => x.uid === replaceUid) : -1;
|
||
const [old] = target.items.splice(i >= 0 ? i : 0, 1);
|
||
replaceUid = null;
|
||
this.m.dispatchItem(this, old, target, 'onDestroy', { item: old, holder: target, reason: 'replace' });
|
||
}
|
||
target.items.push(item);
|
||
}
|
||
|
||
/** g.art {itemUid, row, col, dir?}: the Art's range grid is rotated by `dir` (absent ⇒ RIGHT). */
|
||
useArt(itemUid, row, col, dir) {
|
||
const g = this._gate(); if (g) return g;
|
||
const d = parseDir(dir);
|
||
if (!d) return fail(ERR.BAD_TARGET, 'bad dir');
|
||
const loc = this.find(itemUid);
|
||
if (!loc || loc.piece.kind !== 'item' || (loc.area !== 'hand' && loc.area !== 'temp')) return fail(ERR.BAD_TARGET);
|
||
const rec = this.gd.item(loc.piece.id);
|
||
if (!rec || rec.itemType !== 'MAGIC') return fail(ERR.BAD_TARGET, 'not an art');
|
||
if (!inField(row, col)) return fail(ERR.BAD_TILE);
|
||
if (this.round.arts >= this.gd.maxArtsPerRound) return fail(ERR.BAD_TARGET, 'art limit');
|
||
const key = 'item:' + itemKey(loc.piece.id);
|
||
if (!this.m.registry.has(key)) return fail(ERR.BAD_TARGET, 'effect not available');
|
||
const grid = Array.isArray(rec.rangeGrid) && rec.rangeGrid.length ? rec.rangeGrid : [[0, 0]];
|
||
const targets = [];
|
||
for (const [dr, dc] of grid) {
|
||
const [tr, tc] = offsetTile(row, col, dr, dc, d);
|
||
const p = this.board.get(tileKey(tr, tc));
|
||
if (p) targets.push(p);
|
||
}
|
||
const ev = { item: loc.piece, row, col, dir: d, targets, error: null, used: true };
|
||
this.m.dispatchItem(this, loc.piece, null, 'onArt', ev);
|
||
if (ev.error) return fail(ERR[ev.error] ? ev.error : ERR.BAD_TARGET, typeof ev.detail === 'string' ? ev.detail : undefined);
|
||
if (ev.used !== false) {
|
||
const again = this.find(itemUid);
|
||
if (again) this._detach(again);
|
||
this.round.arts++;
|
||
}
|
||
this.recompute();
|
||
return OK;
|
||
}
|
||
|
||
/**
|
||
* g.destroy {uid}: an item in the hand / temp (Arts included). Equipped items are locked (research 04 §2 / addendum:
|
||
* they leave the operator only on promotion, merge or sale); replacing one is g.equip's `replaceUid`.
|
||
*/
|
||
destroy(uid) {
|
||
const g = this._gate(); if (g) return g;
|
||
const loc = this.find(uid);
|
||
if (!loc || loc.piece.kind !== 'item') return fail(ERR.BAD_TARGET, 'only items can be destroyed');
|
||
if (loc.area === 'equipped') return fail(ERR.BAD_TARGET, 'equipped items are locked');
|
||
this._detach(loc);
|
||
this.m.dispatchItem(this, loc.piece, loc.holder || null, 'onDestroy', { item: loc.piece, holder: loc.holder || null, reason: 'player' });
|
||
this.recompute();
|
||
return OK;
|
||
}
|
||
|
||
pickReward(idx) {
|
||
const g = this._gate(); if (g) return g;
|
||
const offer = this.offers[0];
|
||
if (!offer) return fail(ERR.BAD_TARGET, 'no reward');
|
||
if (!Number.isInteger(idx) || idx < 0 || idx >= offer.slots.length) return fail(ERR.BAD_TARGET);
|
||
const slot = offer.slots[idx];
|
||
if (!slot || slot.sold) return fail(ERR.SOLD_OUT);
|
||
const handFull = freeSlot(this.hand) < 0;
|
||
if (slot.kind === 'item') {
|
||
if (!this.gd.item(slot.id)) return fail(ERR.BAD_TARGET);
|
||
if (handFull && !this.completesItemMerge(slot.id)) return fail(ERR.HAND_FULL);
|
||
} else {
|
||
const rec = this.gd.chess(slot.id);
|
||
if (!rec) 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);
|
||
if (handFull && !this.completesChessMerge(slot.id)) return fail(ERR.HAND_FULL);
|
||
}
|
||
const price = Number.isFinite(slot.price) && slot.price > 0 ? Math.trunc(slot.price) : 0;
|
||
if (price > this.funds) return fail(ERR.NO_FUNDS);
|
||
slot.sold = true;
|
||
this.offers.shift();
|
||
if (price > 0) this.spend(price);
|
||
if (slot.kind === 'item') this.acquireItem(slot.id, { source: 'reward' });
|
||
else this.acquireChess(slot.id, { source: 'reward' });
|
||
this._afterSpend(price, 'reward');
|
||
this.recompute();
|
||
return OK;
|
||
}
|
||
|
||
setReady(on) {
|
||
if (!this.alive) return fail(ERR.ELIMINATED);
|
||
if (this.m.phase !== PHASE.PREP) return fail(ERR.WRONG_PHASE);
|
||
if (on && !this.tempEmpty) return fail(ERR.TEMP_NOT_EMPTY);
|
||
if (this.ready === !!on) return OK;
|
||
this.ready = !!on;
|
||
// un-ready: the player can act again, so what overflowed while it was ready is due at this prep's deadline
|
||
if (!on) for (const p of this.temp) if (p && this.tempDue(p) > this.prepsEnded) this._tempDue.set(p.uid, this.prepsEnded);
|
||
this.dirty();
|
||
this.m.onReadyChanged(this);
|
||
return OK;
|
||
}
|
||
|
||
/**
|
||
* Prep deadline: every temp piece due at this prep (tempDue ≤ prepsEnded) is resolved — a chess is sold back (its
|
||
* pool copies return, its summons are removed), items and summon stacks are destroyed. Pieces that overflowed after
|
||
* the player could no longer act on them (after Ready, at the prep end) are kept for the next prep.
|
||
*/
|
||
resolveTemp() {
|
||
let changed = false;
|
||
for (let i = 0; i < this.temp.length; i++) {
|
||
const p = this.temp[i];
|
||
if (!p || this.tempDue(p) > this.prepsEnded) continue;
|
||
this.temp[i] = null;
|
||
this._tempDue.delete(p.uid);
|
||
changed = true;
|
||
if (p.kind === 'chess') {
|
||
this.removeTokensOf(p.uid);
|
||
this.returnCopies(p);
|
||
}
|
||
}
|
||
if (changed) this.recompute();
|
||
}
|
||
|
||
// =================================================================================================
|
||
// round lifecycle helpers (called by Match)
|
||
|
||
startRound(r) {
|
||
this.round = { refreshes: 0, buys: 0, sells: 0, spent: 0, gainedChess: 0, arts: 0 };
|
||
if (r > 1) this.shop.upgradePrice = Math.max(0, this.shop.upgradePrice - 1);
|
||
// onIncome handlers may rewrite ev.income / ev.pending (e.g. 老鲤 withholds R1–R2 income until R3)
|
||
const ev = { round: r, income: this.gd.income(r), pending: this.pendingFunds };
|
||
this.pendingFunds = 0;
|
||
this.m.dispatch(this, 'onIncome', ev);
|
||
const nonNeg = (v) => (Number.isFinite(v) && v > 0 ? Math.trunc(v) : 0);
|
||
this.addFunds(nonNeg(ev.income) + nonNeg(ev.pending), { reason: 'income' });
|
||
// temp is NOT wiped here: the last prep's deadline resolved what the player could act on (endPrep); what overflowed
|
||
// after it (battle-result grants, SETTLE merges, returned equipment) is shown and usable in this prep (tempDue).
|
||
// Likewise reward offers of the last prep already expired at its end; what is still queued was earned after it —
|
||
// a merge completed during SETTLE / the Final Assault (突变细胞, battle-result grants) — and is shown in this prep
|
||
this.ready = false;
|
||
this.rollShop({ keepFrozen: true });
|
||
this.shop.frozen = false;
|
||
for (const s of this.shop.slots) if (s) s.frozen = false;
|
||
this.recompute();
|
||
}
|
||
|
||
/**
|
||
* Prep deadline (Match.endPrep, after the <休整期结束时> onPrepEnd effects): the temp pieces due at this prep are
|
||
* resolved; what overflowed after Ready or during onPrepEnd stays for the next prep, which `prepsEnded` now names.
|
||
*/
|
||
endPrep() {
|
||
this.resolveTemp();
|
||
this.prepsEnded++;
|
||
for (const uid of [...this._tempDue.keys()]) if (!this.temp.some((p) => p && p.uid === uid)) this._tempDue.delete(uid);
|
||
this.offers = [];
|
||
this.clearUnfrozenShop();
|
||
if (!this.gd.leftoverKeptBands.includes(this.bandId)) this.funds = 0;
|
||
this.ready = true;
|
||
this.dirty();
|
||
}
|
||
|
||
eliminate(round) {
|
||
this.alive = false;
|
||
this.ready = false;
|
||
this.eliminatedRound = round;
|
||
const all = [];
|
||
for (const p of this.board.values()) all.push(p);
|
||
for (const p of this.hand) if (p) all.push(p);
|
||
for (const p of this.temp) if (p) all.push(p);
|
||
for (const p of all) this.returnCopies(p);
|
||
this.board.clear();
|
||
this.hand.fill(null);
|
||
this.temp.fill(null);
|
||
this._tempDue.clear();
|
||
this.offers = [];
|
||
this.bounties = [];
|
||
this.shop.slots = [];
|
||
this.funds = 0;
|
||
this.pendingFunds = 0;
|
||
this.recompute();
|
||
}
|
||
|
||
recompute() {
|
||
if (this._legalityStale) this._evictIllegal();
|
||
this.bonds = computeBonds(this.gd, this);
|
||
this.dirty();
|
||
}
|
||
|
||
activatedLayers() { return activatedLayers(this.bonds); }
|
||
|
||
// =================================================================================================
|
||
// battle input
|
||
|
||
battleInput({ side = 'L', colOffset = 0, carry = null } = {}) {
|
||
// a terrain change not yet followed by a recompute (a content hook at the prep end) never fields an illegal board
|
||
if (this._legalityStale) this.recompute();
|
||
const units = [];
|
||
for (const { r, c, piece } of boardOrder(this.board)) {
|
||
if (piece.kind === 'chess') {
|
||
const u = { uid: piece.uid, kind: 'chess', chessId: piece.id, row: r, col: c, dir: pieceDir(piece), items: (piece.items || []).map((i) => i.id) };
|
||
// DESIGN §16: the equipped skill / module (elite only) from the loadout (defaults when absent)
|
||
const lo = this.loadoutFor(this.gd.chess(piece.id));
|
||
u.skillIndex = lo.skillIndex;
|
||
u.moduleId = lo.moduleId;
|
||
if (carry && carry.has(piece.uid)) u.carryState = carry.get(piece.uid);
|
||
units.push(u);
|
||
} else if (piece.kind === 'token') {
|
||
units.push({ uid: piece.uid, kind: 'token', tokenId: piece.id, row: r, col: c, dir: pieceDir(piece), ownerUid: piece.ownerUid });
|
||
}
|
||
}
|
||
return {
|
||
playerId: this.playerId,
|
||
seat: this.seat,
|
||
side,
|
||
colOffset,
|
||
units,
|
||
bonds: bondSnapshot(this.bonds),
|
||
bandId: this.bandId,
|
||
playerEffects: this.effects.filter((e) => e.battle !== false).map((e) => ({
|
||
id: e.id, key: e.key ?? null, source: e.iconKind ?? null, params: e.params ?? null, counter: e.counter ?? null, data: e.data ?? null,
|
||
})),
|
||
deviceOverrides: { ...this.deviceOverrides },
|
||
};
|
||
}
|
||
|
||
// =================================================================================================
|
||
// views
|
||
|
||
pieceView(p, rc = null) {
|
||
const rec = p.kind === 'item' ? this.gd.item(p.id) : p.kind === 'token' ? this.gd.token(p.id) : this.gd.chess(p.id);
|
||
const v = {
|
||
uid: p.uid,
|
||
kind: p.kind,
|
||
id: p.id,
|
||
golden: !!(rec && rec.isGolden),
|
||
tier: rec && Number.isInteger(rec.tier) ? rec.tier : null,
|
||
items: p.kind === 'chess' ? (p.items || []).map((it) => ({ uid: it.uid, id: it.id })) : [],
|
||
count: p.kind === 'token' ? (p.count || 1) : 1,
|
||
ownerUid: p.kind === 'token' ? p.ownerUid ?? null : null,
|
||
};
|
||
if (rc) { v.row = rc[0]; v.col = rc[1]; v.dir = pieceDir(p); }
|
||
return v;
|
||
}
|
||
|
||
effectsView() {
|
||
const out = [];
|
||
const band = this.bandId ? this.gd.band(this.bandId) : null;
|
||
if (band) out.push({ id: band.effectId || band.bandId, name: band.effectName || band.name, desc: band.desc || '', iconKind: 'band', iconId: band.iconId || band.bandId });
|
||
for (const e of this.effects) {
|
||
if (e.hidden) continue;
|
||
const v = { id: e.id, name: e.name || e.id, desc: e.desc || '', iconKind: e.iconKind || 'choice', iconId: e.iconId || e.id };
|
||
if (e.counter != null) v.counter = e.counter;
|
||
out.push(v);
|
||
}
|
||
for (const b of this.bounties) {
|
||
out.push({ id: b.id, name: b.card.name || '悬赏', desc: b.card.desc || '', iconKind: 'choice', iconId: b.card.effectId || 'bounty', counter: b.roundsLeft >= 90 ? null : b.roundsLeft });
|
||
}
|
||
return out;
|
||
}
|
||
|
||
privateView() {
|
||
const slots = this.shop.slots.map((s) => (s ? { kind: s.kind, id: s.id, price: this.priceOf(s), basePrice: s.basePrice, sold: !!s.sold, frozen: !!s.frozen } : null));
|
||
const offer = this.offers[0] || null;
|
||
const free = this.shop.freeRefreshes > 0;
|
||
const board = [];
|
||
for (const { r, c, piece } of boardOrder(this.board)) board.push(this.pieceView(piece, [r, c]));
|
||
return {
|
||
t: 'm.private',
|
||
playerId: this.playerId,
|
||
seat: this.seat,
|
||
alive: this.alive,
|
||
lp: this.lp,
|
||
funds: this.funds,
|
||
bandId: this.bandId,
|
||
ready: this.ready,
|
||
canReady: this.alive && this.tempEmpty && this.m.phase === PHASE.PREP,
|
||
shop: {
|
||
level: this.shop.level,
|
||
maxLevel: this.gd.maxShopLevel,
|
||
upgradePrice: this.shop.level >= this.gd.maxShopLevel ? 0 : this.shop.upgradePrice,
|
||
refreshPrice: free ? 0 : this.gd.refreshPrice,
|
||
freeRefreshes: this.shop.freeRefreshes,
|
||
frozen: this.shop.frozen,
|
||
slots,
|
||
rewardOffer: offer ? { tier: offer.tier, slots: offer.slots.map((s) => ({ kind: s.kind === 'item' ? 'item' : 'chess', id: s.id, price: s.price, sold: !!s.sold })) } : null,
|
||
},
|
||
hand: this.hand.map((p) => (p ? this.pieceView(p) : null)),
|
||
temp: this.temp.map((p) => (p ? this.pieceView(p) : null)),
|
||
board,
|
||
deployCap: this.deployCap,
|
||
deployCount: this.deployCount,
|
||
bonds: bondList(this.gd, this.bonds, { full: true }),
|
||
effects: this.effectsView(),
|
||
nextEnemies: this.m.nextEnemiesFor(this),
|
||
// DESIGN §16: the effective operator loadout ({ [baseChessId]: { skill, module } }; chess not listed use defaults)
|
||
loadout: this.loadout,
|
||
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,
|
||
},
|
||
};
|
||
}
|
||
}
|
||
|
||
export { FIELD };
|