Ported by hand into the split layout: -83fc408board.js placeClass: the player's loadout no longer counts, as in83fc408. 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. -83fc408client 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. -83fc408PlayerState._placementOf comment → server/match/player/placement.js. -83fc408tools/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 by83fc408. 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 见行者.
253 lines
14 KiB
JavaScript
253 lines
14 KiB
JavaScript
// 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, or a 深水区 under an active 特制水上平台 (waterPlatform, "在水上建立可以
|
||
// 部署任意单位的平台"): melee AND ranged chess may stand here ("所有行动内远程干员可部署在近战位").
|
||
// 'ranged' — HIGH tile with buildable ALL/RANGED, or a tile under an active platform (射击台): ranged only (and the
|
||
// melee chess whose trait reads 「可以放置于远程位」, positionClass below).
|
||
// (absent) — not deployable (NONE, forbidden, road lanes, tiles under active crates/mounds, the 深水区 — the legend's
|
||
// `buildable` is the effective type: tile_deepsea refuses deployment, PRTS 深水区 地形信息
|
||
// "拒绝部署(待补充)", although its level buildableType is ALL; player report #3 after 0.1.0, 战场#08's
|
||
// pool).
|
||
// 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).
|
||
//
|
||
// Deploy field (user playtest #5 item 7): in the prep of a boss round (最终攻势 / 隐秘核心) the player deploys on ITS
|
||
// half of the boss field (research 09 §1.2, official ConvertChessPositionInfoToBossMap / player_map_ud_offset 7 /
|
||
// player_map_lr_boundary_col 10), so legality reads the tiles and devices THERE: `field` 'bossL' → board (r, c) is
|
||
// stage tile (r − 7, c); 'bossR' (the second player of a pair, mirrored) → (r − 7, 20 − c); 'normal' → (r, c). Board
|
||
// coordinates stay the same everywhere (the server's board, g.move, the sim input); only the tile read changes. E.g.
|
||
// act2 m01's 1×3 fenced tiles (tile_fence_bound, LOW / buildable ALL) at boss (3–5, 8) and (3–5, 12) = board
|
||
// (10–12, 8), which are '#' on the normal field. The result equals stages[id].deployTiles.bossLeft / bossRight
|
||
// mapped to board coordinates (test/match/playtest5-deploy.test.js).
|
||
//
|
||
// Chess follow their `position` (MELEE ⇒ melee tiles, RANGED ⇒ any deployable), except a MELEE chess whose trait reads
|
||
// 「可以放置于远程位」 — the 钩索师 / 推击手 branch trait: 歌蕾蒂娅, 崖心, 见行者, normal and elite, any module or none
|
||
// (shared/highGround.js ⇒ any deployable tile; the owner's decision of 2026-10-05, following PRTS). The loadout does
|
||
// not change the class.
|
||
// Tokens follow their own `position` (ALL ⇒ any deployable tile, MELEE ⇒ melee tiles, RANGED ⇒ any deployable).
|
||
// A token whose text reads "只能部署在召唤者攻击范围内" (tokens.json `ownerRange`: the tacticians' 援军 — 伺夜's 狼群,
|
||
// 缪尔赛思's 流形; PRTS 狼群 特性) also needs a tile of its owner's attack range: `ownerRangeKeys` = the owner's range
|
||
// grid (loadout-resolved, shared/loadoutRecord.js attackRangeGrid) rotated by its facing around its board tile
|
||
// (PlayerState._legal; player report #9 after 0.1.0).
|
||
// 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 { meleeOnHighGround } from '../../shared/highGround.js';
|
||
import { DEFAULT_DIR, isDir, rotateOffset } from '../sim/dir.js';
|
||
import { BOSS_ROW_OFFSET, COLS } from '../sim/constants.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']);
|
||
/** 特制水上平台 (act1 m05, weight 0 this season): its tile takes any unit. */
|
||
const WATER_PLATFORM_ROLES = new Set(['waterPlatform']);
|
||
|
||
/** Deploy fields: the own normal board, or the player's half of the boss field (left / mirrored right). */
|
||
export const DEPLOY_FIELDS = Object.freeze(['normal', 'bossL', 'bossR']);
|
||
/**
|
||
* How many rows a board row lies below its boss-field row: board row = boss-field row + 7 (official
|
||
* player_map_ud_offset 7; = −sim BOSS_ROW_OFFSET). Note the sign: render/prepfield.js BOSS_ROW_SHIFT is −7 (board →
|
||
* display row), the opposite direction.
|
||
*/
|
||
export const BOARD_ROWS_ABOVE_BOSS = -BOSS_ROW_OFFSET;
|
||
/** Mirror column of the right boss half: board col c ↔ field col 20 − c (sim Battle.mapTile). */
|
||
export const BOSS_MIRROR_COL = COLS - 1;
|
||
const fieldOf = (f) => (DEPLOY_FIELDS.includes(f) ? f : 'normal');
|
||
|
||
/** The stage tile [row, col] a board tile (r, c) stands on in deploy field `field`. */
|
||
export function fieldTile(field, r, c) {
|
||
const f = fieldOf(field);
|
||
if (f === 'normal') return [r, c];
|
||
return [r - BOARD_ROWS_ABOVE_BOSS, f === 'bossR' ? BOSS_MIRROR_COL - c : c];
|
||
}
|
||
|
||
/** Inverse of fieldTile: the board tile of stage tile (r, c) in deploy field `field`. */
|
||
export function boardTileOf(field, r, c) {
|
||
const f = fieldOf(field);
|
||
if (f === 'normal') return [r, c];
|
||
return [r + BOARD_ROWS_ABOVE_BOSS, f === 'bossR' ? BOSS_MIRROR_COL - c : c];
|
||
}
|
||
|
||
/**
|
||
* @param {object|null} stage data/stages.json entry
|
||
* @param {{ deviceOverrides?: Record<string, boolean>, tileOverrides?: Record<string, string>, field?: 'normal'|'bossL'|'bossR' }} [opts]
|
||
* @returns {Map<string, 'melee'|'ranged'>} keyed by BOARD tile 'r,c' (rows 9–12, cols 2–10)
|
||
*/
|
||
export function buildDeployMap(stage, { deviceOverrides = {}, tileOverrides = {}, field = 'normal' } = {}) {
|
||
/** @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++) {
|
||
for (let c = FIELD.c0; c <= FIELD.c1; c++) {
|
||
const [sr, sc] = fieldTile(field, r, c);
|
||
const line = rows && typeof rows[sr] === 'string' ? rows[sr] : null;
|
||
let cls = null;
|
||
if (line) {
|
||
const g = line[sc];
|
||
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] = boardTileOf(field, d.pos[0], d.pos[1]);
|
||
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');
|
||
else if (WATER_PLATFORM_ROLES.has(d.role)) map.set(k, 'melee');
|
||
}
|
||
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'. A MELEE chess whose trait reads
|
||
* 「可以放置于远程位」 (shared/highGround.js: 歌蕾蒂娅, 崖心, 见行者, normal and elite) is widened to 'all': it may also
|
||
* stand on the ranged (高台) tiles. Every other MELEE record stays 'melee'. `chess.json` has no `placement` field.
|
||
* @param {object|null} rec
|
||
*/
|
||
export function positionClass(rec) {
|
||
return meleeOnHighGround(rec) ? 'all' : basePositionClass(rec);
|
||
}
|
||
|
||
/**
|
||
* Placement class of a record on a player's board — the server's single entry point for pieces (g.move, swaps, merges,
|
||
* the audit, the invariants, the bot). Since the owner's decision of 2026-10-05 the player's loadout no longer changes
|
||
* it (any module, or none). A chess the player fields as its stand-in (0.2.0 补位, `ps.fieldRecord`) is placed by the
|
||
* stand-in's position — the body that is deployed (圣约送葬人's 预备干员-先锋 stands on the ground, 塞雷娅's Touch
|
||
* anywhere) [ASSUMED: the official deploy check reads the fielded character]; tokens and records without a chess id
|
||
* are their own body.
|
||
* @param {{ fieldRecord?: (rec: object) => object }|null} ps
|
||
* @param {object|null} rec
|
||
*/
|
||
export function placeClass(ps, rec) {
|
||
if (!rec || !rec.chessId || !ps || typeof ps.fieldRecord !== 'function') return positionClass(rec);
|
||
let body;
|
||
try {
|
||
body = ps.fieldRecord(rec) || rec;
|
||
} catch {
|
||
body = rec;
|
||
}
|
||
return positionClass(body);
|
||
}
|
||
|
||
/**
|
||
* The record's own position class ('melee' | 'ranged' | 'all'), without the placement widening — the class the battle
|
||
* plays (a MELEE operator still blocks on a ground tile; on a 高台 it blocks nothing): the bots' blocker test and their
|
||
* 高台 preference for a widened melee chess (bot.js isBlocker / planLayoutSteps).
|
||
*/
|
||
export function basePositionClass(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
|
||
}
|
||
|
||
/**
|
||
* Board tiles ('r,c' keys) of an owner's attack range: `grid` (facing-RIGHT [dRow, dCol] offsets) rotated by `dir`
|
||
* around (r, c) — where a "只能部署在召唤者攻击范围内" summon (tokens.json `ownerRange`) may stand. The owner's own tile is
|
||
* part of most grids but always occupied by the owner. public/js/ui/gameLogic.js mirrors it for the client.
|
||
* @param {number[][]|null} grid
|
||
* @returns {Set<string>}
|
||
*/
|
||
export function ownerRangeKeys(grid, r, c, dir) {
|
||
const out = new Set();
|
||
for (const g of Array.isArray(grid) ? grid : []) {
|
||
if (!Array.isArray(g)) continue;
|
||
const [dr, dc] = rotateOffset(g[0], g[1], dir);
|
||
out.add(tileKey(r + dr, c + dc));
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/** 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 in reading order: top row first (row desc), left to right within a row (col asc) — the battle input's order
|
||
* (unit ids), the garrisons' dispatch order and the lists the views send. Not the deployment order: the battle deploys by
|
||
* column (Battle.start; mergeTile follows that one).
|
||
*/
|
||
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);
|
||
}
|
||
|
||
/**
|
||
* Where a merge's elite stands (PRTS 卫戍协议/帮助 "若消耗已部署至作战区的干员,则发送至作战区对应位置"): of the board tiles
|
||
* the consumed copies stood on (`[{ key, dir }]`), the first in deployment order (the left board column first, top to
|
||
* bottom within a column — the copy the battle deploys first, Battle.start; the right-hand boss half is mirrored on
|
||
* screen but deploys in the same board order) that `legal(r, c)` accepts for the elite, or null (⇒ the hand). [ASSUMED]
|
||
* the order: the official text names one position. Until 0.1.3 the deployment order, and so this one, was top row first.
|
||
* public/js/ui/gameLogic.js mergeTarget mirrors it for the client.
|
||
* @param {Array<{ key: string, dir?: string }>} tiles
|
||
* @param {(r: number, c: number) => boolean} [legal]
|
||
* @returns {{ key: string, dir?: string, r: number, c: number } | null}
|
||
*/
|
||
export function mergeTile(tiles, legal = () => true) {
|
||
const sorted = (tiles || []).map((t) => { const [r, c] = parseKey(t.key); return { ...t, r, c }; }).sort((a, b) => a.c - b.c || b.r - a.r);
|
||
return sorted.find((t) => inField(t.r, t.c) && legal(t.r, t.c)) || null;
|
||
}
|
||
|
||
/** 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;
|
||
}
|