feedback5 R2: PlayerState.js → server/match/player/*.js, a pure move — the 87 class members (82 methods and the isHumanActive / botControlled / deployCap / deployCount / tempEmpty getters), verbatim with their comments, go to 9 per-concern method containers (basics, pieces, acquire, economy, placement, items, prep, round, views) that PlayerState.js installs on PlayerState.prototype in a fixed order with class-method descriptors (a name defined twice throws); HAND_SIZE, TEMP_SIZE, MAX_OFFER_SLOTS, OK and fail go to player/common.js; PlayerState.js keeps the header (+ a code-layout paragraph), the class with its constructor and the FIELD re-export. Section-header comments are replaced by the module headers. docs-consistency reads the method modules (playerText), pins layerGainRoom in player/economy.js and checks that no Match / PlayerState module has a layerRoom
This commit is contained in:
1 parent
1ca4936f0a
commit
64443b4aab
12 files changed
+1631
-1519
No files matched your search
+39
-1517
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,336 @@
|
||||
// server/match/player/acquire.js — PlayerState methods: gains and merges — copy counts, acquireChess (hand, overflow
|
||||
// temp, the no-room rule) and the chess merge (3 copies → the elite, on a consumed deployed copy's tile or in the hand;
|
||||
// the reward offer), promotion, transformation (突变细胞: a destroy, then a gain), item upgrades, reward / item offers,
|
||||
// acquireItem and the item merges (2 identical → the golden item; deferred merges at the next prep start).
|
||||
// Installed on PlayerState.prototype by server/match/PlayerState.js (a method container: never instantiated; `this` is
|
||||
// the player state).
|
||||
|
||||
import { pieceDir, parseDir, mergeTile } from '../board.js';
|
||||
import { MAX_OFFER_SLOTS } from './common.js';
|
||||
|
||||
export class PlayerAcquire {
|
||||
/** 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 — PRTS 卫戍协议/帮助 §干员的获得与精锐化: "发送1名【精锐】状态的该干员至手牌区(若消耗已部署至作战区的干员,
|
||||
* 则发送至作战区对应位置)" (the user's playtest #6 follow-up confirms it). The tile (`mergeTile`): when a consumed copy
|
||||
* stood on the board the elite takes its tile and facing — of several, the one that deploys first (the left board
|
||||
* column first, top to bottom within a column — Battle.start's order) [ASSUMED]. The incoming copy is never deployed (a 突变细胞 transformation
|
||||
* destroyed its carrier before the gain: that tile is no copy's). It replaces a deployed copy, so the deploy count
|
||||
* never grows. Otherwise the elite goes to the hand, overflow temp — outside PREP too (a SETTLE merge's elite waits in
|
||||
* temp through the next prep, tempDue). The copies' equipment returns to the hand ("干员晋级后已配发装备会回收至整备区";
|
||||
* overflow temp; with both full it stays on the elite, up to its equipPerChess (2) slots — any further item is
|
||||
* destroyed with a log warning, as before the official rule) and an identical normal pair among it merges like any gain
|
||||
* (checkItemMerges); their summons are removed, and an elite on the board
|
||||
* gets its own summon stack (grantTokensFor: its loadout, like any deployment). Returns the elite piece (or null if
|
||||
* the elite could not be stored).
|
||||
* @param {string} baseId
|
||||
* @param {any} incoming the acquired, not yet stowed copy (null: only owned copies)
|
||||
*/
|
||||
_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 });
|
||||
// this round's per-piece counters: the highest of the copies' (an elite made from 拉普兰德 that already saw their
|
||||
// first refresh this round does not fire again this round — [ASSUMED] conservative, pieceRoundCount)
|
||||
for (const l of consumed) {
|
||||
const rc = l.piece.meta && l.piece.meta.round;
|
||||
if (rc && rc.r === this.m.round) for (const [k, v] of Object.entries(rc.n)) this.bumpPieceRoundCount(elite, k, Math.max(0, v - this.pieceRoundCount(elite, k)));
|
||||
}
|
||||
const deployed = consumed.filter((l) => l.key && !this.board.has(l.key)).map((l) => ({ key: l.key, dir: pieceDir(l.piece) }));
|
||||
const toTile = (t) => { elite.dir = parseDir(t.dir) || 'RIGHT'; this.board.set(t.key, elite); return 'board'; };
|
||||
const tile = mergeTile(deployed, (r, c) => this._legal(elite, r, c));
|
||||
let where = tile ? toTile(tile) : this.stow(elite, { allowTemp: true });
|
||||
// hand and temp full and no deployed tile legal for it (a terrain change not re-checked yet): it stays on the first
|
||||
// deployed copy's tile rather than being lost, like a piece _evictIllegal finds no room for
|
||||
if (!where && deployed.length) where = toTile(mergeTile(deployed));
|
||||
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)`);
|
||||
}
|
||||
// the returned equipment follows the auto-merge rule like any other gain ("已拥有2件同一初始装备时…自动合并")
|
||||
this.checkItemMerges();
|
||||
// deployed like any operator placed by hand: its manually deployable summons join the hand (after the returned
|
||||
// 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.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), area: where });
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transformation (突变细胞 "战斗结束后,装备者替换为高一阶的随机干员"; PRTS 卫戍协议:盟约 下半/PRTS盟约记录 备注 "生效时,原
|
||||
* 干员销毁,获得一名高一阶的随机初始干员(最高六阶)"): a destroy followed by a gain. The carrier is destroyed wherever it
|
||||
* stands — a board tile is freed (the deploy count drops), its summons are removed, its pool copies return. Its
|
||||
* equipment, the cell included, comes off first (PRTS 卫戍协议/帮助 "在失去该干员(干员出售、销毁、合并等)…时自动卸除"): to
|
||||
* the hand, overflowing into temp, auto-merging like any gain. Then `newId` is gained like any other gained operator
|
||||
* (acquireChess, onGain source 'transform'): the hand, overflow temp ("被发送至手牌区的物资优先从右到左填充空位"), and with
|
||||
* both full it goes back to the pool ("整备区已满,获得的干员已返还"); it gets no summon card in the hand (only a deployment
|
||||
* brings one), and a merge it completes follows the ordinary rule (_mergeChess: the elite on a consumed deployed copy's
|
||||
* tile, else the hand — the carrier's freed tile is no copy's). An item that found no slot takes one the gain freed (a
|
||||
* merge consumes copies), else stays on the gained operator up to its equipPerChess slots, else it is destroyed with a
|
||||
* log warning (as in a merge). Official footage (bilibili BV1vzyVBuEN9, BV1Qkw1zMEoR; pointed out in PR #2): at the
|
||||
* next prep the carrier's tile is empty, one more deployment is left and the new operator waits in the 整备区.
|
||||
* @param {any} piece the carrier (an owned chess piece)
|
||||
* @param {string} newId chess id gained in its place
|
||||
* @returns {any} the gained piece (the elite when it completed a merge) or null
|
||||
*/
|
||||
transformChess(piece, newId) {
|
||||
const loc = this.find(piece.uid);
|
||||
if (!loc || loc.piece.kind !== 'chess' || !this.gd.chess(newId)) return null;
|
||||
// 原干员销毁: off its tile / slot, its summons removed, its copies back to the pool
|
||||
this._detach(loc);
|
||||
this.removeTokensOf(piece.uid);
|
||||
const items = piece.items || [];
|
||||
piece.items = [];
|
||||
this.returnCopies(piece);
|
||||
// its equipment comes off first (the returned pair auto-merges, which may free a slot for the gain)
|
||||
const left = items.filter((it) => !this.stow(it, { allowTemp: true }));
|
||||
this.checkItemMerges();
|
||||
// 获得一名…干员: gained like any other gained operator
|
||||
const np = this.acquireChess(newId, { source: 'transform' });
|
||||
for (const it of left) {
|
||||
if (this.stow(it, { allowTemp: true })) continue;
|
||||
if (np && this.find(np.uid) && np.items.length < this.gd.equipPerChess) { np.items.push(it); continue; }
|
||||
this.m.log.warn?.(`[match ${this.m.roomCode}] ${this.playerId}: returned item ${it.id} destroyed (no space)`);
|
||||
}
|
||||
if (left.length) this.checkItemMerges();
|
||||
this.recompute();
|
||||
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` DIFFERENT chess of tier min(level + offset, maxTier) at price 0 (pick 1). Each is a
|
||||
* 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).
|
||||
*/
|
||||
pushRewardOffer(source = 'merge', { tier = null, ids = null, label = null } = {}) {
|
||||
const ro = this.gd.rewardOffer();
|
||||
const t = Number.isInteger(tier) ? tier : Math.min(this.shop.level + ro.tierOffset, ro.maxTier);
|
||||
// an offer never shows one operator twice, whoever built the list (user playtest #6 item 19)
|
||||
let list = Array.isArray(ids) ? [...new Set(ids)].filter((id) => this.gd.chess(id)) : null;
|
||||
if (!list) {
|
||||
list = [];
|
||||
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 });
|
||||
if (id) list.push(id);
|
||||
}
|
||||
}
|
||||
if (!list.length) return null;
|
||||
const offer = { tier: t, source, label: typeof label === 'string' && label ? label : null, 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 (凯瑟琳 定向投放, 娜仁图亚 见者有份); shown as shop.rewardOffer with slots of kind
|
||||
* 'item' under its `label` (the effect's name; player report #6 after 0.1.0).
|
||||
*/
|
||||
pushItemOffer(ids, { source = 'effect', tier = null, label = null } = {}) {
|
||||
const list = [...new Set(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, label: typeof label === 'string' && label ? label : null, 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.
|
||||
* `deferMerge` (effectsMeta, while onPrepEnd is on the stack): stow it — hand, else temp — and do not merge in this
|
||||
* call, even when an identical normal copy is already owned. The next prep's start runs checkItemMerges. Nothing
|
||||
* already equipped is taken off for the fight about to start. Hand and temp both full keeps the 「整备区已满,获得的装备已销毁」
|
||||
* outcome. [ASSUMED] every item granted at 休整期结束, not only 维多利亚's 战栗维式重锤 (owner's decision 2026-10-04).
|
||||
*/
|
||||
acquireItem(itemId, { source = 'grant', toTemp = false, silent = false, deferMerge = false } = {}) {
|
||||
const rec = this.gd.item(itemId);
|
||||
if (!rec) return null;
|
||||
let piece = this.newPiece('item', itemId);
|
||||
// A prep-end grant may sit beside an identical copy until the next prep. The invariant counts only copies
|
||||
// without this mark, so the fight that is about to start is not reported as a missed merge.
|
||||
if (deferMerge) piece.deferMerge = true;
|
||||
if (!deferMerge && 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
// server/match/player/basics.js — PlayerState methods: the basics — seat flags and counts (isHumanActive,
|
||||
// botControlled, deployCap, deployCount, tempEmpty), the prep that resolves a temp piece (tempDue, _putTemp: every
|
||||
// write into a temp slot), the operator loadout (DESIGN §16: setLoadout, loadoutFor), the deploy map on the field the
|
||||
// player deploys on (Match.deployFieldOf) and the withdrawal of pieces a terrain / deploy-field change left on tiles
|
||||
// they may no longer occupy (_evictIllegal), dirty.
|
||||
// Installed on PlayerState.prototype by server/match/PlayerState.js (a method container: never instantiated; `this` is
|
||||
// the player state).
|
||||
|
||||
import { PHASE } from '../../../shared/constants.js';
|
||||
import { checkLoadout, resolveLoadout } from '../../../shared/protocol.js';
|
||||
import { tileKey, boardOrder } from '../board.js';
|
||||
|
||||
export class PlayerBasics {
|
||||
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));
|
||||
}
|
||||
|
||||
/**
|
||||
* Deploy classes of the board tiles (server/match/board.js buildDeployMap) on the field the player deploys on now
|
||||
* (Match.deployFieldOf: the own board, or its half of the boss field in a boss round — user playtest #5 item 7).
|
||||
* A change of that field (the boss round begins, a re-pairing) re-checks the board's legality like a terrain change.
|
||||
*/
|
||||
deployMap() {
|
||||
const field = typeof this.m.deployFieldOf === 'function' ? this.m.deployFieldOf(this) : 'normal';
|
||||
if (this._deployMap && this._deployField !== undefined && this._deployField !== field) {
|
||||
this._deployMap = null;
|
||||
this._legalityStale = true;
|
||||
}
|
||||
if (!this._deployMap) this._deployMap = this.m.deployMapFor(this, field);
|
||||
this._deployField = field;
|
||||
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); }
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
// server/match/player/common.js — constants and small helpers shared by the PlayerState method modules
|
||||
// (server/match/player/*.js) and its constructor (server/match/PlayerState.js).
|
||||
|
||||
import { GEO } from '../../../shared/constants.js';
|
||||
|
||||
export const HAND_SIZE = GEO.HAND_SIZE;
|
||||
export const TEMP_SIZE = GEO.TEMP_SIZE;
|
||||
/** g.reward accepts idx 0..5 (shared/protocol.js) */
|
||||
export const MAX_OFFER_SLOTS = 6;
|
||||
export const OK = Object.freeze({ ok: true });
|
||||
export const fail = (error, detail) => (detail ? { error, detail } : { error });
|
||||
@@ -0,0 +1,200 @@
|
||||
// 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.
|
||||
// Installed on PlayerState.prototype by server/match/PlayerState.js (a method container: never instantiated; `this` is
|
||||
// the player state).
|
||||
|
||||
import { ERR, layerGainRoom } from '../../../shared/constants.js';
|
||||
import { freeSlot } from '../board.js';
|
||||
import { OK, fail } from './common.js';
|
||||
|
||||
export class PlayerEconomy {
|
||||
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: at most the room left
|
||||
* under BOND_LAYER_CAP (999, shared/constants.js) — a gain at the cap adds 0 and dispatches nothing.
|
||||
*/
|
||||
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 before = this.layers[bondId] || 0;
|
||||
const add = layerGainRoom(before, Math.floor(n));
|
||||
if (add <= 0) return 0;
|
||||
this.layers[bondId] = before + add;
|
||||
this.recompute();
|
||||
this.m.dispatch(this, 'onLayers', { bondId, from: before, to: before + add, reason });
|
||||
return add;
|
||||
}
|
||||
|
||||
/** 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();
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
// server/match/player/items.js — PlayerState methods: items — g.equip (max 2, a 3rd replaces the one the player picks;
|
||||
// consume-on-equip items), g.art (Arts on a tile, rotated by the facing) and g.destroy (hand / temp items; equipped
|
||||
// ones are locked).
|
||||
// Installed on PlayerState.prototype by server/match/PlayerState.js (a method container: never instantiated; `this` is
|
||||
// the player state).
|
||||
|
||||
import { ERR } from '../../../shared/constants.js';
|
||||
import { tileKey, inField, parseDir } from '../board.js';
|
||||
import { offsetTile } from '../../sim/dir.js';
|
||||
import { itemKey } from '../gamedata.js';
|
||||
import { OK, fail } from './common.js';
|
||||
|
||||
export class PlayerItems {
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
// 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), the per-piece round counters
|
||||
// (拉普兰德), pool copies back to the shared pool, and summon stacks (removal, lifting, counts, the stack granted for an
|
||||
// owner).
|
||||
// Installed on PlayerState.prototype by server/match/PlayerState.js (a method container: never instantiated; `this` is
|
||||
// the player state).
|
||||
|
||||
import { tileKey, boardOrder, freeSlot } from '../board.js';
|
||||
|
||||
export class PlayerPieces {
|
||||
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 (reading order, board.js boardOrder) 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-piece counter of the current round (`piece.meta.round` = { r, n: { key: count } }): 0 for a key not counted yet
|
||||
* this round. The counters belong to the operator: a move keeps them, a new piece (bought, granted, transformed)
|
||||
* starts at 0, and an elite merged this round keeps the highest count of its copies (_mergeChess) — 拉普兰德's
|
||||
* "本回合首次主动刷新" is the first manual refresh she witnesses (player feedback after 0.1.0, garrisons/meta.js).
|
||||
*/
|
||||
pieceRoundCount(piece, key) {
|
||||
const rc = piece && piece.meta && piece.meta.round;
|
||||
return rc && rc.r === this.m.round && Number.isFinite(rc.n[key]) ? rc.n[key] : 0;
|
||||
}
|
||||
|
||||
/** Add `n` to a piece's counter of the current round (pieceRoundCount); returns the new count. */
|
||||
bumpPieceRoundCount(piece, key, n = 1) {
|
||||
if (!piece || typeof key !== 'string' || !Number.isFinite(n)) return 0;
|
||||
if (!piece.meta || typeof piece.meta !== 'object') piece.meta = {};
|
||||
const v = this.pieceRoundCount(piece, key) + n;
|
||||
if (!piece.meta.round || piece.meta.round.r !== this.m.round) piece.meta.round = { r: this.m.round, n: {} };
|
||||
piece.meta.round.n[key] = v;
|
||||
return v;
|
||||
}
|
||||
|
||||
/** 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* An owner that changes its board tile (moved, swapped): its summons on the board go back onto its stack (PRTS
|
||||
* 卫戍协议/帮助 "移动干员时,其所属召唤物全部退场并重置至手牌区"); overflow temp when no stack or slot is left (a
|
||||
* stack lost there comes back at the next round start). `keep`: a summon the player just placed (the one dragged
|
||||
* onto its owner, which swapped the owner away) stays where it was put.
|
||||
*/
|
||||
_liftTokensOf(ownerUid, keep = null) {
|
||||
for (const [k, p] of [...this.board]) {
|
||||
if (p.kind !== 'token' || p.ownerUid !== ownerUid || p === keep) continue;
|
||||
this.board.delete(k);
|
||||
if (!this._returnToken(p, null, { allowTemp: true })) this.board.set(k, p); // nowhere to go: it stays put
|
||||
}
|
||||
}
|
||||
|
||||
/** Copies of one summon type an owner has (placed pieces + stacks in the hand / temp). */
|
||||
_tokenCountOf(ownerUid, tokenId) {
|
||||
const mine = (p) => !!p && p.kind === 'token' && p.ownerUid === ownerUid && p.id === tokenId;
|
||||
let n = 0;
|
||||
for (const p of this.board.values()) if (mine(p)) n += p.count || 1;
|
||||
for (const p of this.hand) if (mine(p)) n += p.count || 1;
|
||||
for (const p of this.temp) if (mine(p)) n += p.count || 1;
|
||||
return n;
|
||||
}
|
||||
|
||||
/**
|
||||
* Owner on the board: send its placeable summons to the hand (one stack per token type, topped up to the deploy limit;
|
||||
* gamedata.placeableTokens / tokens.json `placeable`, user playtest #6) — those its equipped skill / module makes
|
||||
* (DESIGN §16: 赫默 S2 医疗无人机 a drone, 赫默 S1 none). Called when the owner is placed and at every round start, which
|
||||
* returns a stack removed from temp at the last prep deadline (PRTS 卫戍协议/帮助 §手牌区 "干员所属召唤物会于下一回合
|
||||
* 返还"); copies the owner still has (placed or stacked) are not granted again.
|
||||
*/
|
||||
grantTokensFor(owner) {
|
||||
const rec = owner && owner.kind === 'chess' ? this.gd.chess(owner.id) : null;
|
||||
if (!rec) return;
|
||||
for (const { tokenId, count } of this.gd.placeableTokens(owner.id, this.loadoutFor(rec))) {
|
||||
const missing = count - this._tokenCountOf(owner.uid, tokenId);
|
||||
if (missing <= 0) continue;
|
||||
const stack = [...this.hand, ...this.temp].find((p) => p && p.kind === 'token' && p.ownerUid === owner.uid && p.id === tokenId);
|
||||
if (stack) { stack.count = (stack.count || 1) + missing; continue; }
|
||||
const t = this.newPiece('token', tokenId, { count: missing, ownerUid: owner.uid });
|
||||
this.stow(t, { allowTemp: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
// server/match/player/placement.js — PlayerState methods: g.move — placement legality on the deploy field (board.js),
|
||||
// the summon ranges of 狼群 / 流形 (ownerRange) and the summons an owner's move or re-orientation leaves out of range,
|
||||
// operator / summon moves onto the board, back to the hand, between hand and temp, swaps and re-orientation in place.
|
||||
// Installed on PlayerState.prototype by server/match/PlayerState.js (a method container: never instantiated; `this` is
|
||||
// the player state).
|
||||
|
||||
import { ERR } from '../../../shared/constants.js';
|
||||
import { tileKey, parseKey, inField, canPlace, placeClass, freeSlot, pieceDir, parseDir, ownerRangeKeys } from '../board.js';
|
||||
import { attackRangeGrid, loadoutRecord, resolveRecordLoadout } from '../../../shared/loadoutRecord.js';
|
||||
import { OK, fail } from './common.js';
|
||||
|
||||
export class PlayerPlacement {
|
||||
/**
|
||||
* 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);
|
||||
// elite 歌蕾蒂娅 + HOK-Y may use a 高台; the module is this player's loadout (owner's decision 2026-10-04)
|
||||
return placeClass(this, rec);
|
||||
}
|
||||
|
||||
/**
|
||||
* Where piece may stand on (r, c): the deploy map of its position class (board.js canPlace) and, for a summon whose
|
||||
* text reads "只能部署在召唤者攻击范围内" (tokens.json `ownerRange`: 伺夜's 狼群, 缪尔赛思's 流形), a tile of its owner's
|
||||
* attack range (summonRange). `owner` = the owner's position after the move being checked ({ key, piece, dir }: a
|
||||
* summon swapped with its own owner).
|
||||
*/
|
||||
_legal(piece, r, c, owner = null) {
|
||||
if (!canPlace(this.deployMap(), this._placementOf(piece), r, c)) return false;
|
||||
const range = this.summonRange(piece, owner);
|
||||
return !range || range.has(tileKey(r, c));
|
||||
}
|
||||
|
||||
/**
|
||||
* The 'r,c' keys of the attack range of a range-bound summon's owner (player report #9 after 0.1.0: 伺夜's tactical
|
||||
* point could be placed anywhere; PRTS 狼群 特性 "只能部署在召唤者攻击范围内"): the owner's loadout-resolved range grid
|
||||
* (shared/loadoutRecord.js attackRangeGrid — what the deploy wheel previews) rotated by its facing around its board
|
||||
* tile (board.js ownerRangeKeys). Null when the piece is not range-bound or its owner is not on the board (the other
|
||||
* rules refuse such a placement).
|
||||
* @param {any} piece
|
||||
* @param {{ key: string, piece: any, dir: string } | null} [owner] the owner's position to use instead of its current one
|
||||
* @returns {Set<string> | null}
|
||||
*/
|
||||
summonRange(piece, owner = null) {
|
||||
if (!piece || piece.kind !== 'token' || this.gd.token(piece.id)?.ownerRange !== true) return null;
|
||||
let at = owner;
|
||||
if (!at) for (const [key, p] of this.board) if (p.uid === piece.ownerUid && p.kind === 'chess') { at = { key, piece: p, dir: pieceDir(p) }; break; }
|
||||
const rec = at && this.gd.chess(at.piece.id);
|
||||
if (!rec) return null;
|
||||
const grid = attackRangeGrid(loadoutRecord(rec, resolveRecordLoadout(rec, this.loadoutFor(rec)))) || rec.rangeGrid;
|
||||
const [r, c] = parseKey(at.key);
|
||||
return ownerRangeKeys(grid, r, c, at.dir);
|
||||
}
|
||||
|
||||
/**
|
||||
* Range-bound summons left outside their owner's attack range (the owner re-oriented in place, promoted, its loadout
|
||||
* changed, moved with no room to take its summons back) go back onto their stack — a summon still inside stays
|
||||
* [ASSUMED: the official moves every summon of a MOVED owner back, PRTS 卫戍协议/帮助 "移动干员时,其所属召唤物全部退场
|
||||
* 并重置至手牌区"; an in-place re-orientation keeps the ones it can]. One with no stack and no free hand / temp slot
|
||||
* leaves the board: its stack comes back at the next round start like a summon stack removed from temp at a prep
|
||||
* deadline (startRound → grantTokensFor, "干员所属召唤物会于下一回合返还"), so no illegal placement reaches the
|
||||
* battle. Returns the number taken off the board; a toast names them.
|
||||
*/
|
||||
_liftOutOfRange() {
|
||||
const back = [], gone = [];
|
||||
for (const [k, p] of [...this.board]) {
|
||||
if (p.kind !== 'token') continue;
|
||||
const range = this.summonRange(p);
|
||||
if (!range || range.has(k)) continue;
|
||||
this.board.delete(k);
|
||||
(this._returnToken(p, null, { allowTemp: true }) ? back : gone).push(this.gd.token(p.id)?.name || p.id);
|
||||
}
|
||||
if (back.length) this.m.toast(this, 'warn', `${back.join('、')}只能部署在召唤者攻击范围内,已退回整备区`);
|
||||
if (gone.length) this.m.toast(this, 'warn', `${gone.join('、')}只能部署在召唤者攻击范围内,整备区已满,下回合返还`);
|
||||
return back.length + gone.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether every range-bound summon of `owner` that the range from (ownerKey, dir) leaves out can go back onto its
|
||||
* stack or into a free hand / temp slot (_reorient refuses otherwise: the player's own re-orientation never costs a
|
||||
* summon, like withdrawing one into a full hand gives HAND_FULL).
|
||||
*/
|
||||
_roomForOutOfRange(owner, ownerKey, dir) {
|
||||
const at = { key: ownerKey, piece: owner, dir };
|
||||
const stacks = [...this.hand, ...this.temp].filter((p) => p && p.kind === 'token' && p.ownerUid === owner.uid);
|
||||
const needSlot = new Set();
|
||||
for (const [k, p] of this.board) {
|
||||
if (p.kind !== 'token' || p.ownerUid !== owner.uid || stacks.some((s) => s.id === p.id)) continue;
|
||||
const range = this.summonRange(p, at);
|
||||
if (range && !range.has(k)) needSlot.add(p.id);
|
||||
}
|
||||
return needSlot.size <= [...this.hand, ...this.temp].filter((p) => p == null).length;
|
||||
}
|
||||
|
||||
_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); an
|
||||
// operator that changes its tile takes its summons off the board (back onto their stacks, _liftTokensOf) — its
|
||||
// own summon swapped onto its old tile included, so that one needs no tile check
|
||||
if (occ) {
|
||||
const [sr, sc] = parseKey(loc.key);
|
||||
const ownSummon = occ.kind === 'token' && occ.ownerUid === piece.uid;
|
||||
if (!ownSummon && !this._legal(occ, sr, sc)) return fail(ERR.BAD_TILE);
|
||||
this.board.set(loc.key, occ);
|
||||
if (occ.kind === 'chess') this._liftTokensOf(occ.uid);
|
||||
} else {
|
||||
this.board.delete(loc.key);
|
||||
}
|
||||
piece.dir = dir;
|
||||
this.board.set(key, piece);
|
||||
this._liftTokensOf(piece.uid);
|
||||
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). An owner's range-bound summons the
|
||||
* new range leaves out go back onto their stack (recompute → _liftOutOfRange); HAND_FULL (nothing changes) when one
|
||||
* of them would have nowhere to go.
|
||||
*/
|
||||
_reorient(piece, dir) {
|
||||
if (pieceDir(piece) === dir) return OK;
|
||||
if (piece.kind === 'chess') {
|
||||
const loc = this.find(piece.uid);
|
||||
if (loc && loc.area === 'board' && !this._roomForOutOfRange(piece, loc.key, dir)) return fail(ERR.HAND_FULL);
|
||||
}
|
||||
piece.dir = dir;
|
||||
this.recompute();
|
||||
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)) return fail(ERR.BAD_TILE);
|
||||
const key = tileKey(r, c);
|
||||
const occ = this.board.get(key) || null;
|
||||
// a summon dragged onto its own owner swaps with it: a range-bound one must be inside the owner's range from the
|
||||
// tile the owner takes (the summon's, with the owner's facing)
|
||||
const ownerAfter = loc.area === 'board' && occ && occ !== piece && occ.uid === piece.ownerUid ? { key: loc.key, piece: occ, dir: pieceDir(occ) } : null;
|
||||
if (!this._legal(piece, r, c, ownerAfter)) return fail(ERR.BAD_TILE);
|
||||
if (occ === piece) return this._reorient(piece, dir);
|
||||
if (loc.area === 'board') {
|
||||
// board → board: move or swap; an operator swapped onto the summon's old tile changed its tile, so its other
|
||||
// summons go back onto their stacks like any moved operator (_liftTokensOf; the summon just placed stays)
|
||||
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);
|
||||
if (occ && occ.kind === 'chess') this._liftTokensOf(occ.uid, 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
// server/match/player/prep.js — PlayerState methods: the gate of every prep intent (_gate: alive, PREP, not ready),
|
||||
// g.reward (a reward offer's pick), Ready (refused while temp holds pieces) and the resolution of the temp pieces due
|
||||
// at a prep's deadline.
|
||||
// Installed on PlayerState.prototype by server/match/PlayerState.js (a method container: never instantiated; `this` is
|
||||
// the player state).
|
||||
|
||||
import { ERR, PHASE } from '../../../shared/constants.js';
|
||||
import { freeSlot } from '../board.js';
|
||||
import { OK, fail } from './common.js';
|
||||
|
||||
export class PlayerPrep {
|
||||
_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;
|
||||
}
|
||||
|
||||
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 are destroyed, the summon stack of an owner still on the board
|
||||
* is removed and comes back at the next round start (startRound → grantTokensFor; PRTS "干员所属召唤物会于下一回合
|
||||
* 返还"). 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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
// 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, bonds), the bond views — and battleInput (the player's PlayerBattleInput: board units with
|
||||
// their loadout, carried 联防 state, the reached layers).
|
||||
// Installed on PlayerState.prototype by server/match/PlayerState.js (a method container: never instantiated; `this` is
|
||||
// the player state).
|
||||
|
||||
import { boardOrder, pieceDir } from '../board.js';
|
||||
import { computeBonds, bondSnapshot, activatedLayers, bondsWithGains } from '../bondsMeta.js';
|
||||
|
||||
export class PlayerRound {
|
||||
startRound(r) {
|
||||
this.round = { refreshes: 0, buys: 0, sells: 0, spent: 0, gainedChess: 0, arts: 0 };
|
||||
this.pendingLayerGains = null; // settled (or lapsed) at the last SETTLE
|
||||
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;
|
||||
// summon stacks removed from temp at the last prep deadline come back (PRTS 卫戍协议/帮助 §手牌区); full hand ⇒ temp
|
||||
for (const p of [...this.board.values()]) if (p.kind === 'chess') this.grantTokensFor(p);
|
||||
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();
|
||||
}
|
||||
|
||||
// `effects` are kept: a 信标 gift still pending is delivered to the teammate at the next round start (builtin_gift is
|
||||
// flagged afterElimination — GitHub #86); nothing else of an eliminated player is dispatched.
|
||||
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() {
|
||||
this.deployMap(); // a change of the deploy field (a boss round's prep) marks the legality stale
|
||||
if (this._legalityStale) this._evictIllegal();
|
||||
this._liftOutOfRange();
|
||||
this.bonds = computeBonds(this.gd, this);
|
||||
this.dirty();
|
||||
}
|
||||
|
||||
activatedLayers() { return activatedLayers(this.bonds); }
|
||||
|
||||
/**
|
||||
* The bond states the views show (m.private bonds, m.public players[].bonds): the computed states plus the pending
|
||||
* in-battle gains of this round's finished normal battle (bondsMeta.bondsWithGains). The 联防 field fights with them too
|
||||
* (battleInput `reached`); no other rule reads them.
|
||||
*/
|
||||
bondsView() { return bondsWithGains(this.bonds, this.pendingLayerGains); }
|
||||
|
||||
/**
|
||||
* `reached`: the bonds carry the layers this round's own combat reached (bondsView: the pending in-battle gains, capped
|
||||
* like settle()) — the 联防 field (unite.js; PRTS 卫戍协议/帮助 §联防阶段 "将以其阵地当前的状态", [ASSUMED] the current
|
||||
* state includes those layers, as the strip shows them). The gains stay pending: settle() adds them once.
|
||||
*/
|
||||
battleInput({ side = 'L', colOffset = 0, carry = null, reached = false } = {}) {
|
||||
// a terrain change not yet followed by a recompute (a content hook at the prep end) never fields an illegal board
|
||||
this.deployMap();
|
||||
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') {
|
||||
const u = { uid: piece.uid, kind: 'token', tokenId: piece.id, row: r, col: c, dir: pieceDir(piece), ownerUid: piece.ownerUid };
|
||||
if (carry && carry.has(piece.uid)) u.carryState = carry.get(piece.uid); // 联防: { sp } (unite.js)
|
||||
units.push(u);
|
||||
}
|
||||
}
|
||||
return {
|
||||
playerId: this.playerId,
|
||||
seat: this.seat,
|
||||
side,
|
||||
colOffset,
|
||||
units,
|
||||
bonds: bondSnapshot(reached ? this.bondsView() : 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 },
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
// server/match/player/views.js — PlayerState methods: the views — pieceView, effectsView (the effects column: 策略 / 机变 /
|
||||
// 悬赏 …) and m.private (privateView).
|
||||
// Installed on PlayerState.prototype by server/match/PlayerState.js (a method container: never instantiated; `this` is
|
||||
// the player state).
|
||||
|
||||
import { PHASE } from '../../../shared/constants.js';
|
||||
import { boardOrder, pieceDir } from '../board.js';
|
||||
import { bondList, offBondCounts } from '../bondsMeta.js';
|
||||
import { bountyText } from '../choices.js';
|
||||
|
||||
export class PlayerViews {
|
||||
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) {
|
||||
// the battles the bounty's enemies still come for (an official multi-round card: every battle, no counter) and
|
||||
// the card's official rich text (blue "下场作战" / "两场作战", red "每场"; a multi-round card reads as long as it
|
||||
// lasts — choices.js bountyText, MULTI_ROUND_BOUNTY_BATTLES) — user playtest #6 item 4
|
||||
const left = b.roundsLeft >= 90 ? null : b.roundsLeft;
|
||||
const eff = b.card.effectId ? this.gd.effect(b.card.effectId) : null;
|
||||
out.push({
|
||||
id: b.id, name: b.card.name || '悬赏', desc: bountyText((eff && eff.descRaw) || b.card.desc || '', b.card), iconKind: 'choice', iconId: b.card.effectId || 'bounty',
|
||||
counter: left, counterText: left == null ? '之后的每场作战' : `还剩 ${left} 场作战`,
|
||||
});
|
||||
}
|
||||
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,
|
||||
// `source` 'merge' = the promotion reward (晋升奖励); any other offer (a strategy, an item, a 特质) carries the
|
||||
// `label` the bar shows instead; `queued` = offers waiting behind it (player report #6 after 0.1.0)
|
||||
rewardOffer: offer ? { tier: offer.tier, source: offer.source === 'merge' ? 'merge' : 'special', label: offer.label || null, queued: this.offers.length - 1, 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,
|
||||
// + the mode-off bonds it has members of (`off: true`, the strip's grey 本局禁用 discs — bondsMeta.offBondCounts)
|
||||
bonds: bondList(this.gd, this.bondsView(), { full: true, off: offBondCounts(this.gd, this) }),
|
||||
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,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,8 @@ const doc = (p) => readFileSync(join(ROOT, p), 'utf8');
|
||||
const battleText = () => [doc('server/sim/Battle.js'), ...readdirSync(join(ROOT, 'server/sim/battle')).sort().map((f) => doc(`server/sim/battle/${f}`))].join('\n');
|
||||
// the Match class: the façade (constructor) and its method modules (server/match/match/)
|
||||
const matchText = () => [doc('server/match/Match.js'), ...readdirSync(join(ROOT, 'server/match/match')).sort().map((f) => doc(`server/match/match/${f}`))].join('\n');
|
||||
// the PlayerState class: the façade (header, constructor) and its method modules (server/match/player/)
|
||||
const playerText = () => [doc('server/match/PlayerState.js'), ...readdirSync(join(ROOT, 'server/match/player')).sort().map((f) => doc(`server/match/player/${f}`))].join('\n');
|
||||
// the sources of a former kits/tierN.js: its helpers (kits/shared/tierN.js) and its kit files (kits/ops/, kits/index.js)
|
||||
const tierSources = (t) => [`server/sim/content/kits/shared/tier${t}.js`, ...KIT_FILES[t - 1].map((f) => `server/sim/content/kits/ops/${f}`)];
|
||||
const DESIGN = doc('docs/DESIGN.md');
|
||||
@@ -559,11 +561,12 @@ test('playtest6b follow-up (DESIGN §20.10–§20.13): leader HP, 直接乘算,
|
||||
assert.equal(BOND_LAYER_CAP, 999);
|
||||
assert.equal(layerGainRoom(995, 10), 4);
|
||||
assert.ok(!('BOND_LAYER_CAP' in SIM_CONST) && !('layerRoom' in SIM_CONST), 'no second cap in server/sim/constants.js');
|
||||
for (const f of ['server/match/PlayerState.js', 'server/match/match/settle.js', 'server/sim/battle/economy.js']) {
|
||||
for (const f of ['server/match/player/economy.js', 'server/match/match/settle.js', 'server/sim/battle/economy.js']) {
|
||||
const src = doc(f);
|
||||
assert.match(src, /layerGainRoom/, `${f} clamps with layerGainRoom`);
|
||||
assert.ok(!/layerRoom\b/.test(src), `${f}: no layerRoom`);
|
||||
}
|
||||
for (const [name, src] of [['Match', matchText()], ['PlayerState', playerText()]]) assert.ok(!/layerRoom\b/.test(src), `${name}: no layerRoom`);
|
||||
// 限伤: official constant, not an overflow — no doc keeps the boss-HP branch's "fixed-point overflow, not modelled"
|
||||
assert.equal(BOSS_HIT_LIMIT, 300000);
|
||||
for (const [name, text] of [['BALANCE', BALANCE], ['DATA', DATA_MD], ['PLAYING', PLAYING], ['SIM', SIM], ['META', META], ['research 02', R02], ['DESIGN', DESIGN]]) {
|
||||
@@ -812,7 +815,7 @@ test('突变细胞 after the WA merge (DESIGN §21.1): the carrier is destroyed,
|
||||
assert.match(PLAYING, /原来的格子空出来,剩余可放置角色加 1/);
|
||||
assert.match(DATA.items.chess_item_5_08_e_a.note, /进入整备区,需要重新部署/);
|
||||
// the code: a destroy, then a gain through acquireChess; _mergeChess has no carrier-tile option left
|
||||
const PS = doc('server/match/PlayerState.js');
|
||||
const PS = playerText();
|
||||
const { PlayerState } = await import('../server/match/PlayerState.js');
|
||||
assert.equal(PlayerState.prototype.transformChess.length, 2, 'transformChess(piece, newId)');
|
||||
assert.equal(PlayerState.prototype._mergeChess.length, 2, '_mergeChess(baseId, incoming)');
|
||||
|
||||
Reference in new issue
Block a user