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

+3 -3
View File
@@ -403,12 +403,12 @@ exactly (the builder warns if a multi-fragment template ever appears).
| `weight`, `active`, `modes` | `50`, `true`, `["mode_single_normal",…]` | match-start pick weight (act1 m05–m07 weight 0) |
| `size` | `[19,21]` | |
| `rows[]` | `rows[9] = "##Errr#rrrSrrr#rrrS##"` | 19 strings, **index = row (0 = bottom)**, one glyph per col |
| `tiles[glyph]` | `{"tileKey":"tile_road","height":"LOW","buildable":"ALL","passable":"ALL","groundPassable":true,"flyPassable":true,"special":null,"bb":{}}` | actual tile properties of each glyph used. `buildable` is the **effective** deploy type: the level's buildableType, except a tile whose mechanism refuses deployment — 深水区 `tile_deepsea` (PRTS 地形 深水区 "部署类型 全部位 … 地形机制 拒绝部署"; player report #3 after 0.1.0) — which is `NONE` and keeps the level's value in `buildableType` (`server/sim/grid.js DEPLOY_REFUSED_TILES`, shared with the builder) |
| `tiles[glyph]` | `{"tileKey":"tile_road","height":"LOW","buildable":"ALL","passable":"ALL","groundPassable":true,"flyPassable":true,"special":null,"bb":{}}` | actual tile properties of each glyph used. `buildable` is the **effective** deploy type: the level's buildableType, except a tile whose mechanism refuses deployment — 深水区 `tile_deepsea` (PRTS 深水区 地形信息 "部署类型 全部位 … 地形机制 拒绝部署(待补充)"; player report #3 after 0.1.0) — which is `NONE` and keeps the level's value in `buildableType` (`server/sim/grid.js DEPLOY_REFUSED_TILES`, shared with the builder) |
| `devices[]` | `{"key":"trap_013_blower","name":"源石流发生装置","alias":"trap_013_blower#001","pos":[13,5],"dir":"DOWN","hidden":false,"role":"blower","active":true,"stats":{…},"rangeGrid":[[0,0],…],"rangeTiles":[[13,5],[12,5],[11,5],[10,5]],"skill":{"skillId":"sktok_blower","bb":{"blower_s_character[equal].atk":0.3,…}},"desc":null}` | predefined devices; `role` ∈ crate, platform, mound, blower, mireController, tideController, turret (双眼皮, activated by band 机械援助), coldWind, sandstorm, waterPlatform, sealedFloor, bush, bossSpawn; `active` = present at match start (= not `hidden` in the level file); `rangeGrid` is facing right, `rangeTiles` = absolute tiles after rotating by `dir` (RIGHT `(dr,dc)`, UP `(dc,−dr)`, LEFT `(−dr,−dc)`, DOWN `(−dc,dr)`), clipped to the grid (null without a range). Terrain cards / band effects toggle devices by `alias` |
| `mapChars[]` | `{"key":"char_605_cmedic","alias":"char_605_cmedic#1","pos":[10,2],…}` | band_amedic map characters (→ `tokens.json`) |
| `special` | `{"mire":{"intervalSec":3,"aspdPerStack":-0.05,"moveMulPerStack":-0.05,"maxStacks":10,…}}`, `{"infection":{"bb":{"damage":70,"atk":0.2,"attack_speed":20,"duration":300}}}`, `{"deepsea":{"skillId":"sktok_tidectrl_3","bb":{…}}}`, `{"smog":{"rule":"operatorsNotTargetableByEnemyRanged"}}`, `{"blower":{…}}` | special-terrain parameters |
| `runes[]`, `globalBuffs[]` | `{"key":"map_tile_blackb_assign","bb":{…},"bbStr":{…}}` | level runes (act2 m03 reed rune has no reed tiles — leftover) |
| `deployTiles` | `{"normal":{"melee":[[9,3],…],"rangedOnly":[[10,4],…],"changedByDevices":[…]},"bossLeft":{…},"bossRight":{…}}` | deployable tiles at match start: `melee` = LOW (effective) buildable ALL/MELEE (ranged may use them too) — and a 深水区 under an active 特制水上平台 (`waterPlatform`, "在水上建立可以部署任意单位的平台"; act1 m05 only) —, `rangedOnly` = high ground / active platforms; tiles under active crates/mounds and the 深水区 excluded. `bossLeft` / `bossRight` (field coordinates, boss rows 1–5) are what a player may deploy on in a boss round's prep: board (r, c) = boss (r − 7, c) / the right half mirrored (r − 7, 20 − c) (server/match/board.js `field`, user playtest #5 item 7) |
| `deployTiles` | `{"normal":{"melee":[[9,3],…],"rangedOnly":[[10,4],…],"changedByDevices":[…]},"bossLeft":{…},"bossRight":{…}}` | deployable tiles at match start: `melee` = LOW (effective) buildable ALL/MELEE (ranged may use them too) — and a 深水区 under an active 特制水上平台 (`waterPlatform`, "在水上建立可以部署任意单位的平台"; act1 m05 only — the prep's deploy map only: the sim does not model the canoes, so its Grid keeps those tiles NONE, automatic placements avoid them and the path tie-break counts them as non-blockable [ASSUMED, weight 0 this season]) —, `rangedOnly` = high ground / active platforms; tiles under active crates/mounds and the 深水区 excluded. `bossLeft` / `bossRight` (field coordinates, boss rows 1–5) are what a player may deploy on in a boss round's prep: board (r, c) = boss (r − 7, c) / the right half mirrored (r − 7, 20 − c) (server/match/board.js `field`, user playtest #5 item 7) |
| `groundPaths` / `groundPathsWithDevices` | `{"12,10->9,2":[[12,10],[12,9],…,[12,3],[11,3],[10,3],[9,3],[9,2]]}` | the ground route of the sim's own `server/sim/grid.js` flow field (research 08 §3.4: 4-direction SPFA from the goal, crates cost 1000, Bresenham line-of-sight smoothing — official route lengths — plus the remake's road-over-floor preference: among equal-length routes the fewest non-blockable floor / gate tiles, and smoothing never cuts across floor the grid route does not walk, e.g. 战场#01's lower gate takes the col-8 road, not the col-9 floor lane; DESIGN §14 corrections) as the tiles an enemy crosses, over the whole map, without / with the match-start devices (crates cost 1000, platforms / mounds blocking [ASSUMED]); the battle re-reads its flow fields live |
| `options`, `config` | `{"characterLimit":8,"moveMultiplier":0.5}`, `{…configBlackBoard strings…}` | |
@@ -429,7 +429,7 @@ Glyph legend (`rows`):
| `S` / `E` | tile_start / tile_end | LOW | NONE | yes | red gate / blue objective |
| `I` / `O` | tile_telin / tile_telout | LOW | NONE | yes | teleport in / out (boss field) |
| `m` `g` `i` | tile_mire / tile_smog / tile_infection | LOW | ALL | yes | special terrain (see `special`) |
| `d` | tile_deepsea | LOW | NONE (level: ALL) | yes | 深水区 (see `special.deepsea`): enemies wade through it; no unit may be deployed on it (PRTS "拒绝部署") — 战场#08's pool at board (10–12, 6), (10–12, 14), boss (3–5, 6) / (3–5, 14) |
| `d` | tile_deepsea | LOW | NONE (level: ALL) | yes | 深水区 (see `special.deepsea`): enemies wade through it; no unit may be deployed on it (PRTS 深水区 地形信息 "拒绝部署(待补充)") — 战场#08's pool at board (10–12, 6), (10–12, 14), boss (3–5, 6) / (3–5, 14) |
## 13. `bosses.json` — `{ [bossId]: Boss }` (10; boss_8–10 hidden)
+12 -8
View File
@@ -418,12 +418,13 @@ Any `choice:` handler whose EffectRef reuses its own key must guard like this (o
the boss field (board coordinates unchanged). `onMerge` carries `area` ('board' | 'hand' | 'temp').
* **Board**: rows 9–12 × cols 2–10, legality from `stages[id].tiles` + devices (board.js); deploy cap 8 (+effects);
summons don't use slots; board↔hand swaps always allowed. The legend's `buildable` is the effective deploy type: the
深水区 (`tile_deepsea`, 战场#08's pool — level buildableType ALL) refuses deployment (PRTS 地形 深水区 "拒绝部署"; a
特制水上平台 device makes its tile deployable for any unit; player report #3 after 0.1.0). A terrain change (terrain 机变 cards such as 模拟战场演变·
模式二 "阻隔工事变为射击台", content `setDeviceActive` / `setTileOverride`) is checked at the next `recompute()` (at the
latest when the battle input is built): a piece left on a tile it may no longer occupy — a melee operator on a new
射击台 — is withdrawn to the hand (overflow temp: re-placed during the prep; its summons leave with it), a summon back
onto its stack, with a toast (`PlayerState._evictIllegal`).
深水区 (`tile_deepsea`, 战场#08's pool — level buildableType ALL) refuses deployment (PRTS 深水区 地形信息
"拒绝部署(待补充)"; a 特制水上平台 device makes its tile deployable for any unit; player report #3 after 0.1.0). A
terrain change (terrain 机变 cards such as 模拟战场演变·模式二 "阻隔工事变为射击台", content `setDeviceActive` /
`setTileOverride`) is checked at the next `recompute()` (at the latest when the battle input is built): a piece left
on a tile it may no longer occupy — a melee operator on a new 射击台 — is withdrawn to the hand (overflow temp:
re-placed during the prep; its summons leave with it), a summon back onto its stack, with a toast
(`PlayerState._evictIllegal`).
* **Summons** (PRTS 卫戍协议/帮助 §战斗部署, user playtest #6): an operator placed on the board sends its manually deployable
summons (tokens.json `placeable`: 赫默 S2 医疗探机, 巫恋 S2 诅咒娃娃, 凯瑟琳 爬行号·防护单元, 浊心斯卡蒂 海嗣, 伺夜 狼群,
缪尔赛思 流形 — only those its equipped skill / module makes, `gamedata.placeableTokens(chessId, loadout)`) to the hand as
@@ -439,8 +440,11 @@ Any `choice:` handler whose EffectRef reuses its own key must guard like this (o
(`PlayerState._legal` / `summonRange`, `board.js ownerRangeKeys`; a summon dragged onto its own owner is checked from
the owner's new tile, an operator dragged onto the summon from the summon's new tile). When the owner is re-oriented in
place (or promoted) a summon its new range leaves out goes back onto its stack with a toast (`_liftOutOfRange` in
`recompute`) — one still inside stays [ASSUMED]. The client lights and accepts the same tiles (`ui/gameLogic.js
summonRange`), the bot plans inside them. Battle side: SIM.md §1.1 token pieces.
`recompute`) — one still inside stays [ASSUMED]. A re-orientation that would leave such a summon with no stack and no
free hand / temp slot is refused (HAND_FULL, like withdrawing a summon into a full hand); in the other cases (a
promotion, an owner moved while hand and temp are full) the summon leaves the board and its stack comes back at the
next round start (`grantTokensFor`), so no out-of-range placement reaches the battle. The client lights and accepts
the same tiles (`ui/gameLogic.js summonRange`), the bot plans inside them. Battle side: SIM.md §1.1 token pieces.
* **Bonds**: bondsMeta.js (BOARD distinct, BOARD_AND_DECK, 绝技 elites, 调和 +1, 独行 downward, 助力 upper tiers,
变形同构体 grants). Σ activated layers for the hidden core = Σ layers of active bonds at the boss round's prep end.
**Layer cap** (research 11 §1; the client's `MAX_GARRISON_STACK` / `AddBondCount` = min(L + n, 999)): each bond's
+1 -1
View File
@@ -66,7 +66,7 @@
- **点选按地上的方格**:点哪个方格就选中站在那个方格上的单位,点空方格不选任何单位(点到后一格干员画出来的头部,算作点了后面那一格)。作战中友方同样按方格;敌人在格子之间走动,点它的身体或脚下附近(0.6 格内)即可,飞行敌人点它的身体。
- 拖动时模型一直在指针 / 手指下(脚在指针下方约半格,鼠标和触屏一样),**指针所在的格子**就是落点并高亮;装备拖到哪个格子,就配发给那个格子上的干员;在商店栏上松手则放回原位。
- 点击干员显示攻击范围和底部按钮(「撤退」回到整备区、「出售 +1」);右键 / 长按查看详细信息:头像旁是盟约,下面第一项就是干员的**特质**(如「休整期结束时 …」),之后是特性、属性、攻击范围、技能、模组、装备、天赋。属性是**实时数值**:作战中每秒刷新 4 次(当前生命、攻击、防御、法抗、攻击间隔、阻挡),休整期显示棋盘上的干员**开战时**的数值(已算入装备、盟约层数、特质、策略与机变效果);高于基础值为绿色、低于为红色并标出差值(攻击间隔变短算增益),鼠标悬停可看基础值。
- **召唤物手动摆放**:把带可部署召唤物的干员放上棋盘后,它的召唤物会进入整备区(赫默的医疗探机、巫恋的诅咒娃娃、凯瑟琳的 2 个支援装置、浊心斯卡蒂的海嗣、伺夜的狼群、缪尔赛思的流形;同一干员的召唤物叠在一格,不占部署位),像干员一样拖到棋盘上并选方向。凯瑟琳的支援装置给**它面朝的那一格**上的干员加屏障,所以要放在干员旁边、朝向干员。**战术家的援军**(伺夜的狼群、缪尔赛思的流形,也就是选战术点)和官方一样**只能放在召唤者的攻击范围内**:拖动时只有这些格子会亮起;之后在原地给战术家换朝向时,不在新攻击范围里的援军会退回整备区(还在范围里的保持不动)。撤退或出售干员时它的召唤物一起移除;**移动干员到别的格子时(包括把召唤物拖到干员身上交换位置),它已摆放的召唤物会退回整备区**,需要重新摆放。整备区满时退回的召唤物进入临时整备区,休整期结束还没处理的会在下一回合重新回到整备区。
- **召唤物手动摆放**:把带可部署召唤物的干员放上棋盘后,它的召唤物会进入整备区(赫默的医疗探机、巫恋的诅咒娃娃、凯瑟琳的 2 个支援装置、浊心斯卡蒂的海嗣、伺夜的狼群、缪尔赛思的流形;同一干员的召唤物叠在一格,不占部署位),像干员一样拖到棋盘上并选方向。凯瑟琳的支援装置给**它面朝的那一格**上的干员加屏障,所以要放在干员旁边、朝向干员。**战术家的援军**(伺夜的狼群、缪尔赛思的流形,也就是选战术点)和官方一样**只能放在召唤者的攻击范围内**:拖动时只有这些格子会亮起;之后在原地给战术家换朝向时,不在新攻击范围里的援军会退回整备区(还在范围里的保持不动;整备区和临时整备区都满、援军无处可退时,这次换朝向不生效并提示「整备区已满」)。撤退或出售干员时它的召唤物一起移除;**移动干员到别的格子时(包括把召唤物拖到干员身上交换位置),它已摆放的召唤物会退回整备区**,需要重新摆放。整备区满时退回的召唤物进入临时整备区,休整期结束还没处理的会在下一回合重新回到整备区。
- 作战中:海嗣、狼群、流形、支援装置开战时就在摆放的位置部署;医疗探机、诅咒娃娃开战时也在摆放的位置免费部署一次(持续 10 / 15 秒,和官方一样不需要先开技能),之后所属干员每次发动技能时再次在原位出现;所属干员被击倒、再部署回场时,技能给的那一个如果还没出现,会立刻出现。巫恋退场时诅咒娃娃随之撤退,赫默退场时已出现的医疗探机不受影响。医疗探机、诅咒娃娃、支援装置、海嗣没有摆放就不会出现;狼群、流形(战术家的援军)没有摆放时会在战术点出现。
- **阻挡**看接触:干员阻挡中心距离约 0.7 格以内、与它接触的敌人(地面干员挡不住飞行和近地悬浮的敌人)。敌人卡在两名干员之间时,阻挡它的干员倒下、撤退或被晕眩后,另一名仍与它接触、还有空余阻挡数的干员会**立刻接替阻挡**;都没有空位时敌人才会走掉。迎面走来的敌人会停在干员格子前面。**阻挡了就一定能打到**:任何干员(包括站在近战位的远程干员和召唤物)总能攻击自己阻挡的敌人,不管它在不在攻击范围内、在干员哪一侧,并且优先攻击它(医疗干员和「停止攻击」类技能期间除外;技能原文另有限制的也按原文:普罗旺斯二技能开启时不以生命值高于 80% 的敌人为目标,寒檀二技能的冰凌随机落在攻击范围内的地块上)。
- 开战时干员按**从上到下、从左到右**的顺序部署,召唤物在所有干员之后。被阻挡的敌人先打阻挡它的干员;没被阻挡的远程敌人先打嘲讽等级高的,同级时打最后部署的(召唤物、靠下靠右的干员和刚再部署的干员更容易挨打)。隐匿的干员只会被它阻挡的敌人攻击;**迷彩**(如忍冬三技能期间击倒敌人后、寒芒克洛丝一技能)同样只会被它阻挡的敌人攻击,也显示为半透明,但它不算隐匿(不触发叙拉古等「隐匿时」效果)。叙拉古的隐匿按时(32 秒 + 每层 0.4 秒)结束;忍冬的迷彩按官方持续到她下一次开技能。
+1 -1
View File
@@ -580,7 +580,7 @@ or guard with a per-unit flag while dealing it. When the guard trips, the logged
| `forceAttack(unit, targets?, { noAmmo })` | an immediate attack with the current profile (hooks, attack SP, a running ammo skill's bullet); `noAmmo: true` = spends no bullet and emits no `ammoUsed` (圣约送葬人 extra attack). Returns true when it attacked |
| `findTacticalPoint(unit)`, `groundPathTiles()` | tactical point (战术点, tactician 援军 / talent tokens): a free (`isReservedTile`) walkable, standable (`canStand`: never the 深水区) tile of the initial range **on an enemy ground path first** (`groundPathTiles`: grid paths of every non-FLY route, cached per grid version), then nearest (Chebyshev, rows break ties), then the lowest tile key |
| `effectiveProfile(unit)`, `reduceElement(unit, amount, el?)` | |
| `battle.grid` | `tile(r,c)` → `{glyph, key, height:'LOW'|'HIGH', build, pass:'ALL'|'FLY'|'NONE', terrain, special}`, `inRect`, `canStand(r,c,{ranged})` (every automatic placement: the 突袭 landing tile, tactical points, summon tiles — `build` is the effective deploy type, so never the 深水区 `tile_deepsea` (PRTS "拒绝部署"); no melee unit on a hard-blocked 射击台 / mound tile), `groundPassable`, `isLow`, `findPath(sr,sc,er,ec)`, `specialTiles('start'|'end'|…)`; `battle.rect`, `battle.stage` (normalised stage incl. `special` terrain params) |
| `battle.grid` | `tile(r,c)` → `{glyph, key, height:'LOW'|'HIGH', build, pass:'ALL'|'FLY'|'NONE', terrain, special}`, `inRect`, `canStand(r,c,{ranged})` (every automatic placement: the 突袭 landing tile, tactical points, summon tiles — `build` is the effective deploy type, so never the 深水区 `tile_deepsea` (PRTS 深水区 地形信息 "拒绝部署(待补充)"; the 特制水上平台 of the inactive act1 m05 is not modelled, its tiles stay NONE here [ASSUMED]); no melee unit on a hard-blocked 射击台 / mound tile), `groundPassable`, `isLow`, `findPath(sr,sc,er,ec)`, `specialTiles('start'|'end'|…)`; `battle.rect`, `battle.stage` (normalised stage incl. `special` terrain params) |
| `battle.data` | DataSource: `getChess(id, loadout?)`, `getEnemy(key)`, `getToken(id, ownerChessId, ownerLoadout?)`, `getStage(id)`, `getWave(id)` (normalised defs; raw record in `def.raw`; the per-battle loadout view, §12) |
---
+8 -14
View File
@@ -6,12 +6,12 @@
// Board = own normal field (GEO.FIELD rows 9–12, cols 2–10). Melee chess stand on `melee` deploy tiles
// (LOW, buildable ALL/MELEE); ranged chess on `melee ∪ rangedOnly` (stages.json → deployTiles.normal,
// derived from the tile legend when missing — the legend's `buildable` is the effective type: 深水区 tile_deepsea
// refuses deployment, PRTS 地形 深水区 "拒绝部署", player report #3 after 0.1.0). Tokens follow their own `position`;
// a summon whose text reads "只能部署在召唤者攻击范围内" (tokens.json `ownerRange`: 伺夜's 狼群, 缪尔赛思's 流形) also
// needs a tile of its owner's attack range (`summonRange`, player report #9). In the prep of a boss round
// (最终攻势 / 隐秘核心) the tiles are the player's half of the boss field (`deployFieldOf`: 'bossL' / mirrored
// 'bossR', board (r, c) = stage tile (r − 7, c) / (r − 7, 20 − c)) like the server's deploy map (server/match/
// board.js field; user playtest #5 item 7) — board coordinates stay the same.
// refuses deployment, PRTS 深水区 地形信息 "拒绝部署(待补充)", player report #3 after 0.1.0). Tokens follow their
// own `position`; a summon whose text reads "只能部署在召唤者攻击范围内" (tokens.json `ownerRange`: 伺夜's 狼群,
// 缪尔赛思's 流形) also needs a tile of its owner's attack range (`summonRange`, player report #9). In the prep of a
// boss round (最终攻势 / 隐秘核心) the tiles are the player's half of the boss field (`deployFieldOf`: 'bossL' /
// mirrored 'bossR', board (r, c) = stage tile (r − 7, c) / (r − 7, 20 − c)) like the server's deploy map
// (server/match/board.js field; user playtest #5 item 7) — board coordinates stay the same.
// Hand = 10 slots (index = col). Temp slots are server-filled only (no move target).
// Dropping onto an occupied tile/slot swaps (both pieces must be legal at their new spots);
// an EQUIP item dropped onto a chess piece equips it (tokens can't carry items); an Arts (MAGIC) item
@@ -23,7 +23,7 @@
import { GEO, PHASE, UF } from '../../../shared/constants.js';
import { resolveLoadout, loadoutOptions, MODULE_NONE } from '../../../shared/protocol.js';
import { resolveRecordLoadout, loadoutRecord, attackRangeGrid } from '../../../shared/loadoutRecord.js';
import { rotateOffset, pieceDir } from './facing.js';
import { rangeTiles, pieceDir } from './facing.js';
import { layoutPen } from '../render/pen.js';
import { BOSS_ROW_SHIFT, MAX_COL } from '../render/prepfield.js';
import { bossLevelSeconds } from './matchStatus.js';
@@ -809,13 +809,7 @@ export function summonRange(ctx, piece, owner = null) {
if (!rec) return null;
let grid = null;
try { grid = attackRangeGrid(chessLoadout(rec, ctx.priv?.loadout ?? null, ctx.getChess)?.record || rec); } catch { /* the data grid */ }
const out = new Set();
for (const g of grid || rec.rangeGrid || []) {
if (!Array.isArray(g)) continue;
const [dr, dc] = rotateOffset(g[0], g[1], pieceDir(at.piece));
out.add(tileKey(at.row + dr, at.col + dc));
}
return out;
return new Set(rangeTiles(grid || rec.rangeGrid, at.row, at.col, pieceDir(at.piece)).map(([r, c]) => tileKey(r, c)));
}
/** tileAllows plus the owner-range rule of a range-bound summon (summonRange). */
+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']));
+42 -5
View File
@@ -1,19 +1,21 @@
// Player reports after the 0.1.0 release, placement legality on the server (PlayerState / board.js), the client's
// mirror (ui/gameLogic.js canPlace / boardTargets = the drag highlights) and the bots:
// #3 "有水池的那张图干员可以错误的被部署到水里" — 战场#08(下半) 涨潮控制 (act2autochess_m04): the 深水区 (tile_deepsea,
// board (10–12, 6)) refuses deployment (PRTS 地形 深水区 "地形机制:拒绝部署"; 特制水上平台 "在水上建立可以部署任意
// 单位的平台" on every 深水区 of the season-1 战场#05) although the level's buildableType says ALL.
// board (10–12, 6)) refuses deployment (PRTS 深水区 地形信息 "地形机制:拒绝部署(待补充)"; 特制水上平台
// "在水上建立可以部署任意单位的平台" on every 深水区 of the season-1 战场#05) although the level's buildableType
// says ALL.
// #9 "干员伺夜这样的战术家的战术点能被错误的被放到攻击范围之外" — the tacticians' summons (伺夜's 狼群, 缪尔赛思's 流形)
// read "只能部署在召唤者攻击范围内" (token description; PRTS 狼群 特性): the piece may only stand on a tile of its
// owner's attack range (the rotated grid of its board tile and facing). Re-orienting the owner in place keeps the
// piece while it is still inside, else sends it back to its stack [ASSUMED]; moving the owner sends it back anyway
// (PRTS 卫戍协议/帮助 "移动干员时,其所属召唤物全部退场并重置至手牌区").
// piece while it is still inside, else sends it back to its stack [ASSUMED] (refused, HAND_FULL, when it would have
// nowhere to go); moving the owner sends it back anyway (PRTS 卫戍协议/帮助 "移动干员时,其所属召唤物全部退场并重置
// 至手牌区") — with no room it leaves the board until the next round start.
// The sim side (突袭 landing tile, tactical point) is test/sim/feedback1-water.test.js.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { buildDeployMap, canPlace as boardCanPlace, legalTiles, positionClass, tileKey, ownerRangeKeys } from '../../server/match/board.js';
import { ERR } from '../../shared/constants.js';
import { DATA, makeMatch, give, checkInvariants, chessOfTier } from './harness.js';
import { DATA, makeMatch, give, giveItem, checkInvariants, chessOfTier } from './harness.js';
import { botPrep, planLayout } from '../../server/match/bot.js';
import { placementContext, canPlace as clientCanPlace, boardTargets, deployMap as clientDeployMap } from '../../public/js/ui/gameLogic.js';
@@ -171,6 +173,41 @@ test('#9 re-orienting the tactician: a summon still inside the new range stays,
m.dispose();
});
test('#9 hand and temp full: a re-orientation that would push 狼群 out is refused (HAND_FULL); a move drops it until the next round', () => {
const { h, m, ps } = prep();
const vigil = give(m, ps, VIGIL);
assert.deepEqual(move(m, vigil.uid, board(10, 4), 'RIGHT'), { ok: true });
assert.deepEqual(move(m, stackOf(ps, WOLF).uid, board(11, 5)), { ok: true });
const wolf = ps.board.get('11,5');
assert.equal(stackOf(ps, WOLF), null, 'its only copy is placed: no stack left to return onto');
const toasts = [];
const toast = m.toast.bind(m);
m.toast = (p, kind, text) => { toasts.push(text); return toast(p, kind, text); };
const golden = Object.keys(DATA.items).find((id) => DATA.items[id].isGolden && DATA.items[id].itemType === 'EQUIP');
while (ps.hand.includes(null)) giveItem(m, ps, golden);
while (ps.temp.includes(null)) giveItem(m, ps, golden, 'temp');
// facing LEFT would leave (11,5) outside her range and the wolf has nowhere to go: refused, nothing changes
assert.equal(move(m, vigil.uid, board(10, 4), 'LEFT').error, ERR.HAND_FULL);
assert.equal(ps.board.get('10,4').dir, 'RIGHT');
assert.equal(ps.board.get('11,5'), wolf);
// facing UP keeps it inside: allowed
assert.deepEqual(move(m, vigil.uid, board(10, 4), 'UP'), { ok: true });
assert.equal(ps.board.get('11,5'), wolf);
checkInvariants(m);
// moving her (her summons go back to the hand; with no room a normal summon stays put) to (9,9) facing RIGHT, whose
// range (rows 8–10, cols 9–12) leaves the wolf out: it leaves the board — no illegal placement reaches the battle
assert.deepEqual(move(m, vigil.uid, board(9, 9), 'RIGHT'), { ok: true });
assert.equal(ps.board.get('11,5'), undefined, 'outside her range with nowhere to go: removed');
assert.ok(toasts.some((t) => t.includes('狼群') && t.includes('下回合返还')), toasts.join(' / '));
assert.equal(ps.battleInput().units.filter((u) => u.kind === 'token').length, 0);
checkInvariants(m);
// …and its stack comes back at the next round start (grantTokensFor, "干员所属召唤物会于下一回合返还")
h.toPrep(2);
assert.ok(stackOf(ps, WOLF), 'the wolf is back in the hand / temp');
checkInvariants(m);
m.dispose();
});
test('#9 swaps: a summon dragged onto its tactician, or an operator onto the summon, must leave the summon inside the range', () => {
const { m, ps } = prep();
const vigil = give(m, ps, VIGIL);
+1 -1
View File
@@ -220,7 +220,7 @@ describe('#7 audit: every stage × deploy field (server = official level data =
test('independent check against the raw official level files (buildableType / heightType + non-hidden devices)', { skip: !HAS_LEVELS && 'no .cache/gamedata level files (run node tools/build-data.mjs once)' }, () => {
// trap_040_canoe 特制水上平台 "在水上建立可以部署任意单位的平台"; tile_deepsea refuses deployment whatever its
// buildableType (PRTS 地形 深水区 "地形机制:拒绝部署" — player report #3 after 0.1.0, 战场#08's pool)
// buildableType (PRTS 深水区 地形信息 "地形机制:拒绝部署(待补充)" — player report #3 after 0.1.0, 战场#08's pool)
const ROLE = { trap_1105_accrate: 'block', trap_032_mound: 'block', trap_1106_achplat: 'platform', trap_040_canoe: 'water' };
for (const [id, st] of Object.entries(DATA.stages)) {
const lv = JSON.parse(readFileSync(levelFile(id), 'utf8'));
+5 -5
View File
@@ -1,10 +1,10 @@
// Player report #3 after the 0.1.0 release: "有水池的那张图干员可以错误的被部署到水里,突袭分队干员也会自动落到水里".
// The pool is the 深水区 (tile_deepsea) of 战场#08(下半) 涨潮控制 (act2autochess_m04: board (10–12, 6), the partner's
// (10–12, 14), boss (3–5, 6) / (3–5, 14)). The level file says buildableType ALL, but the tile refuses deployment
// officially: PRTS 地形 深水区 "地形机制:拒绝部署", and the season-1 战场#05 puts a 特制水上平台 ("在水上建立可以部署任意单位
// 的平台") on every one of its 深水区 tiles. The sim side: `grid.canStand` refuses it, so no automatic placement (the
// 突袭 landing tile, a tactician's tactical point, summon tiles) ever picks a water tile. The prep side (placement on
// the board) is test/match/feedback1-placement.test.js.
// officially: PRTS 深水区 地形信息 "地形机制:拒绝部署(待补充)", and the season-1 战场#05 puts a 特制水上平台
// ("在水上建立可以部署任意单位的平台") on every one of its 深水区 tiles. The sim side: `grid.canStand` refuses it, so
// no automatic placement (the 突袭 landing tile, a tactician's tactical point, summon tiles) ever picks a water tile.
// The prep side (placement on the board) is test/match/feedback1-placement.test.js.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeBattle, chessRec, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
@@ -22,7 +22,7 @@ test('the stage data: 战场#08\'s 深水区 refuses deployment (effective build
const raw = st.raw ?? st;
const d = raw.tiles.d;
assert.equal(d.tileKey, 'tile_deepsea');
assert.equal(d.buildable, 'NONE', 'PRTS 地形 深水区: 拒绝部署');
assert.equal(d.buildable, 'NONE', 'PRTS 深水区 地形信息: 拒绝部署');
assert.equal(d.buildableType, 'ALL', 'the level file\'s own value is kept');
const deploy = new Set([...raw.deployTiles.normal.melee, ...raw.deployTiles.normal.rangedOnly].map(([r, c]) => `${r},${c}`));
for (const [r, c] of WATER) assert.ok(!deploy.has(`${r},${c}`), `deployTiles.normal excludes ${r},${c}`);
+2 -1
View File
@@ -47,7 +47,8 @@ function official(sid, { crates: extraCrates = [], blocks: extraBlocks = [], rec
const inside = (r, c) => (rect ? r >= rect.r0 && r <= rect.r1 && c >= rect.c0 && c <= rect.c1 : r >= 0 && r < H && c >= 0 && c < W);
const passable = (r, c) => inside(r, c) && ['ALL', 'WALK_ONLY'].includes(tile(r, c).passableMask) && !blocked.has(`${r},${c}`);
// non-blockable walkable terrain: not LOWLAND buildable for melee (floor, gates, goal, teleports) — and the 深水区,
// which refuses deployment whatever its buildableType (PRTS 地形 深水区 "拒绝部署"; player report #3 after 0.1.0)
// which refuses deployment whatever its buildableType (PRTS 深水区 地形信息 "拒绝部署(待补充)"; player report #3
// after 0.1.0)
const nb = (r, c) => {
const t = tile(r, c);
if (t.tileKey === 'tile_deepsea') return true;
+3 -3
View File
@@ -2006,9 +2006,9 @@ const BLOCKING_ROLES = new Set(['crate', 'platform', 'mound']);
/**
* The deploy type the game applies to a level tile: its buildableType, except tiles whose mechanism refuses deployment
* (server/sim/grid.js DEPLOY_REFUSED_TILES — 深水区 tile_deepsea: PRTS 地形 深水区 "地形机制:拒绝部署"; player report after
* 0.1.0: operators could be placed in 战场#08's pool), which are NONE. The stages.json legend's `buildable` is this
* value; `buildableType` keeps the level's own where they differ.
* (server/sim/grid.js DEPLOY_REFUSED_TILES — 深水区 tile_deepsea: PRTS 深水区 地形信息 "地形机制:拒绝部署(待补充)";
* player report after 0.1.0: operators could be placed in 战场#08's pool), which are NONE. The stages.json legend's
* `buildable` is this value; `buildableType` keeps the level's own where they differ.
*/
const effectiveBuildable = (t) => (DEPLOY_REFUSED_TILES.has(t.tileKey) ? 'NONE' : t.buildableType);