feedback5 WE2: 重构体 only on the ground inside Mon3tr's range, 战术锚点 only on a 高台 outside 凯尔希·思衡托's (#26)
Mon3tr T1 "可以在攻击范围内的地面使用…重构体"; PRTS 战术锚点 特性 "仅可以部署在凯尔希·思衡托攻击 范围外的远程位". tools/build-data.mjs summonRecord: ownerRange also from an owner talent naming the token, ownerRangeOutside / rangedTilesOnly from the token text. Regenerated offline: 3 paths (重构体 ownerRange true; 战术锚点 ownerRangeOutside, rangedTilesOnly). Match: board.js placement class 'high'; PlayerState.summonExcluded in _legal, the re-orientation lift (+ toast), _roomForOutOfRange, the invariants and the bot; the client mirror (piecePosition HIGH, summonExcluded, reasons; English strings added). Golden: no scenario moved.
This commit is contained in:
1 parent
708719f382
commit
53ee0a1c6f
15 files changed
+236
-45
No files matched your search
+1
-1
@@ -1 +1 @@
|
|||||||
{"units":{"char_600_cpione":{"charId":"char_600_cpione","name":"预备干员-先锋","appellation":"Reserve Operator - Vanguard","rarity":4,"profession":"PIONEER","subProfessionId":"pioneer","subProfessionName":"尖兵","position":"MELEE","nationId":null,"isNotObtainable":true,"assets":{"avatar":"char_600_cpione","avatarGolden":"char_600_cpione","portrait":"char_600_cpione_1","portraitGolden":"char_600_cpione_1","spine":"char_600_cpione","subProfIcon":"sub_pioneer_icon"},"moduleNames":{},"standsIn":["chess_char_5_01_a"],"forms":{"2/1/4/0":{"status":{"phase":2,"level":1,"skillLevel":4,"equipLevel":0},"stats":{"maxHp":1225,"atk":320,"def":272,"res":0,"cost":11,"blockCnt":2,"bat":1.05,"aspd":100,"respawnTime":70,"spRecovery":1,"hpRecoveryPerSec":0,"moveSpeed":1,"tauntLevel":0,"massLevel":0,"deployLimit":1,"deckStack":0},"immunities":{"stun":false,"silence":false,"sleep":false,"frozen":false,"levitate":false},"rangeId":"1-1","rangeGrid":[[0,0],[0,1]],"dmgType":"phys","attackKind":"melee","projectile":"none","canHitFly":false,"targetPriority":null,"trait":{"desc":"能够阻挡两个敌人","descRaw":"能够阻挡两个敌人","bb":{},"bbStr":{},"rangeGrid":null},"skills":[{"skillId":"skcom_charge_cost[1]","iconId":"skcom_charge_cost[1]","name":"冲锋号令·α型","level":4,"desc":"立即获得6点部署费用","descRaw":"立即获得<@ba.vup>6</>点部署费用","skillType":"AUTO","durationType":"NONE","duration":0,"spType":"INCREASE_WITH_TIME","spCost":27,"initSp":3,"maxChargeTime":1,"increment":1,"bb":{"cost":6},"bbStr":{},"rangeId":null,"rangeGrid":null,"prefabId":"skcom_charge_cost","trigger":{"rule":"DEFAULT","rawRule":"DEFAULT","customRangeGrid":null},"index":0,"overrideTokenKey":null},{"skillId":"skcom_charge_cost[2]","iconId":"skcom_charge_cost[2]","name":"冲锋号令·β型","level":4,"desc":"立即获得9点部署费用","descRaw":"立即获得<@ba.vup>9</>点部署费用","skillType":"AUTO","durationType":"NONE","duration":0,"spType":"INCREASE_WITH_TIME","spCost":36,"initSp":9,"maxChargeTime":1,"increment":1,"bb":{"cost":9},"bbStr":{},"rangeId":null,"rangeGrid":null,"prefabId":"skcom_charge_cost","trigger":{"rule":"DEFAULT","rawRule":"DEFAULT","customRangeGrid":null},"index":1,"overrideTokenKey":null},{"skillId":"skcom_charge_cost[3]","iconId":"skcom_charge_cost[3]","name":"冲锋号令·γ型","level":4,"desc":"立即获得12点部署费用","descRaw":"立即获得<@ba.vup>12</>点部署费用","skillType":"AUTO","durationType":"NONE","duration":0,"spType":"INCREASE_WITH_TIME","spCost":41,"initSp":12,"maxChargeTime":1,"increment":1,"bb":{"cost":12},"bbStr":{},"rangeId":null,"rangeGrid":null,"prefabId":"skcom_charge_cost","trigger":{"rule":"DEFAULT","rawRule":"DEFAULT","customRangeGrid":null},"index":2,"overrideTokenKey":null}],"talents":[{"index":0,"name":"攻击提升","desc":"攻击力+8%","descRaw":"攻击力+8%","bb":{"atk":0.08},"bbStr":{},"rangeGrid":null,"tokenKey":null,"hidden":false,"fromModule":false}],"displayTokens":[],"tokens":[]},"2/60/7/1":{"status":{"phase":2,"level":60,"skillLevel":7,"equipLevel":1},"stats":{"maxHp":1674,"atk":380,"def":322,"res":0,"cost":11,"blockCnt":2,"bat":1.05,"aspd":100,"respawnTime":70,"spRecovery":1,"hpRecoveryPerSec":0,"moveSpeed":1,"tauntLevel":0,"massLevel":0,"deployLimit":1,"deckStack":0},"immunities":{"stun":false,"silence":false,"sleep":false,"frozen":false,"levitate":false},"rangeId":"1-1","rangeGrid":[[0,0],[0,1]],"dmgType":"phys","attackKind":"melee","projectile":"none","canHitFly":false,"targetPriority":null,"trait":{"desc":"能够阻挡两个敌人","descRaw":"能够阻挡两个敌人","bb":{},"bbStr":{},"rangeGrid":null},"skills":[{"skillId":"skcom_charge_cost[1]","iconId":"skcom_charge_cost[1]","name":"冲锋号令·α型","level":7,"desc":"立即获得6点部署费用","descRaw":"立即获得<@ba.vup>6</>点部署费用","skillType":"AUTO","durationType":"NONE","duration":0,"spType":"INCREASE_WITH_TIME","spCost":25,"initSp":6,"maxChargeTime":1,"increment":1,"bb":{"cost":6},"bbStr":{},"rangeId":null,"rangeGrid":null,"prefabId":"skcom_charge_cost","trigger":{"rule":"DEFAULT","rawRule":"DEFAULT","customRangeGrid":null},"index":0,"overrideTokenKey":null},{"skillId":"skcom_charge_cost[2]","iconId":"skcom_charge_cost[2]","name":"冲锋号令·β型","level":7,"desc":"立即获得9点部署费用","descRaw":"立即获得<@ba.vup>9</>点部署费用","skillType":"AUTO","durationType":"NONE","duration":0,"spType":"INCREASE_WITH_TIME","spCost":33,"initSp":10,"maxChargeTime":1,"increment":1,"bb":{"cost":9},"bbStr":{},"rangeId":null,"rangeGrid":null,"prefabId":"skcom_charge_cost","trigger":{"rule":"DEFAULT","rawRule":"DEFAULT","customRangeGrid":null},"index":1,"overrideTokenKey":null},{"skillId":"skcom_charge_cost[3]","iconId":"skcom_charge_cost[3]","name":"冲锋号令·γ型","level":7,"desc":"立即获得12点部署费用","descRaw":"立即获得<@ba.vup>12</>点部署费用","skillType":"AUTO","durationType":"NONE","duration":0,"spType":"INCREASE_WILine truncated
|
{"units":{"char_600_cpione":{"charId":"char_600_cpione","name":"预备干员-先锋","appellation":"Reserve Operator - Vanguard","rarity":4,"profession":"PIONEER","subProfessionId":"pioneer","subProfessionName":"尖兵","position":"MELEE","nationId":null,"isNotObtainable":true,"assets":{"avatar":"char_600_cpione","avatarGolden":"char_600_cpione","portrait":"char_600_cpione_1","portraitGolden":"char_600_cpione_1","spine":"char_600_cpione","subProfIcon":"sub_pioneer_icon"},"moduleNames":{},"standsIn":["chess_char_5_01_a"],"forms":{"2/1/4/0":{"status":{"phase":2,"level":1,"skillLevel":4,"equipLevel":0},"stats":{"maxHp":1225,"atk":320,"def":272,"res":0,"cost":11,"blockCnt":2,"bat":1.05,"aspd":100,"respawnTime":70,"spRecovery":1,"hpRecoveryPerSec":0,"moveSpeed":1,"tauntLevel":0,"massLevel":0,"deployLimit":1,"deckStack":0},"immunities":{"stun":false,"silence":false,"sleep":false,"frozen":false,"levitate":false},"rangeId":"1-1","rangeGrid":[[0,0],[0,1]],"dmgType":"phys","attackKind":"melee","projectile":"none","canHitFly":false,"targetPriority":null,"trait":{"desc":"能够阻挡两个敌人","descRaw":"能够阻挡两个敌人","bb":{},"bbStr":{},"rangeGrid":null},"skills":[{"skillId":"skcom_charge_cost[1]","iconId":"skcom_charge_cost[1]","name":"冲锋号令·α型","level":4,"desc":"立即获得6点部署费用","descRaw":"立即获得<@ba.vup>6</>点部署费用","skillType":"AUTO","durationType":"NONE","duration":0,"spType":"INCREASE_WITH_TIME","spCost":27,"initSp":3,"maxChargeTime":1,"increment":1,"bb":{"cost":6},"bbStr":{},"rangeId":null,"rangeGrid":null,"prefabId":"skcom_charge_cost","trigger":{"rule":"DEFAULT","rawRule":"DEFAULT","customRangeGrid":null},"index":0,"overrideTokenKey":null},{"skillId":"skcom_charge_cost[2]","iconId":"skcom_charge_cost[2]","name":"冲锋号令·β型","level":4,"desc":"立即获得9点部署费用","descRaw":"立即获得<@ba.vup>9</>点部署费用","skillType":"AUTO","durationType":"NONE","duration":0,"spType":"INCREASE_WITH_TIME","spCost":36,"initSp":9,"maxChargeTime":1,"increment":1,"bb":{"cost":9},"bbStr":{},"rangeId":null,"rangeGrid":null,"prefabId":"skcom_charge_cost","trigger":{"rule":"DEFAULT","rawRule":"DEFAULT","customRangeGrid":null},"index":1,"overrideTokenKey":null},{"skillId":"skcom_charge_cost[3]","iconId":"skcom_charge_cost[3]","name":"冲锋号令·γ型","level":4,"desc":"立即获得12点部署费用","descRaw":"立即获得<@ba.vup>12</>点部署费用","skillType":"AUTO","durationType":"NONE","duration":0,"spType":"INCREASE_WITH_TIME","spCost":41,"initSp":12,"maxChargeTime":1,"increment":1,"bb":{"cost":12},"bbStr":{},"rangeId":null,"rangeGrid":null,"prefabId":"skcom_charge_cost","trigger":{"rule":"DEFAULT","rawRule":"DEFAULT","customRangeGrid":null},"index":2,"overrideTokenKey":null}],"talents":[{"index":0,"name":"攻击提升","desc":"攻击力+8%","descRaw":"攻击力+8%","bb":{"atk":0.08},"bbStr":{},"rangeGrid":null,"tokenKey":null,"hidden":false,"fromModule":false}],"displayTokens":[],"tokens":[]},"2/60/7/1":{"status":{"phase":2,"level":60,"skillLevel":7,"equipLevel":1},"stats":{"maxHp":1674,"atk":380,"def":322,"res":0,"cost":11,"blockCnt":2,"bat":1.05,"aspd":100,"respawnTime":70,"spRecovery":1,"hpRecoveryPerSec":0,"moveSpeed":1,"tauntLevel":0,"massLevel":0,"deployLimit":1,"deckStack":0},"immunities":{"stun":false,"silence":false,"sleep":false,"frozen":false,"levitate":false},"rangeId":"1-1","rangeGrid":[[0,0],[0,1]],"dmgType":"phys","attackKind":"melee","projectile":"none","canHitFly":false,"targetPriority":null,"trait":{"desc":"能够阻挡两个敌人","descRaw":"能够阻挡两个敌人","bb":{},"bbStr":{},"rangeGrid":null},"skills":[{"skillId":"skcom_charge_cost[1]","iconId":"skcom_charge_cost[1]","name":"冲锋号令·α型","level":7,"desc":"立即获得6点部署费用","descRaw":"立即获得<@ba.vup>6</>点部署费用","skillType":"AUTO","durationType":"NONE","duration":0,"spType":"INCREASE_WITH_TIME","spCost":25,"initSp":6,"maxChargeTime":1,"increment":1,"bb":{"cost":6},"bbStr":{},"rangeId":null,"rangeGrid":null,"prefabId":"skcom_charge_cost","trigger":{"rule":"DEFAULT","rawRule":"DEFAULT","customRangeGrid":null},"index":0,"overrideTokenKey":null},{"skillId":"skcom_charge_cost[2]","iconId":"skcom_charge_cost[2]","name":"冲锋号令·β型","level":7,"desc":"立即获得9点部署费用","descRaw":"立即获得<@ba.vup>9</>点部署费用","skillType":"AUTO","durationType":"NONE","duration":0,"spType":"INCREASE_WITH_TIME","spCost":33,"initSp":10,"maxChargeTime":1,"increment":1,"bb":{"cost":9},"bbStr":{},"rangeId":null,"rangeGrid":null,"prefabId":"skcom_charge_cost","trigger":{"rule":"DEFAULT","rawRule":"DEFAULT","customRangeGrid":null},"index":1,"overrideTokenKey":null},{"skillId":"skcom_charge_cost[3]","iconId":"skcom_charge_cost[3]","name":"冲锋号令·γ型","level":7,"desc":"立即获得12点部署费用","descRaw":"立即获得<@ba.vup>12</>点部署费用","skillType":"AUTO","durationType":"NONE","duration":0,"spType":"INCREASE_WILine truncated
|
||||||
+2
-1
@@ -482,7 +482,8 @@ Glyph legend (`rows`):
|
|||||||
| `tokenId`, `kind`, `name`, `appellation`, `desc`, `descRaw` | …, `"summon"`, `"狼群"` | |
|
| `tokenId`, `kind`, `name`, `appellation`, `desc`, `descRaw` | …, `"summon"`, `"狼群"` | |
|
||||||
| `profession`, `subProfessionId`, `position` | `"TOKEN"`, `"notchar1"`, `"MELEE"` | `position` = the token row's, except where PRTS records the client's row as wrong (`tools/build-data.mjs TOKEN_POSITION_CORRECTIONS`): 望's 棋子 `ALL` (PRTS 棋子 部署位置 "全部位", 备注 "游戏内召唤物信息与实际不符(显示为仅部署在近战位)") — the prep's placement class (board.js `positionClass`) |
|
| `profession`, `subProfessionId`, `position` | `"TOKEN"`, `"notchar1"`, `"MELEE"` | `position` = the token row's, except where PRTS records the client's row as wrong (`tools/build-data.mjs TOKEN_POSITION_CORRECTIONS`): 望's 棋子 `ALL` (PRTS 棋子 部署位置 "全部位", 备注 "游戏内召唤物信息与实际不符(显示为仅部署在近战位)") — the prep's placement class (board.js `positionClass`) |
|
||||||
| `displayType`, `placeable` | `"DEFAULT"`, `true` | `shopStateTokenDict` DEFAULT / HIDDEN (battle-only) / `null` (not listed). `placeable` (a prep hand piece) = a manually deployable summon (PRTS 卫戍协议/帮助 "可手动部署的附属召唤物…加入手牌区"; user playtest #6): not HIDDEN and made by an owner's talent or skill — 医疗探机 (赫默 S2), 诅咒娃娃 (巫恋 S2), 海嗣, 狼群, 流形 and 爬行号·防护单元 (凯瑟琳's talent device — the only pool summon missing from `shopStateTokenDict`, read as shown: placed by hand in the base game, by the friend's report, and confirmed by the user after playtest #6, DESIGN §20); 投递坐标 (HIDDEN) is not; nor is a summon no owner shows (its `display` source, the owner's displayTokenDict — a skill's own object: every such summon is HIDDEN but 予愿安洁莉娜 S3's “一会儿见!”, which the shop state does not list; PRTS 予愿安洁莉娜 S3 备注, 0.2.0). In battle a skill's summon deploys once at the battle start, then on its tile each time the skill gives one (SIM.md, token pieces) |
|
| `displayType`, `placeable` | `"DEFAULT"`, `true` | `shopStateTokenDict` DEFAULT / HIDDEN (battle-only) / `null` (not listed). `placeable` (a prep hand piece) = a manually deployable summon (PRTS 卫戍协议/帮助 "可手动部署的附属召唤物…加入手牌区"; user playtest #6): not HIDDEN and made by an owner's talent or skill — 医疗探机 (赫默 S2), 诅咒娃娃 (巫恋 S2), 海嗣, 狼群, 流形 and 爬行号·防护单元 (凯瑟琳's talent device — the only pool summon missing from `shopStateTokenDict`, read as shown: placed by hand in the base game, by the friend's report, and confirmed by the user after playtest #6, DESIGN §20); 投递坐标 (HIDDEN) is not; nor is a summon no owner shows (its `display` source, the owner's displayTokenDict — a skill's own object: every such summon is HIDDEN but 予愿安洁莉娜 S3's “一会儿见!”, which the shop state does not list; PRTS 予愿安洁莉娜 S3 备注, 0.2.0). In battle a skill's summon deploys once at the battle start, then on its tile each time the skill gives one (SIM.md, token pieces) |
|
||||||
| `ownerRange` | `true` | the token text reads "只能部署在召唤者攻击范围内" (`desc`; the tacticians' 援军 — 狼群, 流形; PRTS 狼群 特性): its hand piece may only stand on a tile of its owner's attack range (server/match/board.js `ownerRangeKeys`, `PlayerState._legal`, the client's `gameLogic.summonRange`; player report #9 after 0.1.0). `false` for every other token |
|
| `ownerRange` | `true` | the token text reads "只能部署在召唤者攻击范围内" (`desc`; the tacticians' 援军 — 狼群, 流形; PRTS 狼群 特性) — or, data/backups.json, an owner's talent naming the token reads "可以在攻击范围内(的地面)部署 / 使用…" (Mon3tr's 重构体; 莱伊's 沙地兽 says both): its hand piece may only stand on a tile of its owner's attack range (server/match/board.js `ownerRangeKeys`, `PlayerState._legal`, the client's `gameLogic.summonRange`; player report #9 after 0.1.0). `false` for every other token |
|
||||||
|
| `ownerRangeOutside`, `rangedTilesOnly` | `true` (present only then) | data/backups.json: the token text reads "部署在…攻击范围外" / "仅可以部署在…远程位" — 凯尔希·思衡托's 战术锚点 "仅可以部署在凯尔希·思衡托攻击范围外的远程位" (PRTS 战术锚点 特性): its hand piece may only stand outside its owner's attack range (`PlayerState.summonExcluded`, the client's `gameLogic.summonExcluded`) and on a ranged (高台) tile (board.js placement class `high`; the mode's "所有行动内远程干员可部署在近战位" is an operators' rule [ASSUMED]) (0.2.0) |
|
||||||
| `owners[]` | `["chess_char_3_19_a","chess_char_3_19_b"]` | |
|
| `owners[]` | `["chess_char_3_19_a","chess_char_3_19_b"]` | |
|
||||||
| `stats`, `rangeGrid`, `dmgType`, `attackKind`, `projectile`, `canHitFly` | first owner's values | defaults |
|
| `stats`, `rangeGrid`, `dmgType`, `attackKind`, `projectile`, `canHitFly` | first owner's values | defaults |
|
||||||
| `skill` | `{"skillId":"sktok_vigil_wolf_3","bb":{…}}` | default token skill (same slot as the owner's skill) |
|
| `skill` | `{"skillId":"sktok_vigil_wolf_3","bb":{…}}` | default token skill (same slot as the owner's skill) |
|
||||||
|
|||||||
+1
-1
@@ -155,7 +155,7 @@ Ownership rule for parallel agents: **only edit files assigned to you**; if you
|
|||||||
- **Partner display** cols 11–18 of rows 9–12 (the unite field uses them).
|
- **Partner display** cols 11–18 of rows 9–12 (the unite field uses them).
|
||||||
- **Boss field**: rows 1–5; left half cols 2–10, right half cols 10–18.
|
- **Boss field**: rows 1–5; left half cols 2–10, right half cols 10–18.
|
||||||
- Enemy preview pen rows 14–18 × cols 7–13 (row 16 empty): during prep the next round's enemies idle there (upper-gate enemies rows 17–18, lower-gate rows 14–15, ≤ 50 models, spawn-time order) — research 08 §4 / 09 §2.
|
- Enemy preview pen rows 14–18 × cols 7–13 (row 16 empty): during prep the next round's enemies idle there (upper-gate enemies rows 17–18, lower-gate rows 14–15, ≤ 50 models, spawn-time order) — research 08 §4 / 09 §2.
|
||||||
- Tile semantics come from `data/stages.json` legend: height (LOW/HIGH), buildable (ALL/MELEE/RANGED/NONE — the **effective** deploy type: the 深水区 `tile_deepsea` refuses deployment, NONE with the level's `buildableType` ALL kept, PRTS 深水区 地形信息 "拒绝部署(待补充)"; an active 特制水上平台 makes its tile deployable in the prep deploy map only, §21.3), ground-passable, fly-only, special terrain (mire, smog, deepsea, infection). The fenced tiles (围墙 `tile_fence_bound` / 围栏 `tile_fence`, legend `b`: LOW, buildable ALL, passable to flyers only) take units, which attack normally from there but block no ground enemy (PRTS 地形机制, §21.23). Melee chess may stand only on LOW tiles with buildable ALL/MELEE — except a melee chess whose trait reads 「可以放置于远程位」 (the 钩索师 / 推击手 branch trait: 歌蕾蒂娅, 崖心, 见行者, normal and elite, with any module or none), which may use the ranged tiles too (`shared/highGround.js`; the owner's decision of 2026-10-05, following PRTS — it reverses the 2026-10-04 one that allowed only elite 歌蕾蒂娅 with HOK-Y; on a 高台 it attacks and blocks nothing); ranged chess on ALL/RANGED **and** on LOW ALL/MELEE tiles ("所有行动内远程干员可部署在近战位"). Tokens follow their own `position`; a summon marked `ownerRange` (狼群, 流形: "只能部署在召唤者攻击范围内") goes only on a tile of its owner's attack range — the loadout grid rotated by the owner's facing around its tile (`PlayerState.summonRange`, §21.3).
|
- Tile semantics come from `data/stages.json` legend: height (LOW/HIGH), buildable (ALL/MELEE/RANGED/NONE — the **effective** deploy type: the 深水区 `tile_deepsea` refuses deployment, NONE with the level's `buildableType` ALL kept, PRTS 深水区 地形信息 "拒绝部署(待补充)"; an active 特制水上平台 makes its tile deployable in the prep deploy map only, §21.3), ground-passable, fly-only, special terrain (mire, smog, deepsea, infection). The fenced tiles (围墙 `tile_fence_bound` / 围栏 `tile_fence`, legend `b`: LOW, buildable ALL, passable to flyers only) take units, which attack normally from there but block no ground enemy (PRTS 地形机制, §21.23). Melee chess may stand only on LOW tiles with buildable ALL/MELEE — except a melee chess whose trait reads 「可以放置于远程位」 (the 钩索师 / 推击手 branch trait: 歌蕾蒂娅, 崖心, 见行者, normal and elite, with any module or none), which may use the ranged tiles too (`shared/highGround.js`; the owner's decision of 2026-10-05, following PRTS — it reverses the 2026-10-04 one that allowed only elite 歌蕾蒂娅 with HOK-Y; on a 高台 it attacks and blocks nothing); ranged chess on ALL/RANGED **and** on LOW ALL/MELEE tiles ("所有行动内远程干员可部署在近战位"). Tokens follow their own `position`; a summon marked `ownerRange` (狼群, 流形: "只能部署在召唤者攻击范围内"; Mon3tr's 重构体 by her talent) goes only on a tile of its owner's attack range — the loadout grid rotated by the owner's facing around its tile (`PlayerState.summonRange`, §21.3) —, one marked `ownerRangeOutside` / `rangedTilesOnly` (凯尔希·思衡托's 战术锚点: "仅可以部署在凯尔希·思衡托攻击范围外的远程位") only on a 高台 outside it (`summonExcluded`, placement class `high`; 0.2.0).
|
||||||
- **Facing (corrected, see research 09 §1.2):** like standard Arknights, every board piece has a direction `dir ∈ UP|RIGHT|DOWN|LEFT` chosen with the 4-direction deploy wheel after dropping it on a tile (also re-orientable in place); default `RIGHT`; persists across rounds. Range grid entries are `[dRow, dCol]` relative to facing RIGHT and are rotated: RIGHT `(dr,dc)`, UP `(dc,−dr)`, LEFT `(−dr,−dc)`, DOWN `(−dc,dr)` (row 0 = bottom); `rangeExtend` applies along +dCol before rotating — to a running skill's range too, unless that skill ignores 攻击距离 (`targeting.noRangeExtend`, 信仰搅拌机 S3, §21.16). The right-side Final Assault player is mirrored col `c→20−c` with RIGHT↔LEFT (UP/DOWN unchanged).
|
- **Facing (corrected, see research 09 §1.2):** like standard Arknights, every board piece has a direction `dir ∈ UP|RIGHT|DOWN|LEFT` chosen with the 4-direction deploy wheel after dropping it on a tile (also re-orientable in place); default `RIGHT`; persists across rounds. Range grid entries are `[dRow, dCol]` relative to facing RIGHT and are rotated: RIGHT `(dr,dc)`, UP `(dc,−dr)`, LEFT `(−dr,−dc)`, DOWN `(−dc,dr)` (row 0 = bottom); `rangeExtend` applies along +dCol before rotating — to a running skill's range too, unless that skill ignores 攻击距离 (`targeting.noRangeExtend`, 信仰搅拌机 S3, §21.16). The right-side Final Assault player is mirrored col `c→20−c` with RIGHT↔LEFT (UP/DOWN unchanged).
|
||||||
- **Range test:** an enemy is inside a grid range if the tile containing it (`round(y), round(x)`) is one of the range tiles; a huge enemy (data `hitArea`: 巨型单位, a 4.95 × 2.95 rectangle moved 1.0 up) if any tile its hit rectangle overlaps is one (PRTS 作战机制 "巨型BOSS单位的每一个占据的格子都可以让其本身通过格子判定"; `sim/body.js`, §19.4). Radius ranges (enemies, auras, AoE) use Euclidean distance in tiles — to a huge enemy's rectangle [ASSUMED] — except splash around a struck target, a 中点判定 on positions (PRTS 作战机制 "中点判定…案例:阻挡,酒神1天赋的1.3溅射半径"). A ranged enemy's normal attack takes an ally whose collider (radius 0.25, PRTS 作战机制 §碰撞体积 "我方干员碰撞体积基本均为以0.25格为半径的圆形") touches its range circle: centre distance ≤ `rangeRadius` + 0.25 (`constants.js ALLY_COLLIDER_RADIUS`, `ai.js enemyAttack`; §20.4).
|
- **Range test:** an enemy is inside a grid range if the tile containing it (`round(y), round(x)`) is one of the range tiles; a huge enemy (data `hitArea`: 巨型单位, a 4.95 × 2.95 rectangle moved 1.0 up) if any tile its hit rectangle overlaps is one (PRTS 作战机制 "巨型BOSS单位的每一个占据的格子都可以让其本身通过格子判定"; `sim/body.js`, §19.4). Radius ranges (enemies, auras, AoE) use Euclidean distance in tiles — to a huge enemy's rectangle [ASSUMED] — except splash around a struck target, a 中点判定 on positions (PRTS 作战机制 "中点判定…案例:阻挡,酒神1天赋的1.3溅射半径"). A ranged enemy's normal attack takes an ally whose collider (radius 0.25, PRTS 作战机制 §碰撞体积 "我方干员碰撞体积基本均为以0.25格为半径的圆形") touches its range circle: centre distance ≤ `rangeRadius` + 0.25 (`constants.js ALLY_COLLIDER_RADIUS`, `ai.js enemyAttack`; §20.4).
|
||||||
- **Fields at runtime:** a `Battle` receives a **field rect** (rows/cols subset) and the full stage grid. Normal battles use rows 9–12 × cols 0–10; unite uses rows 9–12 × cols 0–20 — on its own map, the escaped template's (stages.json `kind: 'unite'`, `unite.js uniteStageId`; GitHub #41), not the round's stage; boss uses rows 0–5 × cols 0–20 (solo boss uses the left half only). Pathfinding never leaves the rect.
|
- **Fields at runtime:** a `Battle` receives a **field rect** (rows/cols subset) and the full stage grid. Normal battles use rows 9–12 × cols 0–10; unite uses rows 9–12 × cols 0–20 — on its own map, the escaped template's (stages.json `kind: 'unite'`, `unite.js uniteStageId`; GitHub #41), not the round's stage; boss uses rows 0–5 × cols 0–20 (solo boss uses the left half only). Pathfinding never leaves the rect.
|
||||||
|
|||||||
+3
-1
@@ -576,7 +576,9 @@ Any `choice:` handler whose EffectRef reuses its own key must guard like this (o
|
|||||||
the tacticians' 援军, 伺夜 狼群 / 缪尔赛思 流形 — choosing the tactical point; player report #9 after 0.1.0) only goes on
|
the tacticians' 援军, 伺夜 狼群 / 缪尔赛思 流形 — choosing the tactical point; player report #9 after 0.1.0) only goes on
|
||||||
a tile of its owner's attack range: the owner's loadout grid (`attackRangeGrid`) rotated by its facing around its tile
|
a tile of its owner's attack range: the owner's loadout grid (`attackRangeGrid`) rotated by its facing around its tile
|
||||||
(`PlayerState._legal` / `summonRange`, `board.js ownerRangeKeys`; a summon dragged onto its own owner is checked from
|
(`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
|
the owner's new tile, an operator dragged onto the summon from the summon's new tile); Mon3tr's 重构体 likewise (her
|
||||||
|
talent), while 凯尔希·思衡托's 战术锚点 (`ownerRangeOutside` / `rangedTilesOnly`) goes only on a 高台 outside that range
|
||||||
|
(`summonExcluded`, board.js class `high`). 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
|
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]. A re-orientation that would leave such a summon with no stack and no
|
`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
|
free hand / temp slot is refused (HAND_FULL, like withdrawing a summon into a full hand); in the other cases (a
|
||||||
|
|||||||
@@ -577,6 +577,8 @@
|
|||||||
"请将装备拖拽至干员身上": "Drag Equipment onto an Operator",
|
"请将装备拖拽至干员身上": "Drag Equipment onto an Operator",
|
||||||
"近战单位只能部署在地面": "Melee units can only be deployed on ground tiles",
|
"近战单位只能部署在地面": "Melee units can only be deployed on ground tiles",
|
||||||
"只能部署在召唤者攻击范围内": "Can only be deployed within the summoner's Attack Range",
|
"只能部署在召唤者攻击范围内": "Can only be deployed within the summoner's Attack Range",
|
||||||
|
"只能部署在召唤者攻击范围外": "Can only be deployed outside the summoner's Attack Range",
|
||||||
|
"只能部署在远程位": "Can only be deployed on a Ranged tile",
|
||||||
"该位置已有单位": "This tile is already occupied",
|
"该位置已有单位": "This tile is already occupied",
|
||||||
"召唤者尚未部署": "The summoner is not deployed yet",
|
"召唤者尚未部署": "The summoner is not deployed yet",
|
||||||
"装备只能配发给干员": "Equipment can only be equipped on Operators",
|
"装备只能配发给干员": "Equipment can only be equipped on Operators",
|
||||||
@@ -842,6 +844,8 @@
|
|||||||
"地形变化:{names}无法停留在原位置,已撤回整备区": "Terrain change: {names} can no longer stay in place and returned to the Bench",
|
"地形变化:{names}无法停留在原位置,已撤回整备区": "Terrain change: {names} can no longer stay in place and returned to the Bench",
|
||||||
"{names}只能部署在召唤者攻击范围内,已退回整备区": "{names} can only be deployed within the summoner's attack range and returned to the Bench",
|
"{names}只能部署在召唤者攻击范围内,已退回整备区": "{names} can only be deployed within the summoner's attack range and returned to the Bench",
|
||||||
"{names}只能部署在召唤者攻击范围内,整备区已满,下回合返还": "{names} can only be deployed within the summoner's attack range; the Bench is full, so they come back next round",
|
"{names}只能部署在召唤者攻击范围内,整备区已满,下回合返还": "{names} can only be deployed within the summoner's attack range; the Bench is full, so they come back next round",
|
||||||
|
"{names}只能部署在召唤者攻击范围外,已退回整备区": "{names} can only be deployed outside the summoner's attack range and returned to the Bench",
|
||||||
|
"{names}只能部署在召唤者攻击范围外,整备区已满,下回合返还": "{names} can only be deployed outside the summoner's attack range; the Bench is full, so they come back next round",
|
||||||
"【{label}】层数达成,下回合开始时获得{gain}资金": "[{label}] stack milestone reached: +{gain} Funds at the start of the next round",
|
"【{label}】层数达成,下回合开始时获得{gain}资金": "[{label}] stack milestone reached: +{gain} Funds at the start of the next round",
|
||||||
"【{label}】层数达成,获得{gain}资金": "[{label}] stack milestone reached: +{gain} Funds",
|
"【{label}】层数达成,获得{gain}资金": "[{label}] stack milestone reached: +{gain} Funds",
|
||||||
"【奇迹】下次刷新不消耗资金": "[Marvel] Your next refresh is free",
|
"【奇迹】下次刷新不消耗资金": "[Marvel] Your next refresh is free",
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ export { sortedPlayers } from './gameLogic/shared.js';
|
|||||||
export { ownFieldId, homeFieldId, cycleField, watchTarget, switcherLabel, fieldLabel, activeBubbles } from './gameLogic/watch.js';
|
export { ownFieldId, homeFieldId, cycleField, watchTarget, switcherLabel, fieldLabel, activeBubbles } from './gameLogic/watch.js';
|
||||||
export { sortBonds, bondTier, nextThreshold, grantedBonds, pieceBondIds, morphPairings, HARMONY_BOND, harmonyMembers, bondMembers, memberHeadCount, bannedPerBond, disabledBondSets, briefingBondTip, modeOffBonds, bandOffBonds, bandOffLine } from './gameLogic/bonds.js';
|
export { sortBonds, bondTier, nextThreshold, grantedBonds, pieceBondIds, morphPairings, HARMONY_BOND, harmonyMembers, bondMembers, memberHeadCount, bannedPerBond, disabledBondSets, briefingBondTip, modeOffBonds, bandOffBonds, bandOffLine } from './gameLogic/bonds.js';
|
||||||
export { priceTone, mergeProgress, mergeTarget, handFull, completesMerge, offerHeader, shopBlockReason, readyFundsPrompt } from './gameLogic/shop.js';
|
export { priceTone, mergeProgress, mergeTarget, handFull, completesMerge, offerHeader, shopBlockReason, readyFundsPrompt } from './gameLogic/shop.js';
|
||||||
export { deploySets, stageOverrides, deployMap, effectiveStage, indexPieces, placementContext, piecePosition, tileAllows, summonRange, canPlace, itemAttaches, equipMerges, boardTargets, dropIntent, dropFailureReason } from './gameLogic/placement.js';
|
export { deploySets, stageOverrides, deployMap, effectiveStage, indexPieces, placementContext, piecePosition, tileAllows, summonRange, summonExcluded, canPlace, itemAttaches, equipMerges, boardTargets, dropIntent, dropFailureReason } from './gameLogic/placement.js';
|
||||||
export { normalizeDraft, normalizeSp } from './gameLogic/draft.js';
|
export { normalizeDraft, normalizeSp } from './gameLogic/draft.js';
|
||||||
export { groupEnemies, PEN, penZoneTiles, penPlacement, previewEnemyKey, factionTypes } from './gameLogic/enemies.js';
|
export { groupEnemies, PEN, penZoneTiles, penPlacement, previewEnemyKey, factionTypes } from './gameLogic/enemies.js';
|
||||||
export { snapHud, bossFrac, bossPctText, hasFlag, UF, attackInterval, fmtNum, rangeGridBox } from './gameLogic/format.js';
|
export { snapHud, bossFrac, bossPctText, hasFlag, UF, attackInterval, fmtNum, rangeGridBox } from './gameLogic/format.js';
|
||||||
|
|||||||
@@ -227,8 +227,13 @@ export function piecePosition(ctx, piece) {
|
|||||||
if (meleeOnHighGround(rec)) return 'ALL';
|
if (meleeOnHighGround(rec)) return 'ALL';
|
||||||
return rec?.position === 'MELEE' ? 'MELEE' : 'RANGED';
|
return rec?.position === 'MELEE' ? 'MELEE' : 'RANGED';
|
||||||
}
|
}
|
||||||
// tokens: MELEE → ground only; RANGED / ALL → any deployable tile
|
// tokens: MELEE → ground only; RANGED / ALL → any deployable tile; `rangedTilesOnly` (战术锚点 "仅可以部署在…远程位")
|
||||||
if (piece.kind === 'token') return ctx.getToken(piece.id)?.position === 'MELEE' ? 'MELEE' : 'RANGED';
|
// → 'HIGH', the ranged tiles only (server/match/board.js positionClass 'high')
|
||||||
|
if (piece.kind === 'token') {
|
||||||
|
const t = ctx.getToken(piece.id);
|
||||||
|
if (t?.rangedTilesOnly === true) return 'HIGH';
|
||||||
|
return t?.position === 'MELEE' ? 'MELEE' : 'RANGED';
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,6 +242,7 @@ export function tileAllows(ctx, piece, row, col) {
|
|||||||
const pos = piecePosition(ctx, piece);
|
const pos = piecePosition(ctx, piece);
|
||||||
if (!pos) return false;
|
if (!pos) return false;
|
||||||
const k = tileKey(row, col);
|
const k = tileKey(row, col);
|
||||||
|
if (pos === 'HIGH') return ctx.deploy.ranged.has(k) && !ctx.deploy.melee.has(k);
|
||||||
return pos === 'MELEE' ? ctx.deploy.melee.has(k) : ctx.deploy.ranged.has(k);
|
return pos === 'MELEE' ? ctx.deploy.melee.has(k) : ctx.deploy.ranged.has(k);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -251,6 +257,23 @@ export function tileAllows(ctx, piece, row, col) {
|
|||||||
*/
|
*/
|
||||||
export function summonRange(ctx, piece, owner = null) {
|
export function summonRange(ctx, piece, owner = null) {
|
||||||
if (!isObj(piece) || piece.kind !== 'token' || ctx?.getToken?.(piece.id)?.ownerRange !== true) return null;
|
if (!isObj(piece) || piece.kind !== 'token' || ctx?.getToken?.(piece.id)?.ownerRange !== true) return null;
|
||||||
|
return ownerRangeOf(ctx, piece, owner);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Board tiles ('r,c') an outside-bound summon may NOT stand on — its owner's attack range (tokens.json
|
||||||
|
* `ownerRangeOutside`: 凯尔希·思衡托's 战术锚点 "仅可以部署在凯尔希·思衡托攻击范围外的远程位") — or null. Mirror of
|
||||||
|
* server/match/player/placement.js summonExcluded.
|
||||||
|
* @param {ReturnType<typeof placementContext>} ctx
|
||||||
|
* @returns {Set<string> | null}
|
||||||
|
*/
|
||||||
|
export function summonExcluded(ctx, piece, owner = null) {
|
||||||
|
if (!isObj(piece) || piece.kind !== 'token' || ctx?.getToken?.(piece.id)?.ownerRangeOutside !== true) return null;
|
||||||
|
return ownerRangeOf(ctx, piece, owner);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The attack-range tiles of a summon piece's owner on the board (summonRange / summonExcluded), or null. */
|
||||||
|
function ownerRangeOf(ctx, piece, owner = null) {
|
||||||
let at = owner;
|
let at = owner;
|
||||||
if (!at) for (const e of ctx.boardAt.values()) if (e.piece.uid === piece.ownerUid && e.piece.kind === 'chess') { at = e; break; }
|
if (!at) for (const e of ctx.boardAt.values()) if (e.piece.uid === piece.ownerUid && e.piece.kind === 'chess') { at = e; break; }
|
||||||
const rec = at && ctx.getChess(at.piece.id);
|
const rec = at && ctx.getChess(at.piece.id);
|
||||||
@@ -260,11 +283,14 @@ export function summonRange(ctx, piece, owner = null) {
|
|||||||
return new Set(rangeTiles(grid || rec.rangeGrid, at.row, at.col, pieceDir(at.piece)).map(([r, c]) => tileKey(r, c)));
|
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). */
|
/** tileAllows plus the owner-range rules of a range-bound summon (summonRange, summonExcluded). */
|
||||||
function unitAllowed(ctx, piece, row, col, owner = null) {
|
function unitAllowed(ctx, piece, row, col, owner = null) {
|
||||||
if (!tileAllows(ctx, piece, row, col)) return false;
|
if (!tileAllows(ctx, piece, row, col)) return false;
|
||||||
|
const k = tileKey(row, col);
|
||||||
const range = summonRange(ctx, piece, owner);
|
const range = summonRange(ctx, piece, owner);
|
||||||
return !range || range.has(tileKey(row, col));
|
if (range && !range.has(k)) return false;
|
||||||
|
const out = summonExcluded(ctx, piece, owner);
|
||||||
|
return !out || !out.has(k);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -329,13 +355,16 @@ export function canPlace(ctx, uid, target) {
|
|||||||
if (src.area === 'board' && src.row === row && src.col === col) return { ok: true, action: 'orient' };
|
if (src.area === 'board' && src.row === row && src.col === col) return { ok: true, action: 'orient' };
|
||||||
if (!tileAllows(ctx, piece, row, col)) {
|
if (!tileAllows(ctx, piece, row, col)) {
|
||||||
const deployable = ctx.deploy.ranged.has(tileKey(row, col));
|
const deployable = ctx.deploy.ranged.has(tileKey(row, col));
|
||||||
return no('BAD_TILE', deployable && piecePosition(ctx, piece) === 'MELEE' ? '近战单位只能部署在地面' : '无法部署在该位置');
|
const pos = piecePosition(ctx, piece);
|
||||||
|
return no('BAD_TILE', deployable && pos === 'MELEE' ? '近战单位只能部署在地面' : deployable && pos === 'HIGH' ? '只能部署在远程位' : '无法部署在该位置');
|
||||||
}
|
}
|
||||||
// a range-bound summon (战术点): inside its owner's attack range — seen from the summon's old tile when it is
|
// a range-bound summon (战术点): inside its owner's attack range — seen from the summon's old tile when it is
|
||||||
// dropped onto its own owner (the two swap)
|
// dropped onto its own owner (the two swap); an outside-bound one (战术锚点) outside it
|
||||||
const ownerSwap = src.area === 'board' && occ && occ.piece.uid === piece.ownerUid ? { row: src.row, col: src.col, piece: occ.piece } : null;
|
const ownerSwap = src.area === 'board' && occ && occ.piece.uid === piece.ownerUid ? { row: src.row, col: src.col, piece: occ.piece } : null;
|
||||||
const range = summonRange(ctx, piece, ownerSwap);
|
const range = summonRange(ctx, piece, ownerSwap);
|
||||||
if (range && !range.has(tileKey(row, col))) return no('BAD_TILE', '只能部署在召唤者攻击范围内');
|
if (range && !range.has(tileKey(row, col))) return no('BAD_TILE', '只能部署在召唤者攻击范围内');
|
||||||
|
const out = summonExcluded(ctx, piece, ownerSwap);
|
||||||
|
if (out && out.has(tileKey(row, col))) return no('BAD_TILE', '只能部署在召唤者攻击范围外');
|
||||||
if (src.area === 'board') {
|
if (src.area === 'board') {
|
||||||
// board → board: move or swap (the occupant must be legal on the source tile — the mover's own summon excepted:
|
// board → board: move or swap (the occupant must be legal on the source tile — the mover's own summon excepted:
|
||||||
// a moved operator's summons go back to the hand anyway)
|
// a moved operator's summons go back to the hand anyway)
|
||||||
|
|||||||
+11
-6
@@ -27,11 +27,13 @@
|
|||||||
// 「可以放置于远程位」 — the 钩索师 / 推击手 branch trait: 歌蕾蒂娅, 崖心, 见行者, normal and elite, any module or none
|
// 「可以放置于远程位」 — the 钩索师 / 推击手 branch trait: 歌蕾蒂娅, 崖心, 见行者, normal and elite, any module or none
|
||||||
// (shared/highGround.js ⇒ any deployable tile; the owner's decision of 2026-10-05, following PRTS). The loadout does
|
// (shared/highGround.js ⇒ any deployable tile; the owner's decision of 2026-10-05, following PRTS). The loadout does
|
||||||
// not change the class.
|
// not change the class.
|
||||||
// Tokens follow their own `position` (ALL ⇒ any deployable tile, MELEE ⇒ melee tiles, RANGED ⇒ any deployable).
|
// Tokens follow their own `position` (ALL ⇒ any deployable tile, MELEE ⇒ melee tiles, RANGED ⇒ any deployable), except
|
||||||
|
// a token marked `rangedTilesOnly` ("仅可以部署在…远程位": 凯尔希·思衡托's 战术锚点) — class 'high', the ranged tiles only.
|
||||||
// A token whose text reads "只能部署在召唤者攻击范围内" (tokens.json `ownerRange`: the tacticians' 援军 — 伺夜's 狼群,
|
// 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
|
// 缪尔赛思's 流形; PRTS 狼群 特性; Mon3tr's 重构体 by her talent) also needs a tile of its owner's attack range:
|
||||||
// grid (loadout-resolved, shared/loadoutRecord.js attackRangeGrid) rotated by its facing around its board tile
|
// `ownerRangeKeys` = the owner's range grid (loadout-resolved, shared/loadoutRecord.js attackRangeGrid) rotated by its
|
||||||
// (PlayerState._legal; player report #9 after 0.1.0).
|
// facing around its board tile (PlayerState._legal; player report #9 after 0.1.0); one marked `ownerRangeOutside`
|
||||||
|
// ("…攻击范围外": the 战术锚点) a tile outside that range.
|
||||||
// Facing: board pieces carry `dir` ∈ UP|RIGHT|DOWN|LEFT (server/sim/dir.js); `pieceDir` reads it (absent ⇒ RIGHT),
|
// 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.
|
// `parseDir` validates an intent's optional direction.
|
||||||
|
|
||||||
@@ -135,12 +137,14 @@ export function buildDeployMap(stage, { deviceOverrides = {}, tileOverrides = {}
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Placement class of a chess / token record: 'melee' | 'ranged' | 'all'. A MELEE chess whose trait reads
|
* Placement class of a chess / token record: 'melee' | 'ranged' | 'all' | 'high'. A MELEE chess whose trait reads
|
||||||
* 「可以放置于远程位」 (shared/highGround.js: 歌蕾蒂娅, 崖心, 见行者, normal and elite) is widened to 'all': it may also
|
* 「可以放置于远程位」 (shared/highGround.js: 歌蕾蒂娅, 崖心, 见行者, normal and elite) is widened to 'all': it may also
|
||||||
* stand on the ranged (高台) tiles. Every other MELEE record stays 'melee'. `chess.json` has no `placement` field.
|
* stand on the ranged (高台) tiles. Every other MELEE record stays 'melee'. A token marked `rangedTilesOnly` (tokens
|
||||||
|
* "仅可以部署在…远程位": 战术锚点) is 'high': the ranged tiles only. `chess.json` has no `placement` field.
|
||||||
* @param {object|null} rec
|
* @param {object|null} rec
|
||||||
*/
|
*/
|
||||||
export function positionClass(rec) {
|
export function positionClass(rec) {
|
||||||
|
if (rec && rec.rangedTilesOnly === true) return 'high';
|
||||||
return meleeOnHighGround(rec) ? 'all' : basePositionClass(rec);
|
return meleeOnHighGround(rec) ? 'all' : basePositionClass(rec);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,6 +187,7 @@ export function canPlace(map, pos, r, c) {
|
|||||||
const cls = map.get(tileKey(r, c));
|
const cls = map.get(tileKey(r, c));
|
||||||
if (!cls) return false;
|
if (!cls) return false;
|
||||||
if (pos === 'melee') return cls === 'melee';
|
if (pos === 'melee') return cls === 'melee';
|
||||||
|
if (pos === 'high') return cls === 'ranged';
|
||||||
return true; // ranged / all: melee tiles and ranged tiles
|
return true; // ranged / all: melee tiles and ranged tiles
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+4
-2
@@ -956,16 +956,18 @@ export function* planLayoutSteps(m, ps, pieces, params = LAYOUT_PARAMS, { occupi
|
|||||||
let bestV = -Infinity;
|
let bestV = -Infinity;
|
||||||
// a "只能部署在召唤者攻击范围内" summon (伺夜's 狼群, 缪尔赛思's 流形): only the tiles of its owner's range
|
// 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;
|
const within = p.kind === 'token' && typeof ps.summonRange === 'function' ? ps.summonRange(p) : null;
|
||||||
|
// and an outside-bound one (凯尔希·思衡托's 战术锚点): never a tile of its owner's range
|
||||||
|
const without = p.kind === 'token' && typeof ps.summonExcluded === 'function' ? ps.summonExcluded(p) : null;
|
||||||
// ground tiles for a MELEE blocker. placeClass 'all' on a MELEE record is a chess whose trait reads 「可以放置于远程位」
|
// ground tiles for a MELEE blocker. placeClass 'all' on a MELEE record is a chess whose trait reads 「可以放置于远程位」
|
||||||
// (歌蕾蒂娅, 崖心, 见行者, any module): it may stand on a 高台, and when one of those tiles covers the enemy road it is
|
// (歌蕾蒂娅, 崖心, 见行者, any module): it may stand on a 高台, and when one of those tiles covers the enemy road it is
|
||||||
// planned there even with the ground free (owner 2026-10-04: the bot uses the 高台; 2026-10-05: every such chess).
|
// planned there even with the ground free (owner 2026-10-04: the bot uses the 高台; 2026-10-05: every such chess).
|
||||||
const cls = p.kind === 'token' ? basePositionClass(r0) : placeClass(ps, pgd.chess(p.id) || r0);
|
const cls = p.kind === 'token' ? positionClass(r0) : placeClass(ps, pgd.chess(p.id) || r0);
|
||||||
const preferHigh = cls === 'all' && basePositionClass(r0) === 'melee';
|
const preferHigh = cls === 'all' && basePositionClass(r0) === 'melee';
|
||||||
let bestHigh = null;
|
let bestHigh = null;
|
||||||
let bestHighV = -Infinity;
|
let bestHighV = -Infinity;
|
||||||
for (const [r, c] of legalTiles(map, cls)) {
|
for (const [r, c] of legalTiles(map, cls)) {
|
||||||
const k = tileKey(r, c);
|
const k = tileKey(r, c);
|
||||||
if (taken.has(k) || (within && !within.has(k))) continue;
|
if (taken.has(k) || (within && !within.has(k)) || (without && without.has(k))) continue;
|
||||||
const noise = m.rngBots() * 1e-6;
|
const noise = m.rngBots() * 1e-6;
|
||||||
const seen = new Set();
|
const seen = new Set();
|
||||||
for (const dir of PLAN_DIRS) {
|
for (const dir of PLAN_DIRS) {
|
||||||
|
|||||||
@@ -128,6 +128,9 @@ export function collectViolations(m, { limit = 25 } = {}) {
|
|||||||
// a "只能部署在召唤者攻击范围内" summon inside its owner's attack range (PlayerState.summonRange: a pure read)
|
// 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;
|
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 (range && !range.has(k)) fail(`${id}: ${p.id} on ${k}, outside its owner's attack range`);
|
||||||
|
// an outside-bound summon (战术锚点, PlayerState.summonExcluded) outside it
|
||||||
|
const out = p.kind === 'token' && typeof ps.summonExcluded === 'function' ? ps.summonExcluded(p) : null;
|
||||||
|
if (out && out.has(k)) fail(`${id}: ${p.id} on ${k}, inside its owner's attack range`);
|
||||||
if (p.kind === 'chess') deployed++;
|
if (p.kind === 'chess') deployed++;
|
||||||
}
|
}
|
||||||
if (deployed > ps.deployCap) fail(`${id}: ${deployed} chess deployed > cap ${ps.deployCap}`);
|
if (deployed > ps.deployCap) fail(`${id}: ${deployed} chess deployed > cap ${ps.deployCap}`);
|
||||||
|
|||||||
@@ -44,14 +44,18 @@ export class PlayerPlacement {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Where piece may stand on (r, c): the deploy map of its position class (board.js canPlace) and, for a summon whose
|
* 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
|
* text reads "只能部署在召唤者攻击范围内" (tokens.json `ownerRange`: 伺夜's 狼群, 缪尔赛思's 流形, Mon3tr's 重构体), a tile of
|
||||||
* attack range (summonRange). `owner` = the owner's position after the move being checked ({ key, piece, dir }: a
|
* its owner's attack range (summonRange); for one marked `ownerRangeOutside` (凯尔希·思衡托's 战术锚点), a tile outside it
|
||||||
* summon swapped with its own owner).
|
* (summonExcluded). `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) {
|
_legal(piece, r, c, owner = null) {
|
||||||
if (!canPlace(this.deployMap(), this._placementOf(piece), r, c)) return false;
|
if (!canPlace(this.deployMap(), this._placementOf(piece), r, c)) return false;
|
||||||
|
const k = tileKey(r, c);
|
||||||
const range = this.summonRange(piece, owner);
|
const range = this.summonRange(piece, owner);
|
||||||
return !range || range.has(tileKey(r, c));
|
if (range && !range.has(k)) return false;
|
||||||
|
const out = this.summonExcluded(piece, owner);
|
||||||
|
return !out || !out.has(k);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -66,6 +70,24 @@ export class PlayerPlacement {
|
|||||||
*/
|
*/
|
||||||
summonRange(piece, owner = null) {
|
summonRange(piece, owner = null) {
|
||||||
if (!piece || piece.kind !== 'token' || this.gd.token(piece.id)?.ownerRange !== true) return null;
|
if (!piece || piece.kind !== 'token' || this.gd.token(piece.id)?.ownerRange !== true) return null;
|
||||||
|
return this._ownerRangeOf(piece, owner);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The 'r,c' keys an outside-bound summon may NOT stand on — its owner's attack range (tokens `ownerRangeOutside`: PRTS
|
||||||
|
* 战术锚点 "仅可以部署在凯尔希·思衡托攻击范围外的远程位"; 0.2.0 WE2) — or null (not outside-bound, or the owner is off the
|
||||||
|
* board). Same range as summonRange.
|
||||||
|
* @param {any} piece
|
||||||
|
* @param {{ key: string, piece: any, dir: string } | null} [owner]
|
||||||
|
* @returns {Set<string> | null}
|
||||||
|
*/
|
||||||
|
summonExcluded(piece, owner = null) {
|
||||||
|
if (!piece || piece.kind !== 'token' || this.gd.token(piece.id)?.ownerRangeOutside !== true) return null;
|
||||||
|
return this._ownerRangeOf(piece, owner);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The owner's attack-range keys of a summon piece (summonRange / summonExcluded), or null when its owner is off the board. */
|
||||||
|
_ownerRangeOf(piece, owner = null) {
|
||||||
let at = owner;
|
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; }
|
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 chess = at && this.gd.chess(at.piece.id);
|
const chess = at && this.gd.chess(at.piece.id);
|
||||||
@@ -86,17 +108,23 @@ export class PlayerPlacement {
|
|||||||
* battle. Returns the number taken off the board; a toast names them.
|
* battle. Returns the number taken off the board; a toast names them.
|
||||||
*/
|
*/
|
||||||
_liftOutOfRange() {
|
_liftOutOfRange() {
|
||||||
const back = [], gone = [];
|
const back = [], gone = [], backOut = [], goneOut = [];
|
||||||
for (const [k, p] of [...this.board]) {
|
for (const [k, p] of [...this.board]) {
|
||||||
if (p.kind !== 'token') continue;
|
if (p.kind !== 'token') continue;
|
||||||
const range = this.summonRange(p);
|
const range = this.summonRange(p);
|
||||||
if (!range || range.has(k)) continue;
|
const out = this.summonExcluded(p);
|
||||||
|
const inside = !!(out && out.has(k));
|
||||||
|
if (!inside && (!range || range.has(k))) continue;
|
||||||
this.board.delete(k);
|
this.board.delete(k);
|
||||||
(this._returnToken(p, null, { allowTemp: true }) ? back : gone).push(this.gd.token(p.id)?.name || p.id);
|
const ok = this._returnToken(p, null, { allowTemp: true });
|
||||||
|
(inside ? (ok ? backOut : goneOut) : (ok ? back : gone)).push(this.gd.token(p.id)?.name || p.id);
|
||||||
}
|
}
|
||||||
if (back.length) this.m.toast(this, 'warn', msg('{names}只能部署在召唤者攻击范围内,已退回整备区', { names: back.map(dn) }));
|
if (back.length) this.m.toast(this, 'warn', msg('{names}只能部署在召唤者攻击范围内,已退回整备区', { names: back.map(dn) }));
|
||||||
if (gone.length) this.m.toast(this, 'warn', msg('{names}只能部署在召唤者攻击范围内,整备区已满,下回合返还', { names: gone.map(dn) }));
|
if (gone.length) this.m.toast(this, 'warn', msg('{names}只能部署在召唤者攻击范围内,整备区已满,下回合返还', { names: gone.map(dn) }));
|
||||||
return back.length + gone.length;
|
// an outside-bound summon (战术锚点) its owner's new range now covers
|
||||||
|
if (backOut.length) this.m.toast(this, 'warn', msg('{names}只能部署在召唤者攻击范围外,已退回整备区', { names: backOut.map(dn) }));
|
||||||
|
if (goneOut.length) this.m.toast(this, 'warn', msg('{names}只能部署在召唤者攻击范围外,整备区已满,下回合返还', { names: goneOut.map(dn) }));
|
||||||
|
return back.length + gone.length + backOut.length + goneOut.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -111,7 +139,8 @@ export class PlayerPlacement {
|
|||||||
for (const [k, p] of this.board) {
|
for (const [k, p] of this.board) {
|
||||||
if (p.kind !== 'token' || p.ownerUid !== owner.uid || stacks.some((s) => s.id === p.id)) continue;
|
if (p.kind !== 'token' || p.ownerUid !== owner.uid || stacks.some((s) => s.id === p.id)) continue;
|
||||||
const range = this.summonRange(p, at);
|
const range = this.summonRange(p, at);
|
||||||
if (range && !range.has(k)) needSlot.add(p.id);
|
const out = this.summonExcluded(p, at);
|
||||||
|
if ((range && !range.has(k)) || (out && out.has(k))) needSlot.add(p.id);
|
||||||
}
|
}
|
||||||
return needSlot.size <= [...this.hand, ...this.temp].filter((p) => p == null).length;
|
return needSlot.size <= [...this.hand, ...this.temp].filter((p) => p == null).length;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,8 +68,8 @@
|
|||||||
// the move be refused, she comes back on her own tile [ASSUMED; officially she is knocked out].
|
// the move be refused, she comes back on her own tile [ASSUMED; officially she is knocked out].
|
||||||
// At the skill's end any anchor of hers on the field is withdrawn and the stock emptied (token_ctrl). The flight's begin
|
// At the skill's end any anchor of hers on the field is withdrawn and the stock emptied (token_ctrl). The flight's begin
|
||||||
// clip and its 生命回复速度 ×0 are not modelled [ASSUMED].
|
// clip and its 生命回复速度 ×0 are not modelled [ASSUMED].
|
||||||
// - 战术锚点 (token_10068_kalts2_mtship, placed in prep — "仅可以部署在凯尔希·思衡托攻击范围外的远程位" is the match's
|
// - 战术锚点 (token_10068_kalts2_mtship, placed in prep on a ranged tile outside her attack range — "仅可以部署在凯尔希·思衡托
|
||||||
// placement rule): 无敌 and never targeted ("不会受到攻击"), no attack, blocks nothing; its range (hers) is only shown.
|
// 攻击范围外的远程位": the data's `ownerRangeOutside` / `rangedTilesOnly`, enforced by the match, 0.2.0 WE2): 无敌 and never targeted ("不会受到攻击"), no attack, blocks nothing; its range (hers) is only shown.
|
||||||
// A placed piece is docked by content/tokens.js (a skill's summon: deployed for free at the battle start, then only when
|
// A placed piece is docked by content/tokens.js (a skill's summon: deployed for free at the battle start, then only when
|
||||||
// her S3 gives one and its 70 s redeploy time has passed, on its own tile).
|
// her S3 gives one and its 70 s redeploy time has passed, on its own tile).
|
||||||
|
|
||||||
|
|||||||
@@ -34,9 +34,9 @@
|
|||||||
// stage 3: the next attack@chain.extra_cnt jumps after that neither). Her heals pass 禁疗 ("治疗效果无视禁疗"); her selection
|
// stage 3: the next attack@chain.extra_cnt jumps after that neither). Her heals pass 禁疗 ("治疗效果无视禁疗"); her selection
|
||||||
// takes her own 重构体 through its 禁疗 (`healThrough`), no other 禁疗 unit [ASSUMED: the jumps too].
|
// takes her own 重构体 through its 禁疗 (`healThrough`), no other 禁疗 unit [ASSUMED: the jumps too].
|
||||||
// - Module XAH-X 记忆存档: the trait's 0.85 per jump (traitOverride); stage 3 changes T1 (below); ATK / DEF from the attributes.
|
// - Module XAH-X 记忆存档: the trait's 0.85 per jump (traitOverride); stage 3 changes T1 (below); ATK / DEF from the attributes.
|
||||||
// - T1 自我修复 (cnt 1): her 重构体 is a hand piece the player places (the talent's summon). "可以在攻击范围内的地面使用" — a
|
// - T1 自我修复 (cnt 1): her 重构体 is a hand piece the player places (the talent's summon) on a ground tile of her attack
|
||||||
// ground tile of her attack range — is not in the token data (`ownerRange` false), so the match does not hold the piece to
|
// range — "可以在攻击范围内的地面使用": the data's `ownerRange` from that talent text (tools/build-data.mjs summonRecord,
|
||||||
// it and the battle takes it where it stands [open question]. Its kit: 禁疗 (only her heals reach it), blocks nothing, taunt
|
// 0.2.0 WE2) and its MELEE position, enforced by the match. Its kit: 禁疗 (only her heals reach it), blocks nothing, taunt
|
||||||
// −1, loses 8 HP every 0.1 s from 1 s after it lands (流失, Battle.loseHp — PRTS "每0.1s流失8生命值"; trait damage_per_second
|
// −1, loses 8 HP every 0.1 s from 1 s after it lands (流失, Battle.loseHp — PRTS "每0.1s流失8生命值"; trait damage_per_second
|
||||||
// 80); every ally of its
|
// 80); every ally of its
|
||||||
// 3×3 but itself (no 孤立 one) ATK +atk (0.15; XAH-X stage 3 0.25; 同名效果取最高). Gone (destroyed or withdrawn) ⇒ back on its
|
// 3×3 but itself (no 孤立 one) ATK +atk (0.15; XAH-X stage 3 0.25; 同名效果取最高). Gone (destroyed or withdrawn) ⇒ back on its
|
||||||
|
|||||||
@@ -1,23 +1,32 @@
|
|||||||
// test/match/feedback5-we2-placement.test.js — 0.2.0 WE2: where the 自选 summon pieces may be placed in prep.
|
// test/match/feedback5-we2-placement.test.js — 0.2.0 WE2: where the 自选 summon pieces may be placed in prep.
|
||||||
// #22 望's 棋子 go on any tile class (PRTS 棋子 部署位置 "全部位", 备注 "游戏内召唤物信息与实际不符(显示为仅部署在近战位)").
|
// #22 望's 棋子 go on any tile class (PRTS 棋子 部署位置 "全部位", 备注 "游戏内召唤物信息与实际不符(显示为仅部署在近战位)").
|
||||||
|
// #26 Mon3tr's 重构体 only inside her attack range on the ground (her talent "可以在攻击范围内的地面使用一个…重构体");
|
||||||
|
// 凯尔希·思衡托's 战术锚点 only on a ranged tile outside her attack range (PRTS 战术锚点 特性 "仅可以部署在凯尔希·思衡托攻击范围外的
|
||||||
|
// 远程位") — server (PlayerState._legal / summonRange / summonExcluded, board.js 'high'), the client mirror (gameLogic
|
||||||
|
// canPlace) and the re-orientation lift.
|
||||||
// Run: node --test test/match/feedback5-we2-placement.test.js
|
// Run: node --test test/match/feedback5-we2-placement.test.js
|
||||||
|
|
||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { ERR } from '../../shared/constants.js';
|
import { ERR } from '../../shared/constants.js';
|
||||||
import { FIELD, canPlace, placeClass, tileKey } from '../../server/match/board.js';
|
import { FIELD, canPlace, placeClass, tileKey, parseKey } from '../../server/match/board.js';
|
||||||
import { makeMatch, checkInvariants } from './harness.js';
|
import { placementContext, canPlace as clientCanPlace, diyGetter } from '../../public/js/ui/gameLogic.js';
|
||||||
|
import { DATA, makeMatch, checkInvariants } from './harness.js';
|
||||||
|
|
||||||
const T5A = 'chess_char_5_diy1_a';
|
const T5A = 'chess_char_5_diy1_a';
|
||||||
const WANG = 'char_2027_wang';
|
const WANG = 'char_2027_wang';
|
||||||
const STONE = 'token_10064_wang_stone1';
|
const STONE = 'token_10064_wang_stone1';
|
||||||
|
const MONSTR = 'char_4179_monstr';
|
||||||
|
const PROSTS = 'token_10050_monstr_prosts';
|
||||||
|
const KALTS2 = 'char_1052_kalts2';
|
||||||
|
const ANCHOR = 'token_10068_kalts2_mtship';
|
||||||
|
|
||||||
/** A solo match whose human slots `picks`, in its first prep on 战场#01 (3 高台 tiles) with an empty board and hand. */
|
/** A solo match whose human slots `picks`, in its first prep on `stage` (战场#01: 3 高台 tiles) with an empty board and hand. */
|
||||||
function prep(picks, seed = 2101) {
|
function prep(picks, seed = 2101, stage = 'act2autochess_m01') {
|
||||||
const seats = [{ seat: 0, playerId: 'p_0', name: 'P0', isBot: false, connected: true, diy: picks }];
|
const seats = [{ seat: 0, playerId: 'p_0', name: 'P0', isBot: false, connected: true, diy: picks }];
|
||||||
const h = makeMatch({ mode: 'solo', difficulty: 'NORMAL', seats, seed }).start();
|
const h = makeMatch({ mode: 'solo', difficulty: 'NORMAL', seats, seed }).start();
|
||||||
h.toPrep(1);
|
h.toPrep(1);
|
||||||
h.setStage('act2autochess_m01');
|
h.setStage(stage);
|
||||||
const ps = h.ps('p_0');
|
const ps = h.ps('p_0');
|
||||||
for (const p of [...ps.board.values(), ...ps.hand.filter(Boolean)]) if (p.kind === 'chess') ps.returnCopies(p);
|
for (const p of [...ps.board.values(), ...ps.hand.filter(Boolean)]) if (p.kind === 'chess') ps.returnCopies(p);
|
||||||
ps.board.clear();
|
ps.board.clear();
|
||||||
@@ -74,3 +83,99 @@ test('#22 望\'s 棋子: a hand piece goes on a 高台 (ranged tile) as well as
|
|||||||
checkInvariants(m);
|
checkInvariants(m);
|
||||||
m.dispose();
|
m.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** The owner's attack-range keys on the board (the server's own reading). */
|
||||||
|
const rangeOf = (ps, owner) => ps._ownerRangeOf({ kind: 'token', ownerUid: owner.uid, id: '' });
|
||||||
|
/** The summon stack of `owner` in the hand. */
|
||||||
|
const stackOf = (ps, owner, tokenId) => ps.hand.find((p) => p && p.kind === 'token' && p.id === tokenId && p.ownerUid === owner.uid);
|
||||||
|
/** The client's legality of dropping `uid` on (r, c), from the player's private view. */
|
||||||
|
function clientOk(m, ps, uid, r, c) {
|
||||||
|
const priv = ps.privateView();
|
||||||
|
const getChess = diyGetter((id) => DATA.chess[id] ?? null, priv, DATA);
|
||||||
|
const ctx = placementContext({
|
||||||
|
priv, stage: m.stage, editable: true, getChess, backups: DATA.backups,
|
||||||
|
getToken: (id) => DATA.tokens[id] ?? DATA.backups.tokens[id] ?? null, getItem: (id) => DATA.items[id] ?? null,
|
||||||
|
});
|
||||||
|
return clientCanPlace(ctx, uid, { area: 'board', row: r, col: c }).ok;
|
||||||
|
}
|
||||||
|
/** Every free tile of the field: [r, c, serverLegal]. */
|
||||||
|
function freeTiles(ps, piece) {
|
||||||
|
const out = [];
|
||||||
|
for (let r = FIELD.r1; r >= FIELD.r0; r--) for (let c = FIELD.c0; c <= FIELD.c1; c++) if (!ps.board.has(tileKey(r, c))) out.push([r, c, ps._legal(piece, r, c)]);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('#26 Mon3tr\'s 重构体: only on a ground tile of her attack range — data ownerRange from her talent; server and client agree', () => {
|
||||||
|
assert.equal(DATA.backups.tokens[PROSTS].ownerRange, true);
|
||||||
|
const { m, ps } = prep({ [T5A]: { charId: MONSTR, skillIndex: 0 } });
|
||||||
|
const mon = placeDiy(m, ps, T5A, [10, 5]);
|
||||||
|
const stack = stackOf(ps, mon, PROSTS);
|
||||||
|
assert.ok(stack, 'her 重构体 joined the hand');
|
||||||
|
const range = rangeOf(ps, mon);
|
||||||
|
const map = ps.deployMap();
|
||||||
|
const tiles = freeTiles(ps, stack);
|
||||||
|
let inside = 0, outside = 0;
|
||||||
|
for (const [r, c, legal] of tiles) {
|
||||||
|
const k = tileKey(r, c);
|
||||||
|
assert.equal(legal, range.has(k) && map.get(k) === 'melee', `server ${k}`);
|
||||||
|
assert.equal(clientOk(m, ps, stack.uid, r, c), legal, `client ${k}`);
|
||||||
|
if (legal) inside++; else if (map.get(k) === 'melee') outside++;
|
||||||
|
}
|
||||||
|
assert.ok(inside > 0 && outside > 0, `${inside} legal ground tiles in her range, ${outside} outside`);
|
||||||
|
const [r0, c0] = tiles.find((t) => t[2]);
|
||||||
|
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: stack.uid, to: { area: 'board', row: r0, col: c0 } }), { ok: true });
|
||||||
|
const off = tiles.find(([r, c]) => map.get(tileKey(r, c)) === 'melee' && !range.has(tileKey(r, c)));
|
||||||
|
const again = ps.board.get(tileKey(r0, c0));
|
||||||
|
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: again.uid, to: { area: 'board', row: off[0], col: off[1] } }), { error: ERR.BAD_TILE }, 'outside her range');
|
||||||
|
checkInvariants(m);
|
||||||
|
m.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('#26 凯尔希·思衡托\'s 战术锚点 (S3): only on a ranged tile outside her attack range; re-oriented so that her range covers it, it goes back to the hand', () => {
|
||||||
|
const rec = DATA.backups.tokens[ANCHOR];
|
||||||
|
assert.deepEqual([rec.position, rec.ownerRangeOutside, rec.rangedTilesOnly, rec.ownerRange], ['RANGED', true, true, false]);
|
||||||
|
// act1 m03: 高台 at (10, 3), (10, 4), (11, 7), (11, 8); she stands on the ground at (12, 5), facing RIGHT — her y-6 takes
|
||||||
|
// (11, 7) in and leaves the other three out
|
||||||
|
const { h, m, ps } = prep({ [T5A]: { charId: KALTS2, skillIndex: 2 } }, 2102, 'act1autochess_m03');
|
||||||
|
const map = ps.deployMap();
|
||||||
|
const highs = [...map].filter(([, v]) => v === 'ranged').map(([k]) => parseKey(k));
|
||||||
|
assert.ok(highs.length >= 2, 'the stage has 高台 tiles');
|
||||||
|
const kal = placeDiy(m, ps, T5A, [12, 5]);
|
||||||
|
const stack = stackOf(ps, kal, ANCHOR);
|
||||||
|
assert.ok(stack && (stack.count || 1) === 1, 'one 战术锚点 in the hand (S3)');
|
||||||
|
const range = rangeOf(ps, kal);
|
||||||
|
const tiles = freeTiles(ps, stack);
|
||||||
|
let legalN = 0;
|
||||||
|
for (const [r, c, legal] of tiles) {
|
||||||
|
const k = tileKey(r, c);
|
||||||
|
assert.equal(legal, map.get(k) === 'ranged' && !range.has(k), `server ${k}`);
|
||||||
|
assert.equal(clientOk(m, ps, stack.uid, r, c), legal, `client ${k}`);
|
||||||
|
if (legal) legalN++;
|
||||||
|
}
|
||||||
|
const insideHigh = highs.find(([r, c]) => range.has(tileKey(r, c)) && !ps.board.has(tileKey(r, c)));
|
||||||
|
const ground = tiles.find(([r, c]) => map.get(tileKey(r, c)) === 'melee' && !range.has(tileKey(r, c)));
|
||||||
|
assert.ok(ground, 'a ground tile outside her range');
|
||||||
|
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: stack.uid, to: { area: 'board', row: ground[0], col: ground[1] } }), { error: ERR.BAD_TILE }, 'not on the ground');
|
||||||
|
if (insideHigh) assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: stack.uid, to: { area: 'board', row: insideHigh[0], col: insideHigh[1] } }), { error: ERR.BAD_TILE }, 'not inside her range');
|
||||||
|
assert.ok(legalN > 0, 'a 高台 outside her range');
|
||||||
|
// placed on a 高台 outside her range; then she turns so that her range takes that tile in: the anchor goes back
|
||||||
|
for (const dir of ['RIGHT', 'UP', 'DOWN', 'LEFT']) {
|
||||||
|
const atDir = { key: tileKey(12, 5), piece: kal, dir };
|
||||||
|
const keys = ps._ownerRangeOf({ kind: 'token', ownerUid: kal.uid, id: '' }, atDir);
|
||||||
|
const spot = tiles.find(([r, c, legal]) => legal && keys.has(tileKey(r, c)));
|
||||||
|
if (!spot) continue;
|
||||||
|
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: stack.uid, to: { area: 'board', row: spot[0], col: spot[1] } }), { ok: true }, 'onto a free 高台 outside her range');
|
||||||
|
const placed = ps.board.get(tileKey(spot[0], spot[1]));
|
||||||
|
assert.equal(placed.id, ANCHOR);
|
||||||
|
const toasts = h.sent.length;
|
||||||
|
assert.deepEqual(m.handle('p_0', { t: 'g.move', uid: kal.uid, to: { area: 'board', row: 12, col: 5 }, dir }), { ok: true }, `re-oriented ${dir}`);
|
||||||
|
assert.ok(!ps.board.has(tileKey(spot[0], spot[1])), 'the anchor left the board');
|
||||||
|
assert.ok(stackOf(ps, kal, ANCHOR), 'back on its stack');
|
||||||
|
const said = h.sent.slice(toasts).filter(([, msg]) => msg.t === 'm.toast').map(([, msg]) => msg.msgid || msg.text);
|
||||||
|
assert.ok(said.some((x) => String(x).includes('只能部署在召唤者攻击范围外')), `the toast: ${said.join(' / ')}`);
|
||||||
|
checkInvariants(m);
|
||||||
|
m.dispose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
assert.fail('no facing of hers covers a legal 高台');
|
||||||
|
});
|
||||||
+18
-7
@@ -1530,15 +1530,23 @@ const TOKEN_POSITION_CORRECTIONS = Object.freeze({
|
|||||||
* skill's summon deploys once at the start, then takes its tile again each time the skill gives one
|
* skill's summon deploys once at the start, then takes its tile again each time the skill gives one
|
||||||
* (sim/content/tokens.js dockSkillSummons, shared/constants.js SKILL_SUMMON_START_DEPLOY).
|
* (sim/content/tokens.js dockSkillSummons, shared/constants.js SKILL_SUMMON_START_DEPLOY).
|
||||||
* `ownerRange`: the token text "只能部署在召唤者攻击范围内" (the tacticians' 援军 — 伺夜's 狼群, 缪尔赛思's 流形; PRTS 狼群
|
* `ownerRange`: the token text "只能部署在召唤者攻击范围内" (the tacticians' 援军 — 伺夜's 狼群, 缪尔赛思's 流形; PRTS 狼群
|
||||||
* 特性): its hand piece may only be placed on a tile of its owner's attack range (server/match/board.js
|
* 特性) or an owner's talent naming it "可以在攻击范围内(的地面)部署 / 使用…" (`ownerTexts`: Mon3tr's 重构体 "可以在攻击范围内的地面
|
||||||
* ownerRangeKeys, PlayerState._legal; player report #9 after 0.1.0: 伺夜's tactical point could go anywhere).
|
* 使用一个…重构体", 0.2.0 WE2; 莱伊's 沙地兽, whose text says it too): its hand piece may only be placed on a tile of its
|
||||||
|
* owner's attack range (server/match/board.js ownerRangeKeys, PlayerState._legal; player report #9 after 0.1.0: 伺夜's
|
||||||
|
* tactical point could go anywhere). `ownerRangeOutside` (present when true): the token text "部署在…攻击范围外" — the
|
||||||
|
* piece may only stand OUTSIDE its owner's attack range; `rangedTilesOnly` (present when true): "仅可以部署在…远程位" — only
|
||||||
|
* on a ranged (高台) tile, the mode's "所有行动内远程干员可部署在近战位" being an operators' rule: 凯尔希·思衡托's 战术锚点
|
||||||
|
* "仅可以部署在凯尔希·思衡托攻击范围外的远程位" (PRTS 战术锚点 特性; 0.2.0 WE2, O24).
|
||||||
* `abnormal` = TOKEN_ABNORMAL (PRTS); `position` = TOKEN_POSITION_CORRECTIONS (PRTS) or the token row's.
|
* `abnormal` = TOKEN_ABNORMAL (PRTS); `position` = TOKEN_POSITION_CORRECTIONS (PRTS) or the token row's.
|
||||||
*/
|
*/
|
||||||
function summonRecord(ctx, tokenId, char, variants, produced) {
|
function summonRecord(ctx, tokenId, char, variants, produced, ownerTexts = []) {
|
||||||
const displayType = ctx.ac.shopStateTokenDict?.[tokenId]?.tokenDisplayType || null;
|
const displayType = ctx.ac.shopStateTokenDict?.[tokenId]?.tokenDisplayType || null;
|
||||||
const owners = Object.keys(variants);
|
const owners = Object.keys(variants);
|
||||||
const first = variants[owners[0]];
|
const first = variants[owners[0]];
|
||||||
const ownerRange = /只能部署在\S*攻击范围内/.test(stripRich(first.trait.desc) || '');
|
const text = stripRich(first.trait.desc) || '';
|
||||||
|
const ownerRange = /只能部署在\S*攻击范围内/.test(text) || ownerTexts.some((d) => /可以在攻击范围内(?:的[^,。;]*?)?(?:部署|使用)/.test(stripRich(d) || ''));
|
||||||
|
const ownerRangeOutside = /部署在[^,。;]*攻击范围外/.test(text);
|
||||||
|
const rangedTilesOnly = /仅可以部署在[^,。;]*远程位/.test(text);
|
||||||
const shows = (list) => Array.isArray(list) && list.includes('display');
|
const shows = (list) => Array.isArray(list) && list.includes('display');
|
||||||
const displayed = Object.values(variants).some((v) => shows(v.sources) || Object.values(v.bySkill || {}).some((a) => shows(a.sources)));
|
const displayed = Object.values(variants).some((v) => shows(v.sources) || Object.values(v.bySkill || {}).some((a) => shows(a.sources)));
|
||||||
return {
|
return {
|
||||||
@@ -1546,6 +1554,7 @@ function summonRecord(ctx, tokenId, char, variants, produced) {
|
|||||||
desc: stripRich(first.trait.desc), descRaw: first.trait.descRaw,
|
desc: stripRich(first.trait.desc), descRaw: first.trait.descRaw,
|
||||||
profession: char.profession, subProfessionId: char.subProfessionId, position: TOKEN_POSITION_CORRECTIONS[tokenId] ?? char.position,
|
profession: char.profession, subProfessionId: char.subProfessionId, position: TOKEN_POSITION_CORRECTIONS[tokenId] ?? char.position,
|
||||||
displayType, placeable: displayType !== 'HIDDEN' && produced && displayed, ownerRange,
|
displayType, placeable: displayType !== 'HIDDEN' && produced && displayed, ownerRange,
|
||||||
|
...(ownerRangeOutside ? { ownerRangeOutside: true } : null), ...(rangedTilesOnly ? { rangedTilesOnly: true } : null),
|
||||||
owners,
|
owners,
|
||||||
// Defaults = first owner's variant; per-owner data in variants[owner].
|
// Defaults = first owner's variant; per-owner data in variants[owner].
|
||||||
stats: first.stats, rangeGrid: first.rangeGrid, dmgType: first.dmgType, attackKind: first.attackKind,
|
stats: first.stats, rangeGrid: first.rangeGrid, dmgType: first.dmgType, attackKind: first.attackKind,
|
||||||
@@ -1620,14 +1629,15 @@ function buildDiyTokens(ctx, units, owned) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!owners.has(tokenId)) owners.set(tokenId, []);
|
if (!owners.has(tokenId)) owners.set(tokenId, []);
|
||||||
owners.get(tokenId).push({ key: `${charId}@${key}`, variant: v, produced });
|
const texts = form.talents.filter((t) => t.tokenKey === tokenId).map((t) => t.desc || '');
|
||||||
|
owners.get(tokenId).push({ key: `${charId}@${key}`, variant: v, produced, texts });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const out = {};
|
const out = {};
|
||||||
for (const tokenId of [...owners.keys()].sort(naturalCmp)) {
|
for (const tokenId of [...owners.keys()].sort(naturalCmp)) {
|
||||||
const list = owners.get(tokenId);
|
const list = owners.get(tokenId);
|
||||||
out[tokenId] = summonRecord(ctx, tokenId, charTable[tokenId], Object.fromEntries(list.map((o) => [o.key, o.variant])), list.some((o) => o.produced));
|
out[tokenId] = summonRecord(ctx, tokenId, charTable[tokenId], Object.fromEntries(list.map((o) => [o.key, o.variant])), list.some((o) => o.produced), list.flatMap((o) => o.texts));
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
@@ -1670,7 +1680,8 @@ function buildTokens(ctx, chess, tokenOwners, enemies) {
|
|||||||
}
|
}
|
||||||
const makes = (list) => (list || []).some((s) => s === 'talent' || s === 'skill');
|
const makes = (list) => (list || []).some((s) => s === 'talent' || s === 'skill');
|
||||||
const produced = owners.some((o) => makes(o.sources) || (o.skillAlts || []).some((a) => makes(a.sources)));
|
const produced = owners.some((o) => makes(o.sources) || (o.skillAlts || []).some((a) => makes(a.sources)));
|
||||||
out[tokenId] = summonRecord(ctx, tokenId, char, variants, produced);
|
const ownerTexts = owners.flatMap((o) => (chess[o.chessId]?.talents || []).filter((t) => t.tokenKey === tokenId).map((t) => t.desc || ''));
|
||||||
|
out[tokenId] = summonRecord(ctx, tokenId, char, variants, produced, ownerTexts);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 炎佑 (yanShip 6-member summon) — allied flying unit built from its enemy template.
|
// 炎佑 (yanShip 6-member summon) — allied flying unit built from its enemy template.
|
||||||
|
|||||||
Reference in new issue
Block a user