From 60ed4fdab911b0e0e2b4412279f32d41ef9e800d Mon Sep 17 00:00:00 2001 From: sganggs Date: Sat, 3 Oct 2026 02:45:57 +0800 Subject: [PATCH] feedback1 WC: tactician summons never left out of range with a full hand; source fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/DATA.md | 6 +-- docs/META.md | 20 ++++++---- docs/PLAYING.md | 2 +- docs/SIM.md | 2 +- public/js/ui/gameLogic.js | 22 ++++------- server/match/PlayerState.js | 51 ++++++++++++++++++++------ server/match/board.js | 5 ++- server/sim/content/devices.js | 4 +- server/sim/grid.js | 19 ++++++---- test/match/feedback1-placement.test.js | 47 +++++++++++++++++++++--- test/match/playtest5-deploy.test.js | 2 +- test/sim/feedback1-water.test.js | 10 ++--- test/sim/pathing-crosscheck.test.js | 3 +- tools/build-data.mjs | 6 +-- 14 files changed, 136 insertions(+), 63 deletions(-) diff --git a/docs/DATA.md b/docs/DATA.md index b95599de..7ff296ce 100644 --- a/docs/DATA.md +++ b/docs/DATA.md @@ -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) diff --git a/docs/META.md b/docs/META.md index 6475f11a..8e860a19 100644 --- a/docs/META.md +++ b/docs/META.md @@ -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 diff --git a/docs/PLAYING.md b/docs/PLAYING.md index fc7b431a..7584f0c9 100644 --- a/docs/PLAYING.md +++ b/docs/PLAYING.md @@ -66,7 +66,7 @@ - **点选按地上的方格**:点哪个方格就选中站在那个方格上的单位,点空方格不选任何单位(点到后一格干员画出来的头部,算作点了后面那一格)。作战中友方同样按方格;敌人在格子之间走动,点它的身体或脚下附近(0.6 格内)即可,飞行敌人点它的身体。 - 拖动时模型一直在指针 / 手指下(脚在指针下方约半格,鼠标和触屏一样),**指针所在的格子**就是落点并高亮;装备拖到哪个格子,就配发给那个格子上的干员;在商店栏上松手则放回原位。 - 点击干员显示攻击范围和底部按钮(「撤退」回到整备区、「出售 +1」);右键 / 长按查看详细信息:头像旁是盟约,下面第一项就是干员的**特质**(如「休整期结束时 …」),之后是特性、属性、攻击范围、技能、模组、装备、天赋。属性是**实时数值**:作战中每秒刷新 4 次(当前生命、攻击、防御、法抗、攻击间隔、阻挡),休整期显示棋盘上的干员**开战时**的数值(已算入装备、盟约层数、特质、策略与机变效果);高于基础值为绿色、低于为红色并标出差值(攻击间隔变短算增益),鼠标悬停可看基础值。 -- **召唤物手动摆放**:把带可部署召唤物的干员放上棋盘后,它的召唤物会进入整备区(赫默的医疗探机、巫恋的诅咒娃娃、凯瑟琳的 2 个支援装置、浊心斯卡蒂的海嗣、伺夜的狼群、缪尔赛思的流形;同一干员的召唤物叠在一格,不占部署位),像干员一样拖到棋盘上并选方向。凯瑟琳的支援装置给**它面朝的那一格**上的干员加屏障,所以要放在干员旁边、朝向干员。**战术家的援军**(伺夜的狼群、缪尔赛思的流形,也就是选战术点)和官方一样**只能放在召唤者的攻击范围内**:拖动时只有这些格子会亮起;之后在原地给战术家换朝向时,不在新攻击范围里的援军会退回整备区(还在范围里的保持不动)。撤退或出售干员时它的召唤物一起移除;**移动干员到别的格子时(包括把召唤物拖到干员身上交换位置),它已摆放的召唤物会退回整备区**,需要重新摆放。整备区满时退回的召唤物进入临时整备区,休整期结束还没处理的会在下一回合重新回到整备区。 +- **召唤物手动摆放**:把带可部署召唤物的干员放上棋盘后,它的召唤物会进入整备区(赫默的医疗探机、巫恋的诅咒娃娃、凯瑟琳的 2 个支援装置、浊心斯卡蒂的海嗣、伺夜的狼群、缪尔赛思的流形;同一干员的召唤物叠在一格,不占部署位),像干员一样拖到棋盘上并选方向。凯瑟琳的支援装置给**它面朝的那一格**上的干员加屏障,所以要放在干员旁边、朝向干员。**战术家的援军**(伺夜的狼群、缪尔赛思的流形,也就是选战术点)和官方一样**只能放在召唤者的攻击范围内**:拖动时只有这些格子会亮起;之后在原地给战术家换朝向时,不在新攻击范围里的援军会退回整备区(还在范围里的保持不动;整备区和临时整备区都满、援军无处可退时,这次换朝向不生效并提示「整备区已满」)。撤退或出售干员时它的召唤物一起移除;**移动干员到别的格子时(包括把召唤物拖到干员身上交换位置),它已摆放的召唤物会退回整备区**,需要重新摆放。整备区满时退回的召唤物进入临时整备区,休整期结束还没处理的会在下一回合重新回到整备区。 - 作战中:海嗣、狼群、流形、支援装置开战时就在摆放的位置部署;医疗探机、诅咒娃娃开战时也在摆放的位置免费部署一次(持续 10 / 15 秒,和官方一样不需要先开技能),之后所属干员每次发动技能时再次在原位出现;所属干员被击倒、再部署回场时,技能给的那一个如果还没出现,会立刻出现。巫恋退场时诅咒娃娃随之撤退,赫默退场时已出现的医疗探机不受影响。医疗探机、诅咒娃娃、支援装置、海嗣没有摆放就不会出现;狼群、流形(战术家的援军)没有摆放时会在战术点出现。 - **阻挡**看接触:干员阻挡中心距离约 0.7 格以内、与它接触的敌人(地面干员挡不住飞行和近地悬浮的敌人)。敌人卡在两名干员之间时,阻挡它的干员倒下、撤退或被晕眩后,另一名仍与它接触、还有空余阻挡数的干员会**立刻接替阻挡**;都没有空位时敌人才会走掉。迎面走来的敌人会停在干员格子前面。**阻挡了就一定能打到**:任何干员(包括站在近战位的远程干员和召唤物)总能攻击自己阻挡的敌人,不管它在不在攻击范围内、在干员哪一侧,并且优先攻击它(医疗干员和「停止攻击」类技能期间除外;技能原文另有限制的也按原文:普罗旺斯二技能开启时不以生命值高于 80% 的敌人为目标,寒檀二技能的冰凌随机落在攻击范围内的地块上)。 - 开战时干员按**从上到下、从左到右**的顺序部署,召唤物在所有干员之后。被阻挡的敌人先打阻挡它的干员;没被阻挡的远程敌人先打嘲讽等级高的,同级时打最后部署的(召唤物、靠下靠右的干员和刚再部署的干员更容易挨打)。隐匿的干员只会被它阻挡的敌人攻击;**迷彩**(如忍冬三技能期间击倒敌人后、寒芒克洛丝一技能)同样只会被它阻挡的敌人攻击,也显示为半透明,但它不算隐匿(不触发叙拉古等「隐匿时」效果)。叙拉古的隐匿按时(32 秒 + 每层 0.4 秒)结束;忍冬的迷彩按官方持续到她下一次开技能。 diff --git a/docs/SIM.md b/docs/SIM.md index c55668c8..ef753195 100644 --- a/docs/SIM.md +++ b/docs/SIM.md @@ -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) | --- diff --git a/public/js/ui/gameLogic.js b/public/js/ui/gameLogic.js index 50088bf9..ab078c4a 100644 --- a/public/js/ui/gameLogic.js +++ b/public/js/ui/gameLogic.js @@ -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). */ diff --git a/server/match/PlayerState.js b/server/match/PlayerState.js index 00aba458..dfa96183 100644 --- a/server/match/PlayerState.js +++ b/server/match/PlayerState.js @@ -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; } diff --git a/server/match/board.js b/server/match/board.js index c4b26915..aaf3bb22 100644 --- a/server/match/board.js +++ b/server/match/board.js @@ -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). diff --git a/server/sim/content/devices.js b/server/sim/content/devices.js index 1f4b89b7..ad289f1e 100644 --- a/server/sim/content/devices.js +++ b/server/sim/content/devices.js @@ -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'; diff --git a/server/sim/grid.js b/server/sim/grid.js index 5157b583..bcbbcb7e 100644 --- a/server/sim/grid.js +++ b/server/sim/grid.js @@ -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'])); diff --git a/test/match/feedback1-placement.test.js b/test/match/feedback1-placement.test.js index eb2249df..fa95d1b4 100644 --- a/test/match/feedback1-placement.test.js +++ b/test/match/feedback1-placement.test.js @@ -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); diff --git a/test/match/playtest5-deploy.test.js b/test/match/playtest5-deploy.test.js index 460d2c84..c2dad8f9 100644 --- a/test/match/playtest5-deploy.test.js +++ b/test/match/playtest5-deploy.test.js @@ -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')); diff --git a/test/sim/feedback1-water.test.js b/test/sim/feedback1-water.test.js index 862daeb4..b3eb5bbf 100644 --- a/test/sim/feedback1-water.test.js +++ b/test/sim/feedback1-water.test.js @@ -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}`); diff --git a/test/sim/pathing-crosscheck.test.js b/test/sim/pathing-crosscheck.test.js index 665a3c63..1c3de3e7 100644 --- a/test/sim/pathing-crosscheck.test.js +++ b/test/sim/pathing-crosscheck.test.js @@ -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; diff --git a/tools/build-data.mjs b/tools/build-data.mjs index 6b59e89d..82e2b804 100644 --- a/tools/build-data.mjs +++ b/tools/build-data.mjs @@ -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);