Files
ark-stronghold-protocol/server/match/board.js
T
sganggs 01a44cd288 卫戍协议:盟约 web remake — initial import
Online co-op browser remake of Arknights' Stronghold Protocol: Alliance
(client-simulated combat, official waves/bonds/items/strategies, 3D board,
facing wheel, operator loadout, official emotes, deployment scripts).
2026-09-29 16:21:31 +08:00

128 lines
5.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// server/match/board.js — placement legality (DESIGN §3), board/hand/temp slot helpers.
//
// Deploy map: for every tile of the own board region (rows 9–12, cols 2–10) the legality class is derived from the
// stage legend (data/stages.json `tiles[glyph]`: height + buildable) and the stage devices:
// 'melee' — LOW tile with buildable ALL/MELEE: melee AND ranged chess may stand here
// ("所有行动内远程干员可部署在近战位").
// 'ranged' — HIGH tile with buildable ALL/RANGED, or a tile under an active platform (射击台): ranged only.
// (absent) — not deployable (NONE, forbidden, road lanes, tiles under active crates/mounds).
// Per-player overrides (terrain 机变 cards, content): `deviceOverrides { alias: active }` toggles stage devices,
// `tileOverrides { 'r,c': 'melee'|'ranged'|'none' }` force a class. The result equals stages[id].deployTiles.normal
// for the unmodified stage (asserted in test/match/board.test.js).
//
// Tokens follow their own `position` (ALL ⇒ any deployable tile, MELEE ⇒ melee tiles, RANGED ⇒ any deployable).
// Facing: board pieces carry `dir` ∈ UP|RIGHT|DOWN|LEFT (server/sim/dir.js); `pieceDir` reads it (absent ⇒ RIGHT),
// `parseDir` validates an intent's optional direction.
import { GEO } from '../../shared/constants.js';
import { DEFAULT_DIR, isDir } from '../sim/dir.js';
export const FIELD = GEO.FIELD; // { r0: 9, r1: 12, c0: 2, c1: 10 }
export const tileKey = (r, c) => `${r},${c}`;
export const parseKey = (k) => { const [r, c] = String(k).split(',').map(Number); return [r, c]; };
/** A board piece's direction (default RIGHT). */
export const pieceDir = (piece) => (piece && isDir(piece.dir) ? piece.dir : DEFAULT_DIR);
/** An intent's optional direction: undefined / null ⇒ RIGHT, a direction name ⇒ itself, anything else ⇒ null (invalid). */
export const parseDir = (d) => (d === undefined || d === null ? DEFAULT_DIR : isDir(d) ? d : null);
export const inField = (r, c) => Number.isInteger(r) && Number.isInteger(c) && r >= FIELD.r0 && r <= FIELD.r1 && c >= FIELD.c0 && c <= FIELD.c1;
const OBSTACLE_ROLES = new Set(['crate', 'mound']);
const PLATFORM_ROLES = new Set(['platform']);
/**
* @param {object|null} stage data/stages.json entry
* @param {{ deviceOverrides?: Record<string, boolean>, tileOverrides?: Record<string, string> }} [opts]
* @returns {Map<string, 'melee'|'ranged'>}
*/
export function buildDeployMap(stage, { deviceOverrides = {}, tileOverrides = {} } = {}) {
/** @type {Map<string, 'melee'|'ranged'>} */
const map = new Map();
const rows = stage && Array.isArray(stage.rows) ? stage.rows : null;
const legend = stage && stage.tiles && typeof stage.tiles === 'object' ? stage.tiles : {};
for (let r = FIELD.r0; r <= FIELD.r1; r++) {
const line = rows && typeof rows[r] === 'string' ? rows[r] : null;
for (let c = FIELD.c0; c <= FIELD.c1; c++) {
let cls = null;
if (line) {
const g = line[c];
const t = g != null && Object.hasOwn(legend, g) ? legend[g] : null;
if (t) {
const b = t.buildable;
if (t.height === 'LOW' && (b === 'ALL' || b === 'MELEE')) cls = 'melee';
else if ((t.height === 'HIGH' && (b === 'ALL' || b === 'RANGED')) || (t.height === 'LOW' && b === 'RANGED')) cls = 'ranged';
}
} else if (!stage) {
// no stage data at all: an open board (tests / degraded data) — everything melee-deployable except col 9
cls = c === 9 ? null : 'melee';
}
if (cls) map.set(tileKey(r, c), cls);
}
}
const devices = stage && Array.isArray(stage.devices) ? stage.devices : [];
for (const d of devices) {
if (!d || !Array.isArray(d.pos)) continue;
const [r, c] = d.pos;
if (!inField(r, c)) continue;
let active;
if (d.alias != null && deviceOverrides && Object.hasOwn(deviceOverrides, d.alias)) active = !!deviceOverrides[d.alias];
else if (typeof d.active === 'boolean') active = d.active;
else active = !d.hidden;
if (!active) continue;
const k = tileKey(r, c);
if (OBSTACLE_ROLES.has(d.role)) map.delete(k);
else if (PLATFORM_ROLES.has(d.role)) map.set(k, 'ranged');
}
for (const [k, v] of Object.entries(tileOverrides || {})) {
const [r, c] = parseKey(k);
if (!inField(r, c)) continue;
if (v === 'melee' || v === 'ranged') map.set(tileKey(r, c), v);
else if (v === 'none') map.delete(tileKey(r, c));
}
return map;
}
/** Placement class of a chess / token record: 'melee' | 'ranged' | 'all'. */
export function positionClass(rec) {
const p = rec && typeof rec.position === 'string' ? rec.position.toUpperCase() : 'ALL';
if (p === 'MELEE') return 'melee';
if (p === 'RANGED') return 'ranged';
return 'all';
}
/** Whether a unit of placement class `pos` may stand on (r, c). */
export function canPlace(map, pos, r, c) {
if (!inField(r, c)) return false;
const cls = map.get(tileKey(r, c));
if (!cls) return false;
if (pos === 'melee') return cls === 'melee';
return true; // ranged / all: melee tiles and ranged tiles
}
/** Legal tiles for a placement class, in reading order (top→bottom, left→right). */
export function legalTiles(map, pos) {
const out = [];
for (let r = FIELD.r1; r >= FIELD.r0; r--) {
for (let c = FIELD.c0; c <= FIELD.c1; c++) if (canPlace(map, pos, r, c)) out.push([r, c]);
}
return out;
}
/** Board pieces sorted in deployment order: top→bottom (row desc) then left→right (col asc). */
export function boardOrder(board) {
return [...board.entries()]
.map(([k, p]) => { const [r, c] = parseKey(k); return { r, c, piece: p }; })
.sort((a, b) => b.r - a.r || a.c - b.c);
}
/** Index of the free slot to fill (right→left, config.economy.handFillOrder), or -1. */
export function freeSlot(arr) {
for (let i = arr.length - 1; i >= 0; i--) if (arr[i] == null) return i;
return -1;
}
export function countFree(arr) {
let n = 0;
for (const x of arr) if (x == null) n++;
return n;
}