feedback1 WC: no deployment in the water, tactical points inside the tactician's range
Player reports #3 and #9 after the 0.1.0 release. #3 On 战场#08(下半) 涨潮控制 (act2autochess_m04) operators could be placed in the pool and 突袭 members jumped into it. The 深水区 (tile_deepsea) has buildableType ALL in the level file, but it refuses deployment (PRTS 地形 深水区 "地形机制:拒绝 部署"; the season-1 战场#05 puts a 特制水上平台 - "在水上建立可以部署任意单位的 平台" - on every one of its water tiles). - grid.js DEPLOY_REFUSED_TILES: the legend's effective build is NONE, so canStand (突袭 landing tile, tactical points, summon tiles) never picks the water; a hard-blocked 射击台 / mound tile takes no melee unit either. - build-data: stages.json legend `buildable` is the effective type (the level's value kept in `buildableType`), deployTiles exclude the water, a 特制水上平台 makes its tile deployable (act1 m05, inactive). board.js / gameLogic.js deploy maps follow (water platforms included); bots plan on the same map. - Gate routes are unchanged; the water now counts as non-blockable ground in the path tie-break (audit lists updated). #9 伺夜's 狼群 and 缪尔赛思's 流形 ("只能部署在召唤者攻击范围内", tokens.json `ownerRange`) could be placed anywhere. - PlayerState._legal / summonRange: a range-bound summon only on a tile of its owner's attack range (loadout grid rotated by the owner's facing); swaps with the owner are checked from the owner's new tile; re-orienting (or promoting) the owner sends a summon left outside back onto its stack with a toast. - The client lights and accepts the same tiles (gameLogic.summonRange), the bot plans inside them, the invariants check it, the 伺夜 kit re-uses the piece's tile only inside her range. Tests: test/match/feedback1-placement, test/sim/feedback1-water, the opt-in headless-Chrome test/ui/feedback1-placement.e2e (fastServer hook SP_STAGE); existing summon / deploy / pathing tests moved off the water tiles.
This commit is contained in:
1 parent
7f134737ce
commit
c92867656d
26 files changed
+789
-73
No files matched your search
@@ -42,7 +42,12 @@
|
||||
// it swaps it away) sends its placed summons back onto their stack ("移动干员时,其所属召唤物全部退场并重置至手牌区");
|
||||
// a summon stack removed from temp at a prep deadline comes back at the next round start (startRound tops every
|
||||
// board owner's summons up to the deploy limit, "干员所属召唤物会于下一回合返还"). In battle a skill's summon takes its
|
||||
// tile when the skill fires (sim/content/tokens.js dockSkillSummons).
|
||||
// tile when the skill fires (sim/content/tokens.js dockSkillSummons). A summon whose text reads "只能部署在召唤者
|
||||
// 攻击范围内" (tokens.json `ownerRange`: the tacticians' 狼群 / 流形 — their tactical point; player report #9 after
|
||||
// 0.1.0) only goes on a tile of its owner's attack range (_legal / summonRange: the loadout's grid rotated by the
|
||||
// owner's facing); a swap with its owner is checked from the owner's new tile, and one an in-place re-orientation
|
||||
// (or a promotion) leaves outside goes back onto its stack (recompute → _liftOutOfRange) [ASSUMED: kept when still
|
||||
// inside].
|
||||
// * Facing (DESIGN §3, research 09 §1.2): every board piece has `dir` ∈ UP|RIGHT|DOWN|LEFT (server/sim/dir.js), set
|
||||
// by g.move {…, dir} (absent ⇒ RIGHT) and kept across rounds. g.move onto the piece's OWN tile re-orients it in
|
||||
// place; a swap keeps the occupant's dir; pieces put on the board by effects (a merge elite taking a consumed
|
||||
@@ -56,7 +61,8 @@
|
||||
|
||||
import { ERR, GEO, PHASE, layerGainRoom } from '../../shared/constants.js';
|
||||
import { checkLoadout, resolveLoadout } from '../../shared/protocol.js';
|
||||
import { FIELD, tileKey, parseKey, inField, canPlace, positionClass, boardOrder, freeSlot, pieceDir, parseDir, mergeTile } from './board.js';
|
||||
import { FIELD, tileKey, parseKey, inField, canPlace, positionClass, boardOrder, freeSlot, pieceDir, parseDir, mergeTile, ownerRangeKeys } from './board.js';
|
||||
import { attackRangeGrid, loadoutRecord, resolveRecordLoadout } from '../../shared/loadoutRecord.js';
|
||||
import { offsetTile } from '../sim/dir.js';
|
||||
import { computeBonds, bondList, bondSnapshot, activatedLayers, bondsWithGains } from './bondsMeta.js';
|
||||
import { itemKey } from './gamedata.js';
|
||||
@@ -948,7 +954,58 @@ export class PlayerState {
|
||||
return positionClass(rec);
|
||||
}
|
||||
|
||||
_legal(piece, r, c) { return canPlace(this.deployMap(), this._placementOf(piece), r, c); }
|
||||
/**
|
||||
* 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 rec = at && this.gd.chess(at.piece.id);
|
||||
if (!rec) return null;
|
||||
const grid = attackRangeGrid(loadoutRecord(rec, resolveRecordLoadout(rec, this.loadoutFor(rec)))) || 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) 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]. Returns the number lifted; a toast names them.
|
||||
*/
|
||||
_liftOutOfRange() {
|
||||
const names = [];
|
||||
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);
|
||||
if (!this._returnToken(p, null, { allowTemp: true })) { this.board.set(k, p); continue; } // nowhere to go: it stays put
|
||||
names.push(this.gd.token(p.id)?.name || p.id);
|
||||
}
|
||||
if (names.length) this.m.toast(this, 'warn', `${names.join('、')}只能部署在召唤者攻击范围内,已退回整备区`);
|
||||
return names.length;
|
||||
}
|
||||
|
||||
_moveChessToBoard(loc, r, c, dir = 'RIGHT') {
|
||||
const piece = loc.piece;
|
||||
@@ -958,10 +1015,12 @@ export class PlayerState {
|
||||
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)
|
||||
// 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);
|
||||
if (!this._legal(occ, sr, sc)) return fail(ERR.BAD_TILE);
|
||||
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 {
|
||||
@@ -994,9 +1053,12 @@ export class PlayerState {
|
||||
return OK;
|
||||
}
|
||||
|
||||
/** In-place re-orientation (g.move onto the piece's own tile with a new direction). */
|
||||
/**
|
||||
* 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).
|
||||
*/
|
||||
_reorient(piece, dir) {
|
||||
if (pieceDir(piece) !== dir) { piece.dir = dir; this.dirty(); }
|
||||
if (pieceDir(piece) !== dir) { piece.dir = dir; this.recompute(); }
|
||||
return OK;
|
||||
}
|
||||
|
||||
@@ -1022,9 +1084,13 @@ export class PlayerState {
|
||||
|
||||
_moveTokenToBoard(loc, r, c, dir = 'RIGHT') {
|
||||
const piece = loc.piece;
|
||||
if (!inField(r, c) || !this._legal(piece, r, c)) return fail(ERR.BAD_TILE);
|
||||
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
|
||||
@@ -1366,6 +1432,7 @@ export class PlayerState {
|
||||
recompute() {
|
||||
this.deployMap(); // a change of the deploy field (a boss round's prep) marks the legality stale
|
||||
if (this._legalityStale) this._evictIllegal();
|
||||
this._liftOutOfRange();
|
||||
this.bonds = computeBonds(this.gd, this);
|
||||
this.dirty();
|
||||
}
|
||||
|
||||
+30
-4
@@ -2,10 +2,12 @@
|
||||
//
|
||||
// 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
|
||||
// ("所有行动内远程干员可部署在近战位").
|
||||
// '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.
|
||||
// (absent) — not deployable (NONE, forbidden, road lanes, tiles under active crates/mounds).
|
||||
// (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).
|
||||
@@ -20,11 +22,15 @@
|
||||
// mapped to board coordinates (test/match/playtest5-deploy.test.js).
|
||||
//
|
||||
// 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 { DEFAULT_DIR, isDir } from '../sim/dir.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 }
|
||||
@@ -38,6 +44,8 @@ export const inField = (r, c) => Number.isInteger(r) && Number.isInteger(c) && r
|
||||
|
||||
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']);
|
||||
@@ -108,6 +116,7 @@ export function buildDeployMap(stage, { deviceOverrides = {}, tileOverrides = {}
|
||||
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);
|
||||
@@ -135,6 +144,23 @@ export function canPlace(map, pos, r, c) {
|
||||
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 = [];
|
||||
|
||||
+6
-2
@@ -34,7 +34,9 @@
|
||||
// generators that yield between whole actions (never with a transient board) — the same actions in the same
|
||||
// order as the one-shot functions (runSteps), hence the same rng draws and decisions.
|
||||
// The summon cards of the placed operators (赫默's 医疗探机, 伺夜's 狼群 …; user playtest #6) are placed after
|
||||
// them on the best remaining tiles, 凯瑟琳's 支援装置 next to the best operator no device faces yet, facing it.
|
||||
// them on the best remaining tiles — the tacticians' 狼群 / 流形 only inside their owner's attack range
|
||||
// (PlayerState.summonRange; player report #9 after 0.1.0) —, 凯瑟琳's 支援装置 next to the best operator no device
|
||||
// faces yet, facing it.
|
||||
// 5. equip items on the strongest deployed damage dealers (consume-on-equip items / Arts only with a handler)
|
||||
// 6. resolve the temp slots, keep one hand slot free, then Ready.
|
||||
// Placement quality (tools/matchrun sweeps, research-faithful waves): the planner beats random layouts by ≈ 8 points
|
||||
@@ -606,9 +608,11 @@ export function* planLayoutSteps(m, ps, pieces, params = LAYOUT_PARAMS, { occupi
|
||||
if (!r0) continue;
|
||||
let best = null;
|
||||
let bestV = -Infinity;
|
||||
// a "只能部署在召唤者攻击范围内" summon (伺夜's 狼群, 缪尔赛思's 流形): only the tiles of its owner's range
|
||||
const within = p.kind === 'token' && typeof ps.summonRange === 'function' ? ps.summonRange(p) : null;
|
||||
for (const [r, c] of legalTiles(map, positionClass(r0))) {
|
||||
const k = tileKey(r, c);
|
||||
if (taken.has(k)) continue;
|
||||
if (taken.has(k) || (within && !within.has(k))) continue;
|
||||
const noise = m.rngBots() * 1e-6;
|
||||
const seen = new Set();
|
||||
for (const dir of PLAN_DIRS) {
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
// pieces unique uids; hand 10 / temp 5 slots; chess carry ≤ equipPerChess known items; a normal piece holds ≤ 1
|
||||
// copy, an elite ≤ goldenCopies; merges are immediate (never `mergeCount` normal copies of one chess, never
|
||||
// two copies of a mergeable normal item); every token's owner chess is deployed
|
||||
// board tiles inside the own region and legal for the piece; no items on the board; chess count ≤ deploy cap
|
||||
// board tiles inside the own region and legal for the piece (a range-bound summon inside its owner's attack
|
||||
// range); no items on the board; chess count ≤ deploy cap
|
||||
// (where a merge's elite goes — a consumed deployed copy's tile, else the hand — needs the state before the
|
||||
// merge: audit.js checks it per merge)
|
||||
// bonds ps.bonds equals a fresh computeBonds() (every mutation recomputed them); every bond's layers 0 … BOND_LAYER_CAP
|
||||
@@ -111,6 +112,9 @@ export function collectViolations(m, { limit = 25 } = {}) {
|
||||
if (p.kind === 'item') { fail(`${id}: item ${p.id} stands on the board`); continue; }
|
||||
const rec = p.kind === 'token' ? gd.token(p.id) : gd.chess(p.id);
|
||||
if (rec && !canPlace(dmap, positionClass(rec), r, c)) fail(`${id}: ${p.id} on an illegal tile ${k}`);
|
||||
// a "只能部署在召唤者攻击范围内" summon inside its owner's attack range (PlayerState.summonRange: a pure read)
|
||||
const range = p.kind === 'token' && typeof ps.summonRange === 'function' ? ps.summonRange(p) : null;
|
||||
if (range && !range.has(k)) fail(`${id}: ${p.id} on ${k}, outside its owner's attack range`);
|
||||
if (p.kind === 'chess') deployed++;
|
||||
}
|
||||
if (deployed > ps.deployCap) fail(`${id}: ${deployed} chess deployed > cap ${ps.deployCap}`);
|
||||
|
||||
@@ -23,7 +23,9 @@
|
||||
// + 脆弱 ×damage_scale for weak[limit] s
|
||||
// 助力 deputShip all operators DEF +(base + per·L), redeploy time ×(1 + respawn_time)
|
||||
// 突袭 raidShip member idle ≥ no_attack_duration s (or skill ready) with no enemy in range → "保留技力立即再部署"
|
||||
// next to the most advanced ground enemy: a real redeployment (retreat + free redeploy on the
|
||||
// next to the most advanced ground enemy, on a free tile its position may be deployed on
|
||||
// (grid.canStand: never the 深水区 — player report #3 after 0.1.0, members dropped into 战场#08's
|
||||
// pool after an enemy wading in it): a real redeployment (retreat + free redeploy on the
|
||||
// landing tile, full HP, `deploy` fires — 部署时 traits such as 史尔特尔, 突袭手雷, 卡西米尔, 叙拉古)
|
||||
// with its SP / charges kept (engine redeploy tile + keepSp); ATK/HP +(base + per·L)
|
||||
// until it leaves the field; later redeploys use its board tile again;
|
||||
|
||||
@@ -137,12 +137,14 @@ function freeTile(battle, r, c) {
|
||||
/** Walkable ground tile a melee summon can stand on. */
|
||||
const groundTile = (battle, r, c) => battle.grid.groundPassable(r, c) && battle.grid.canStand(r, c);
|
||||
/**
|
||||
* Tactical point (战术点) of a tactician: `prefer` (the board piece's tile, i.e. the player's choice) when usable,
|
||||
* else the shared tactical point (tokens.js tacticalPoint = Battle.findTacticalPoint: a free walkable tile of its
|
||||
* initial range on an enemy ground path first, then the nearest one).
|
||||
* Tactical point (战术点) of a tactician: `prefer` (the board piece's tile, i.e. the player's choice) when usable —
|
||||
* free, standable ground (never 深水区: grid.canStand) inside her initial range ("只能部署在召唤者攻击范围内"; the prep
|
||||
* already keeps the piece there, PlayerState._legal) —, else the shared tactical point (tokens.js tacticalPoint =
|
||||
* Battle.findTacticalPoint: a free walkable tile of its initial range on an enemy ground path first, then the nearest).
|
||||
*/
|
||||
function tacticalPoint(battle, unit, prefer = null) {
|
||||
if (prefer && freeTile(battle, prefer[0], prefer[1]) && groundTile(battle, prefer[0], prefer[1])) return prefer;
|
||||
const inRange = (r, c) => (unit.baseRangeKeys || unit.rangeKeys || []).includes(r * COLS + c);
|
||||
if (prefer && freeTile(battle, prefer[0], prefer[1]) && groundTile(battle, prefer[0], prefer[1]) && inRange(prefer[0], prefer[1])) return prefer;
|
||||
return sharedTacticalPoint(battle, unit);
|
||||
}
|
||||
/** Tokens `tokenId` summoned by / placed for `owner` (board pieces included). */
|
||||
|
||||
+24
-4
@@ -5,6 +5,11 @@
|
||||
// Stage input: `{ id, rows: string[19] (bottom-first), legend?: {glyph: {...}}, devices?: [...] }`.
|
||||
// Legend entries are normalised from either the research format (heightType/buildableType/passableMask/tileKey)
|
||||
// or the build-data format (height/build/passable/terrain); missing glyphs fall back to DEFAULT_LEGEND.
|
||||
// `build` is the EFFECTIVE deploy type: a tile whose mechanism refuses deployment (DEPLOY_REFUSED_TILES: 深水区
|
||||
// tile_deepsea — PRTS 地形 深水区 "地形机制:拒绝部署"; player report after 0.1.0: operators stood in 战场#08's pool and
|
||||
// 突袭 members jumped into it) is NONE whatever its level buildableType (ALL), so `canStand` — every automatic
|
||||
// placement: the 突袭 landing tile, tactical points, summon tiles — and the path tie-break below treat it as ground no
|
||||
// operator can stand on. data/stages.json legends already carry the effective value (tools/build-data.mjs).
|
||||
//
|
||||
// Pathfinding = the official client's (`Torappu.Battle.SPFA`, research 08 §3.1/§3.4): one FLOW FIELD per destination,
|
||||
// a FIFO SPFA from the destination over the rect with the 4 neighbours UP (row+1), RIGHT, DOWN, LEFT (in that order,
|
||||
@@ -18,7 +23,8 @@
|
||||
// operator can block them"). Two refinements on top of the official algorithm; `dist` (so the crate cost 1000 and
|
||||
// every grid route length) stays exactly the official one:
|
||||
// * tie-break: the SPFA relaxes on (dist, pen) lexicographically, `pen` = number of NON-BLOCKABLE walkable tiles
|
||||
// (floor / gate / goal / teleport tiles — `blockable()` false: not LOW ground buildable for melee) on the chain.
|
||||
// (floor / gate / goal / teleport / 深水区 tiles — `blockable()` false: not LOW ground buildable for melee) on the
|
||||
// chain (the 深水区 on 战场#08's routes is unavoidable: no route there changed when it became non-buildable).
|
||||
// Among equal-length chains the one with the fewest non-blockable tiles wins; remaining ties go to the first
|
||||
// parent in SPFA order (the official order unless a pen improvement re-queued a tile).
|
||||
// * smoothing: a line of sight may only cross a non-blockable tile (including the corner tiles of a diagonal step)
|
||||
@@ -67,10 +73,17 @@ export const DEFAULT_LEGEND = Object.freeze({
|
||||
O: { height: 'LOW', build: 'NONE', pass: 'ALL', key: 'tile_telout', special: 'telout' },
|
||||
m: { height: 'LOW', build: 'ALL', pass: 'ALL', key: 'tile_mire', terrain: 'mire' },
|
||||
g: { height: 'LOW', build: 'ALL', pass: 'ALL', key: 'tile_smog', terrain: 'smog' },
|
||||
d: { height: 'LOW', build: 'ALL', pass: 'ALL', key: 'tile_deepsea', terrain: 'deepsea' },
|
||||
d: { height: 'LOW', build: 'NONE', pass: 'ALL', key: 'tile_deepsea', terrain: 'deepsea' },
|
||||
i: { height: 'LOW', build: 'ALL', pass: 'ALL', key: 'tile_infection', terrain: 'infection' },
|
||||
});
|
||||
|
||||
/**
|
||||
* Tile keys whose mechanism refuses deployment although the level's buildableType allows it: 深水区 tile_deepsea (PRTS
|
||||
* 地形 深水区 "部署类型 全部位 … 地形机制 拒绝部署"; the season-1 战场#05 puts a 特制水上平台 — "在水上建立可以部署任意单位
|
||||
* 的平台" — on every one of its 深水区 tiles). Shared with tools/build-data.mjs (the stages.json legend's `buildable`).
|
||||
*/
|
||||
export const DEPLOY_REFUSED_TILES = Object.freeze(new Set(['tile_deepsea']));
|
||||
|
||||
const TERRAIN_BY_KEY = { tile_mire: 'mire', tile_smog: 'smog', tile_deepsea: 'deepsea', tile_infection: 'infection', tile_deepwater: 'deepsea' };
|
||||
const SPECIAL_BY_KEY = { tile_start: 'start', tile_end: 'end', tile_telin: 'telin', tile_telout: 'telout' };
|
||||
|
||||
@@ -92,7 +105,8 @@ export function normalizeLegendEntry(glyph, e) {
|
||||
const heightRaw = e.height ?? e.heightType;
|
||||
const height = heightRaw == null ? base.height : (/HIGH/i.test(String(heightRaw)) ? 'HIGH' : 'LOW');
|
||||
const buildRaw = e.build ?? e.buildable ?? e.buildableType;
|
||||
const build = buildRaw == null ? base.build : (buildRaw === true ? 'ALL' : buildRaw === false ? 'NONE' : String(buildRaw).toUpperCase());
|
||||
const build = DEPLOY_REFUSED_TILES.has(key) ? 'NONE'
|
||||
: buildRaw == null ? base.build : (buildRaw === true ? 'ALL' : buildRaw === false ? 'NONE' : String(buildRaw).toUpperCase());
|
||||
let pass;
|
||||
if (e.pass != null) pass = normPass(e.pass);
|
||||
else if (e.passable != null || e.passableMask != null) pass = normPass(e.passable ?? e.passableMask);
|
||||
@@ -171,11 +185,17 @@ export class Grid {
|
||||
|
||||
flyPassable(r, c) { return this.inRect(r, c) && this.tile(r, c).pass !== 'NONE'; }
|
||||
|
||||
/** Whether a unit may stand on this tile. `ranged` units may also use LOW ALL/MELEE tiles (DESIGN §3). */
|
||||
/**
|
||||
* Whether a unit may be placed on this tile (every automatic placement: the 突袭 landing tile, tactical points, summon
|
||||
* tiles). `ranged` units may also use LOW ALL/MELEE tiles (DESIGN §3). The effective `build` refuses the 深水区
|
||||
* (DEPLOY_REFUSED_TILES); a hard-blocked tile (OB_BLOCK: a 射击台 switched on by a map card, a mound) takes no melee
|
||||
* unit — the match's deploy map makes a 射击台 ranged-only (server/match/board.js).
|
||||
*/
|
||||
canStand(r, c, { ranged = false } = {}) {
|
||||
const t = this.tile(r, c);
|
||||
if (t.build === 'NONE') return false;
|
||||
if (ranged) return t.build === 'ALL' || t.build === 'RANGED' || (t.height === 'LOW' && t.build === 'MELEE');
|
||||
if (this.inBounds(r, c) && (this.obstacle[r * COLS + c] & OB_BLOCK)) return false;
|
||||
return t.height === 'LOW' && (t.build === 'ALL' || t.build === 'MELEE');
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user