// 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, tileOverrides?: Record }} [opts] * @returns {Map} */ export function buildDeployMap(stage, { deviceOverrides = {}, tileOverrides = {} } = {}) { /** @type {Map} */ 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; }