Files
ark-stronghold-protocol/server/match/player/placement.js
T
sganggs 53ee0a1c6f feedback5 WE2: 重构体 only on the ground inside Mon3tr's range, 战术锚点 only on a 高台 outside 凯尔希·思衡托's (#26)
Mon3tr T1 "可以在攻击范围内的地面使用…重构体"; PRTS 战术锚点 特性 "仅可以部署在凯尔希·思衡托攻击
范围外的远程位". tools/build-data.mjs summonRecord: ownerRange also from an owner talent
naming the token, ownerRangeOutside / rangedTilesOnly from the token text. Regenerated
offline: 3 paths (重构体 ownerRange true; 战术锚点 ownerRangeOutside, rangedTilesOnly).
Match: board.js placement class 'high'; PlayerState.summonExcluded in _legal, the
re-orientation lift (+ toast), _roomForOutOfRange, the invariants and the bot; the
client mirror (piecePosition HIGH, summonExcluded, reasons; English strings added).
Golden: no scenario moved.
2026-10-06 15:34:20 +08:00

336 lines
16 KiB
JavaScript

// 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 { msg, dn } from '../../../shared/i18n.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);
// a melee chess whose trait reads 「可以放置于远程位」 may use a 高台, whatever its module (shared/highGround.js); a
// stand-in is placed by its own record (placeClass)
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 流形, Mon3tr's 重构体), a tile of
* its owner's attack range (summonRange); for one marked `ownerRangeOutside` (凯尔希·思衡托's 战术锚点), a tile outside it
* (summonExcluded). `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 k = tileKey(r, c);
const range = this.summonRange(piece, owner);
if (range && !range.has(k)) return false;
const out = this.summonExcluded(piece, owner);
return !out || !out.has(k);
}
/**
* 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;
return this._ownerRangeOf(piece, owner);
}
/**
* The 'r,c' keys an outside-bound summon may NOT stand on — its owner's attack range (tokens `ownerRangeOutside`: PRTS
* 战术锚点 "仅可以部署在凯尔希·思衡托攻击范围外的远程位"; 0.2.0 WE2) — or null (not outside-bound, or the owner is off the
* board). Same range as summonRange.
* @param {any} piece
* @param {{ key: string, piece: any, dir: string } | null} [owner]
* @returns {Set<string> | null}
*/
summonExcluded(piece, owner = null) {
if (!piece || piece.kind !== 'token' || this.gd.token(piece.id)?.ownerRangeOutside !== true) return null;
return this._ownerRangeOf(piece, owner);
}
/** The owner's attack-range keys of a summon piece (summonRange / summonExcluded), or null when its owner is off the board. */
_ownerRangeOf(piece, owner = 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 chess = at && this.gd.chess(at.piece.id);
if (!chess) return null;
const rec = this.fieldRecord(chess); // 0.2.0 补位: a stand-in's own range (its backup selection)
const grid = attackRangeGrid(loadoutRecord(rec, resolveRecordLoadout(rec, this.loadoutFor(chess)))) || rec.rangeGrid;
const [r, c] = parseKey(at.key);
return ownerRangeKeys(grid, r, c, at.dir);
}
/**
* 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 = [], backOut = [], goneOut = [];
for (const [k, p] of [...this.board]) {
if (p.kind !== 'token') continue;
const range = this.summonRange(p);
const out = this.summonExcluded(p);
const inside = !!(out && out.has(k));
if (!inside && (!range || range.has(k))) continue;
this.board.delete(k);
const ok = this._returnToken(p, null, { allowTemp: true });
(inside ? (ok ? backOut : goneOut) : (ok ? back : gone)).push(this.gd.token(p.id)?.name || p.id);
}
if (back.length) this.m.toast(this, 'warn', msg('{names}只能部署在召唤者攻击范围内,已退回整备区', { names: back.map(dn) }));
if (gone.length) this.m.toast(this, 'warn', msg('{names}只能部署在召唤者攻击范围内,整备区已满,下回合返还', { names: gone.map(dn) }));
// an outside-bound summon (战术锚点) its owner's new range now covers
if (backOut.length) this.m.toast(this, 'warn', msg('{names}只能部署在召唤者攻击范围外,已退回整备区', { names: backOut.map(dn) }));
if (goneOut.length) this.m.toast(this, 'warn', msg('{names}只能部署在召唤者攻击范围外,整备区已满,下回合返还', { names: goneOut.map(dn) }));
return back.length + gone.length + backOut.length + goneOut.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);
const out = this.summonExcluded(p, at);
if ((range && !range.has(k)) || (out && out.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;
}
}