Files
ark-stronghold-protocol/server/match/player/placement.js
T
sganggs c5c52eed34 feedback5 WM: merge feedback4 at 13808e3 (W4A) — 高台 for every 「可以放置于远程位」 trait holder, the 5-阿戈尔 revive slots, the 联防 devour of whoever stands in front
Ported by hand into the split layout:
- 83fc408 board.js placeClass: the player's loadout no longer counts, as in 83fc408. feedback5's 补位 rule stays: a
  chess the player fields as its stand-in is placed by the stand-in's record (ps.fieldRecord). positionClass(rec)
  takes no module.
- 83fc408 client piecePosition → public/js/ui/gameLogic/placement.js: meleeOnHighGround(rec) on the stand-in or
  chess record, no module. The gameLogic.js shim header and deployedModuleId's comment follow; deployedModuleId is
  kept, still exported and tested.
- 83fc408 PlayerState._placementOf comment → server/match/player/placement.js.
- 83fc408 tools/golden.mjs layout: positionClass without the module, on feedback5's stand-in body (recOf) and in
  diyLayout.
- bonds/core.js (6ab9a06, 8a2e0c7) applied cleanly; it equals 13808e3's plus feedback5's i18n toast. shared/highGround.js
  equals 13808e3's.
- data/: only bonds.json (the 阿戈尔 spec text). node tools/build-data.mjs --offline on the merged tree rewrites all
  15 files byte-identical.

Golden regenerated: 16 scenarios moved, all by 83fc408. Each fields 歌蕾蒂娅 (normal, or elite with HOK-X / no
module), whom tools/golden.mjs now lays out beside the enemy path like a ranged operator instead of on the busiest
melee tile: roster-010, roster-012, roster-029, roster-040, roster-041; bond-deputShip-high, bond-indomShip-low,
bond-preciShip-low, bond-preciShip-high, bond-suntShip-low, bond-swiftShip-high; boss-boss_2-pair, boss-boss_4-pair,
boss-boss_6-pair, hidden-boss_8-pair, hidden-boss_10-pair. This is the list in 83fc408's own message. With the
阿戈尔 commits reverted the digests are the same, so those two move nothing in this corpus. The standins, diy and
matches families are unchanged: no stand-in or 自选 piece there is 歌蕾蒂娅, 崖心 or 见行者.
2026-10-06 00:06:41 +08:00

307 lines
14 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 流形), 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 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 = [];
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', msg('{names}只能部署在召唤者攻击范围内,已退回整备区', { names: back.map(dn) }));
if (gone.length) this.m.toast(this, 'warn', msg('{names}只能部署在召唤者攻击范围内,整备区已满,下回合返还', { names: gone.map(dn) }));
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;
}
}