feedback1 WC: tactician summons never left out of range with a full hand; source fixes

Review follow-up to the water / tactical point change.

- Re-orienting 伺夜 / 缪尔赛思 in place while hand and temp are full could leave
  a placed 狼群 / 流形 outside the new range (it had nowhere to go and stayed
  put). The re-orientation is now refused with HAND_FULL when a summon it
  would leave out has no stack and no free slot. On the other paths
  (promotion, an owner moved with no room) such a summon leaves the board and
  its stack comes back at the next round start (grantTokensFor), so no
  out-of-range placement reaches the battle.
- Citation corrected: the 深水区 rule comes from the PRTS page 深水区 (地形信息
  tile_deepsea: 部署类型 全部位, 地形机制 "拒绝部署(待补充)"), corroborated by
  PRTS 作战机制 (地形标记 AdvancedBuildableMask lists 深水). There is no PRTS
  page 地形.
- grid.js: the tie-break note now says what changed on 战场#08 (no gate
  route; a few off-route smoothed steps of the boss field go around the water).
- Documented the known limit of the inactive act1 m05: its 特制水上平台 tiles
  are deployable in the prep's deploy map only; the sim does not model the
  canoes and keeps those tiles non-standable.
- gameLogic.summonRange reuses facing.js rangeTiles.
This commit is contained in:
sganggs committed 2026-10-03 02:46:07 +08:00
1 parent c92867656d
commit 60ed4fdab9
14 files changed
+136 -63

No files matched your search

+40 -11
View File
@@ -47,7 +47,9 @@
// 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].
// inside]. A re-orientation that would leave such a summon with no stack and no free hand / temp slot is refused
// (HAND_FULL); elsewhere (a promotion, an owner moved with no room) it leaves the board and its stack comes back at
// the next round start (grantTokensFor) — no out-of-range placement reaches the battle.
// * 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
@@ -989,22 +991,42 @@ export class PlayerState {
/**
* 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.
* 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 names = [];
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);
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);
(this._returnToken(p, null, { allowTemp: true }) ? back : gone).push(this.gd.token(p.id)?.name || p.id);
}
if (names.length) this.m.toast(this, 'warn', `${names.join('、')}只能部署在召唤者攻击范围内,已退回整备区`);
return names.length;
if (back.length) this.m.toast(this, 'warn', `${back.join('、')}只能部署在召唤者攻击范围内,已退回整备区`);
if (gone.length) this.m.toast(this, 'warn', `${gone.join('、')}只能部署在召唤者攻击范围内,整备区已满,下回合返还`);
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') {
@@ -1055,10 +1077,17 @@ export class PlayerState {
/**
* 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).
* 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) { piece.dir = dir; this.recompute(); }
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;
}
+3 -2
View File
@@ -6,8 +6,9 @@
// 部署任意单位的平台"): 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, 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).
// `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).
+3 -1
View File
@@ -24,7 +24,9 @@
// the field only (unite / boss fields hold both halves); Battle opts.deviceOverrides switch every half.
// `deviceOverridesOf(battle)` is the union (informational).
// Terrain numbers come from `stage.special` (fallback: the device's skill blackboard, then research 05 values).
// Not implemented: the devices of act1 m05–m07 (canoe platforms, sandstorm + mounds, bushes) — weight 0 this season.
// Not implemented: the devices of act1 m05–m07 (canoe platforms, sandstorm + mounds, bushes) — weight 0 this season. The
// canoe (特制水上平台) makes its 深水区 deployable in the prep's deploy map only (server/match/board.js); the sim's Grid
// keeps those tiles NONE (grid.js DEPLOY_REFUSED_TILES), so automatic placements never use them [ASSUMED].
import { COLS, ROWS } from '../constants.js';
import { performAttack } from '../ai.js';
+12 -7
View File
@@ -6,10 +6,12 @@
// 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).
// 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).
// Known limit [ASSUMED]: the 特制水上平台 that makes a 深水区 deployable (act1 m05, weight 0 this season) exists only in
// the match's deploy map; the sim does not model those canoes (content/devices.js), so here their tiles stay NONE.
//
// 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,
@@ -24,7 +26,8 @@
// 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 (the 深水区 on 战场#08's routes is unavoidable: no route there changed when it became non-buildable).
// chain (no gate route of 战场#08 changed when its 深水区 became non-buildable; a few off-route smoothed steps of its
// boss field, rows 4–5, now go around the water instead of cutting across it).
// 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)
@@ -79,8 +82,10 @@ export const DEFAULT_LEGEND = Object.freeze({
/**
* 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`).
* 深水区 地形信息 "部署类型 全部位 … 地形机制 拒绝部署(待补充)"; PRTS 作战机制: the 地形标记 AdvancedBuildableMask —
* 深水 among them — "限制玩家仅能部署匹配的单位于其上"; 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']));