feedback2: merge WF — 钩索师 / 推击手 may stand on a 高台 (branch trait 可以放置于远程位; GitHub #32 item 4)

This commit is contained in:
sganggs committed 2026-10-03 23:39:47 +08:00
commit 2f5cc6edbc
13 files changed
+286 -21

No files matched your search

+1 -1
View File
@@ -1 +1 @@
{"chess_char_1_01_a":{"chessId":"chess_char_1_01_a","baseId":"chess_char_1_01_a","goldenId":"chess_char_1_01_b","isGolden":false,"tier":1,"identifier":0,"isHidden":false,"isDiy":false,"visible":true,"chessType":"PRESET","shopSortId":1,"charId":"char_498_inside","name":"隐现","appellation":"Insider","rarity":5,"profession":"SNIPER","subProfessionId":"fastshot","subProfessionName":"速射手","position":"RANGED","nationId":"laterano","bonds":["lateranoShip","swiftShip"],"garrisonIds":["garrison_16_a"],"price":2,"sellPrice":1,"upgradeNum":3,"upgradeChessId":"chess_char_1_01_b","status":{"phase":1,"level":55,"skillLevel":4,"equipLevel":0},"stats":{"maxHp":1123,"atk":399,"def":140,"res":0,"cost":15,"blockCnt":1,"bat":1,"aspd":100,"respawnTime":80,"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":"3-3","rangeGrid":[[1,0],[1,1],[1,2],[1,3],[0,0],[0,1],[0,2],[0,3],[-1,0],[-1,1],[-1,2],[-1,3]],"dmgType":"phys","attackKind":"ranged","projectile":"arrow","canHitFly":true,"targetPriority":"fly","trait":{"desc":"优先攻击空中单位","descRaw":"优先攻击空中单位","bb":{},"bbStr":{},"rangeGrid":null},"skill":{"skillId":"skchr_inside_2","iconId":"skchr_inside_2","name":"“解决麻烦”","level":4,"desc":"攻击力+80%,攻击间隔略微缩短,优先攻击使用远程武器的敌人并且不容易成为敌人的攻击目标\n攻击装有14发弹药,打完后结束(可随时停止技能)","descRaw":"攻击力<@ba.vup>+80%</>,攻击间隔<@ba.vup>略微缩短</>,优先攻击使用远程武器的敌人并且不容易成为敌人的攻击目标\n<@ba.rem>攻击装有14发弹药,打完后结束(可随时停止技能)</>","skillType":"MANUAL","durationType":"AMMO","duration":0,"spType":"INCREASE_WITH_TIME","spCost":24,"initSp":8,"maxChargeTime":1,"increment":1,"bb":{"atk":0.8,"base_attack_time":-0.3,"attack@trigger_time":14},"bbStr":{},"rangeId":null,"rangeGrid":null,"prefabId":"skchr_inside_2","trigger":{"rule":"DEFAULT","rawRule":"DEFAULT","customRangeGrid":null},"index":1,"overrideTokenKey":null},"talents":[{"index":0,"name":"火力支援","desc":"在场停留20秒后,自身弹药类技能弹药上限+2","descRaw":"在场停留20秒后,自身弹药类技能弹药上限+2","bb":{"self_ammo":2,"duration":20},"bbStr":{},"rangeGrid":null,"tokenKey":null,"hidden":false,"fromModule":false}],"tokens":[],"module":{"id":"uniequip_002_inside","name":"“最初的惊喜”","type":"MAR-X","level":0,"active":false},"assets":{"avatar":"char_498_inside","portrait":"char_498_inside_1","spine":"char_498_inside","skillIcon":"skchr_inside_2","subProfIcon":"sub_fastshot_icon"},"skills":[{"skillId":"skchr_inside_1","iconId":"skchr_inside_1","name":"“不惹麻烦”","level":4,"desc":"每次攻击造成相当于攻击力185%的物理伤害\n攻击装有4发弹药,打完后结束(可随时停止技能)","descRaw":"每次攻击造成相当于攻击力<@ba.vup>185%</>的物理伤害\n<@ba.rem>攻击装有4发弹药,打完后结束(可随时停止技能)</>","skillType":"AUTO","durationType":"AMMO","duration":0,"spType":"INCREASE_WHEN_ATTACK","spCost":14,"initSp":0,"maxChargeTime":1,"increment":1,"bb":{"attack@atk_scale":1.85,"attack@trigger_time":4},"bbStr":{},"rangeId":null,"rangeGrid":null,"prefabId":"skchr_inside_1","trigger":{"rule":"DEFAULT","rawRule":"DEFAULT","customRangeGrid":null},"index":0,"overrideTokenKey":null,"isDefault":false},{"skillId":"skchr_inside_2","iconId":"skchr_inside_2","name":"“解决麻烦”","level":4,"desc":"攻击力+80%,攻击间隔略微缩短,优先攻击使用远程武器的敌人并且不容易成为敌人的攻击目标\n攻击装有14发弹药,打完后结束(可随时停止技能)","descRaw":"攻击力<@ba.vup>+80%</>,攻击间隔<@ba.vup>略微缩短</>,优先攻击使用远程武器的敌人并且不容易成为敌人的攻击目标\n<@ba.rem>攻击装有14发弹药,打完后结束(可随时停止技能)</>","skillType":"MANUAL","durationType":"AMMO","duration":0,"spType":"INCREASE_WITH_TIME","spCost":24,"initSp":8,"maxChargeTime":1,"increment":1,"bb":{"atk":0.8,"base_attack_time":-0.3,"attack@trigger_time":14},"bbStr":{},"rangeId":null,"rangeGrid":null,"prefabId":"skchr_inside_2","trigger":{"rule":"DEFAULT","rawRule":"DEFAULT","customRangeGrid":null},"index":1,"overrideTokenKey":null,"isDefault":true}]},"chess_char_1_01_b":{"chessId":"chess_char_1_01_b","baseId":"chess_char_1_01_a","goldenId":"chess_char_1_01_b","isGolden":true,"tier":1,"identifier":133,"isHidden":false,"isDiy":false,"visible":true,"chessType":"PRESET","shopSortId":1,"charId":"char_498_inside","name":"隐现","appellation":"Insider","rarity":5,"profession":"SNIPER","subProfessionId":"fastshot","subProfessionName":"速射手","position":"RANGED","nationId":"laterano","bonds":["lateranoShip","swiftShip"],"garriLine truncated
{"chess_char_1_01_a":{"chessId":"chess_char_1_01_a","baseId":"chess_char_1_01_a","goldenId":"chess_char_1_01_b","isGolden":false,"tier":1,"identifier":0,"isHidden":false,"isDiy":false,"visible":true,"chessType":"PRESET","shopSortId":1,"charId":"char_498_inside","name":"隐现","appellation":"Insider","rarity":5,"profession":"SNIPER","subProfessionId":"fastshot","subProfessionName":"速射手","position":"RANGED","nationId":"laterano","bonds":["lateranoShip","swiftShip"],"garrisonIds":["garrison_16_a"],"price":2,"sellPrice":1,"upgradeNum":3,"upgradeChessId":"chess_char_1_01_b","status":{"phase":1,"level":55,"skillLevel":4,"equipLevel":0},"stats":{"maxHp":1123,"atk":399,"def":140,"res":0,"cost":15,"blockCnt":1,"bat":1,"aspd":100,"respawnTime":80,"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":"3-3","rangeGrid":[[1,0],[1,1],[1,2],[1,3],[0,0],[0,1],[0,2],[0,3],[-1,0],[-1,1],[-1,2],[-1,3]],"dmgType":"phys","attackKind":"ranged","projectile":"arrow","canHitFly":true,"targetPriority":"fly","trait":{"desc":"优先攻击空中单位","descRaw":"优先攻击空中单位","bb":{},"bbStr":{},"rangeGrid":null},"skill":{"skillId":"skchr_inside_2","iconId":"skchr_inside_2","name":"“解决麻烦”","level":4,"desc":"攻击力+80%,攻击间隔略微缩短,优先攻击使用远程武器的敌人并且不容易成为敌人的攻击目标\n攻击装有14发弹药,打完后结束(可随时停止技能)","descRaw":"攻击力<@ba.vup>+80%</>,攻击间隔<@ba.vup>略微缩短</>,优先攻击使用远程武器的敌人并且不容易成为敌人的攻击目标\n<@ba.rem>攻击装有14发弹药,打完后结束(可随时停止技能)</>","skillType":"MANUAL","durationType":"AMMO","duration":0,"spType":"INCREASE_WITH_TIME","spCost":24,"initSp":8,"maxChargeTime":1,"increment":1,"bb":{"atk":0.8,"base_attack_time":-0.3,"attack@trigger_time":14},"bbStr":{},"rangeId":null,"rangeGrid":null,"prefabId":"skchr_inside_2","trigger":{"rule":"DEFAULT","rawRule":"DEFAULT","customRangeGrid":null},"index":1,"overrideTokenKey":null},"talents":[{"index":0,"name":"火力支援","desc":"在场停留20秒后,自身弹药类技能弹药上限+2","descRaw":"在场停留20秒后,自身弹药类技能弹药上限+2","bb":{"self_ammo":2,"duration":20},"bbStr":{},"rangeGrid":null,"tokenKey":null,"hidden":false,"fromModule":false}],"tokens":[],"module":{"id":"uniequip_002_inside","name":"“最初的惊喜”","type":"MAR-X","level":0,"active":false},"assets":{"avatar":"char_498_inside","portrait":"char_498_inside_1","spine":"char_498_inside","skillIcon":"skchr_inside_2","subProfIcon":"sub_fastshot_icon"},"skills":[{"skillId":"skchr_inside_1","iconId":"skchr_inside_1","name":"“不惹麻烦”","level":4,"desc":"每次攻击造成相当于攻击力185%的物理伤害\n攻击装有4发弹药,打完后结束(可随时停止技能)","descRaw":"每次攻击造成相当于攻击力<@ba.vup>185%</>的物理伤害\n<@ba.rem>攻击装有4发弹药,打完后结束(可随时停止技能)</>","skillType":"AUTO","durationType":"AMMO","duration":0,"spType":"INCREASE_WHEN_ATTACK","spCost":14,"initSp":0,"maxChargeTime":1,"increment":1,"bb":{"attack@atk_scale":1.85,"attack@trigger_time":4},"bbStr":{},"rangeId":null,"rangeGrid":null,"prefabId":"skchr_inside_1","trigger":{"rule":"DEFAULT","rawRule":"DEFAULT","customRangeGrid":null},"index":0,"overrideTokenKey":null,"isDefault":false},{"skillId":"skchr_inside_2","iconId":"skchr_inside_2","name":"“解决麻烦”","level":4,"desc":"攻击力+80%,攻击间隔略微缩短,优先攻击使用远程武器的敌人并且不容易成为敌人的攻击目标\n攻击装有14发弹药,打完后结束(可随时停止技能)","descRaw":"攻击力<@ba.vup>+80%</>,攻击间隔<@ba.vup>略微缩短</>,优先攻击使用远程武器的敌人并且不容易成为敌人的攻击目标\n<@ba.rem>攻击装有14发弹药,打完后结束(可随时停止技能)</>","skillType":"MANUAL","durationType":"AMMO","duration":0,"spType":"INCREASE_WITH_TIME","spCost":24,"initSp":8,"maxChargeTime":1,"increment":1,"bb":{"atk":0.8,"base_attack_time":-0.3,"attack@trigger_time":14},"bbStr":{},"rangeId":null,"rangeGrid":null,"prefabId":"skchr_inside_2","trigger":{"rule":"DEFAULT","rawRule":"DEFAULT","customRangeGrid":null},"index":1,"overrideTokenKey":null,"isDefault":true}]},"chess_char_1_01_b":{"chessId":"chess_char_1_01_b","baseId":"chess_char_1_01_a","goldenId":"chess_char_1_01_b","isGolden":true,"tier":1,"identifier":133,"isHidden":false,"isDiy":false,"visible":true,"chessType":"PRESET","shopSortId":1,"charId":"char_498_inside","name":"隐现","appellation":"Insider","rarity":5,"profession":"SNIPER","subProfessionId":"fastshot","subProfessionName":"速射手","position":"RANGED","nationId":"laterano","bonds":["lateranoShip","swiftShip"],"garriLine truncated
+1
View File
@@ -150,6 +150,7 @@ Top level: `{ season, seasonName, modes, economy, lpCapPerRound, bossOvertimeAft
| `name`, `appellation` | `"隐现"`, `"Insider"` | |
| `rarity` | `5` | stars 1–6 |
| `profession`, `subProfessionId`, `subProfessionName`, `position` | `"SNIPER"`, `"fastshot"`, `"速射手"`, `"RANGED"` | |
| `placement` | `"all"` | only on a MELEE chess whose branch trait (the character's own trait text, no module) reads 可以放置于远程位 — 钩索师 歌蕾蒂娅 / 崖心, 推击手 见行者, normal and elite: the prep placement also allows the ranged (高台) tiles (`server/match/board.js positionClass`); `position` stays MELEE for the battle (DESIGN §22.6). Absent otherwise |
| `nationId` | `"laterano"` | |
| `bonds[]` | `["lateranoShip","swiftShip"]` | bondIds (→ `bonds.json`) |
| `garrisonIds[]` | `["garrison_16_b"]` | 特质 (→ `garrisons.json`); first = displayed trait |
+11 -4
View File
@@ -131,7 +131,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).
- **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.
- 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; 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 branch whose own trait reads "可以放置于远程位" (钩索师 歌蕾蒂娅 / 崖心, 推击手 见行者: chess.json `placement` 'all'), which may use the ranged tiles too (the remake's reading, §22.6); 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).
- **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).
- **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; boss uses rows 0–5 × cols 0–20 (solo boss uses the left half only). Pathfinding never leaves the rect.
@@ -384,7 +384,7 @@ Per match: pick stage (weighted among the mode's `config.modes[m].stages`, weigh
### 6.6 AI players (bot.js)
Deterministic-ish heuristic player used for (a) AI teammates, (b) auto-play for disconnected humans (only auto-ready + auto-picks, not shopping, unless "AI 托管" is toggled), (c) tests. Strategy (§21.6): pick a band (weighted by starting LP; never one built around a bond the mode switches off — `gd.bandBondIds`, §21.26); each prep: take offers, sell dead bench weight, fill the board, complete affordable merges, level on the curve, then buy only merge progress / lineup upgrades worth more than the refreshes their price would buy (shop odds of completing held pairs), freeze for an unaffordable third copy (坎诺特 keeps 5 funds back); build around a committed focus core bond (a teammate's main bond on its strip counts against it; bonds the mode switches off never) + a second bond; value dealers by DPS vs the round's DEF / RES (an attack on every enemy in range counts double — 阵法术师 / 轰击术师), with the range the unit is deployed with; place by the exposure model + rehearsal on the server's deploy map (no 深水区; a tactician's 援军 within `PlayerState.summonRange`; `g.move` judges legality); items by effect (META §1.5: 突变细胞 on the least valuable normal single below 6阶, a cell left in temp gets a hand slot made for it, the operator its transformation gains is deployed by the next prep's lineup step like any owned unit — AI 托管 too; the buy loop's bench shed never sells a piece gained since the bot's last prep, §21.1); 机变 bounties by expected payout − expected LP loss over the offered cards; resolve temp — on a human seat under AI 托管 never by destroying an item while a bench operator can be sold for the slot —; ready. A bot's whole prep — shop decisions and layout planning (`botPrepBeginSteps`, 25–70 ms late in a 4-bot match), the layout rehearsal and `botPrepEndSteps` — runs in wall-clock slices of `botSliceMs` (8 ms) per scheduler callback on a real host (step generators that yield between whole actions, never with a transient board; the same actions in the same order, so decisions are unchanged; at once in virtual time).
Deterministic-ish heuristic player used for (a) AI teammates, (b) auto-play for disconnected humans (only auto-ready + auto-picks, not shopping, unless "AI 托管" is toggled), (c) tests. Strategy (§21.6): pick a band (weighted by starting LP; never one built around a bond the mode switches off — `gd.bandBondIds`, §21.26); each prep: take offers, sell dead bench weight, fill the board, complete affordable merges, level on the curve, then buy only merge progress / lineup upgrades worth more than the refreshes their price would buy (shop odds of completing held pairs), freeze for an unaffordable third copy (坎诺特 keeps 5 funds back); build around a committed focus core bond (a teammate's main bond on its strip counts against it; bonds the mode switches off never) + a second bond; value dealers by DPS vs the round's DEF / RES (an attack on every enemy in range counts double — 阵法术师 / 轰击术师), with the range the unit is deployed with; place by the exposure model + rehearsal on the server's deploy map (no 深水区; a tactician's 援军 within `PlayerState.summonRange`; a 钩索师 / 推击手 on the ground tiles, as a blocker — §22.6; `g.move` judges legality); items by effect (META §1.5: 突变细胞 on the least valuable normal single below 6阶, a cell left in temp gets a hand slot made for it, the operator its transformation gains is deployed by the next prep's lineup step like any owned unit — AI 托管 too; the buy loop's bench shed never sells a piece gained since the bot's last prep, §21.1); 机变 bounties by expected payout − expected LP loss over the offered cards; resolve temp — on a human seat under AI 托管 never by destroying an item while a bench operator can be sold for the slot —; ready. A bot's whole prep — shop decisions and layout planning (`botPrepBeginSteps`, 25–70 ms late in a 4-bot match), the layout rehearsal and `botPrepEndSteps` — runs in wall-clock slices of `botSliceMs` (8 ms) per scheduler callback on a real host (step generators that yield between whole actions, never with a transient board; the same actions in the same order, so decisions are unchanged; at once in virtual time).
---
@@ -1498,6 +1498,13 @@ TBD-22.4
TBD-22.5
### 22.6 钩索师 and 推击手 on 高台 (#32 item 4)
### 22.6 钩索师 and 推击手 on 高台 (#32 item 4) — `tools/build-data.mjs PLACE_ON_RANGED_RE / branchTraitText / validateAll`, chess.json `placement`, `match/board.js positionClass / basePositionClass`, `ui/gameLogic.js piecePosition`, `match/bot.js isBlocker / planLayoutSteps`, `tools/balance.mjs`
TBD-22.6
- **Seen**: public issue #32 item 4 — "哥蕾蒂娅无法部署到高台". 0.1.1 refused every 高台 tile for her (server `BAD_TILE`; the client lit no 高台 and said 近战单位只能部署在地面), and the same for 崖心 (钩索师) and 见行者 (推击手), normal and elite.
- **Official**: the branch trait of both 钩索师 chess (歌蕾蒂娅 `chess_char_4_12`, 崖心 `chess_char_2_03`) reads 「技能可以使敌人产生位移\n可以放置于远程位」, the 推击手 见行者's (`chess_char_3_07`) 「同时攻击阻挡的所有敌人\n可以放置于远程位」 — character_table `description` (their `trait` is null: the branch's own text), position MELEE; act2autochess `charChessDataDict` sets only status, bonds and garrisons for them. PRTS 新人入门 (branch table): 钩索师 and 推击手 "可部署在高台和地面". PRTS 卫戍协议/帮助 §战斗部署: "所有行动内远程干员可部署在近战位", and 部署效果 do not apply in this placement — "携带Y模组的教官仍无法部署至高台位". That 教官 ability is a module talent ("可以额外部署在远程位", battle_equip_table uniequip_003_pallas / uniequip_002_doberm / uniequip_002_rdoc, blackboard `buildable_type` 2), a deploy effect; the 钩索师 / 推击手 line is the branch trait itself — no talent, no blackboard, and every module of the three keeps it (HOK-X, HOK-Y, PUS-X). No Y-module 教官 is in the pool (诗怀雅 has INS-X). No source states the 钩索师 case for 卫戍协议 in so many words: **the remake reads the branch trait as still applying** [ASSUMED]; the old "melee never on ranged-only tiles" was itself an assumption (research 03 C2).
- **Cause**: build-data copied position MELEE and nothing read 可以放置于远程位; `board.js positionClass` mapped MELEE to 'melee' (melee tiles only), so `PlayerState._legal` refused the 高台 (`g.move` → `BAD_TILE`), and the client mirror (`gameLogic.js piecePosition` / `tileAllows`) agreed.
- **Now**: build-data sets `placement: 'all'` on a chess whose branch trait without module (`branchTraitText`: the trait candidate's text, else the description) contains 可以放置于远程位 — exactly 崖心, 见行者 and 歌蕾蒂娅, normal and elite (6 records, the only data change; `validateAll` allows it on a MELEE record only; module-only text never counts). `positionClass` returns 'all' for them, so the server takes them on every deployable tile — `g.move`, swaps, a merge's elite (`mergeTile`), the terrain re-check (`_evictIllegal`), invariants and audit —, and the client's `piecePosition` 'ALL' lights the 高台 while dragging; every other melee unit keeps the refusal and its text. The battle keeps position MELEE: on a ground tile they block as before; on a 高台 (`unit.ground` false) they block nothing and attack from there — the sim needed no change (act2 m01, one 萨卡兹枯朽前锋 on the lower lane, 40 s: 歌蕾蒂娅 on (10,4) facing it 2 hits, no block, on (9,5) 12 hits, blocking; 崖心 and 见行者 alike; no errors).
- **Bots** read the record's own position (`basePositionClass`): they still count these units as blockers (`isBlocker`; with the widened class they would not, and on act2 m01 the planner put 歌蕾蒂娅 and 崖心 on the 高台 as non-blockers, leaving 见行者 the only blocker on the road) and plan them on the ground tiles, as in 0.1.1 [ASSUMED]: offered the 高台, the planner still chose road tiles in the cases measured, but the lineup's blocker count assumes they block and a greedy 高台 pick could take a ranged dealer's tile — not benchmarked. `tools/balance.mjs` uses the same blocker test. Tokens are unchanged; the sim's automatic placements (the 突袭 landing tile, summon / device tiles) still treat them as melee, ground tiles only [ASSUMED].
- **Players notice**: 歌蕾蒂娅, 崖心 and 见行者 can be dragged onto a 高台 (PLAYING §4); there they attack but block nothing.
- Normative lines rewritten with it: §3 (placement), §6.6 (the bot); research 03 C2; DATA §2 (`placement`).
- Tests: `test/match/feedback1-placement.test.js` (#32-4: the data rule, cross-checked against character_table when the cache exists; `g.move` onto (10,4) / (11,4) / (12,4) of 战场#01(下半) for the six records; a 重装 refused with its text; ranged unchanged; client highlights = the server's tiles; swaps; the merge's elite on the 高台; the battle input and the sim on / off the 高台; bots), `test/match/board.test.js` (`positionClass` / `basePositionClass`), `test/ui/gameLogic.test.js` (the client mirror).
+1 -1
View File
@@ -62,7 +62,7 @@
## 4. 摆阵与朝向
- 从整备区把干员拖到棋盘:近战位 / 远程位高亮为可放置格。**水里不能部署**:战场#08(涨潮控制)中间的那条水池(深水区)和官方一样拒绝部署,任何干员和召唤物都放不上去;突袭盟约的干员再部署、战术家的援军自动选战术点时也只会落在岸上。松手后出现**方向轮盘**,向上 / 右 / 下 / 左滑动,棋盘上会用橙色条纹预览旋转后的攻击范围,松手确认;松在中心或点「✕ 点击取消」取消。
- 从整备区把干员拖到棋盘:近战位 / 远程位高亮为可放置格。**钩索师和推击手**(歌蕾蒂娅、崖心、见行者)的特性写着「可以放置于远程位」,地面和高台都能放;站在高台上时照常攻击,但不阻挡敌人。**水里不能部署**:战场#08(涨潮控制)中间的那条水池(深水区)和官方一样拒绝部署,任何干员和召唤物都放不上去;突袭盟约的干员再部署、战术家的援军自动选战术点时也只会落在岸上。松手后出现**方向轮盘**,向上 / 右 / 下 / 左滑动,棋盘上会用橙色条纹预览旋转后的攻击范围,松手确认;松在中心或点「✕ 点击取消」取消。
- 想只改朝向:把干员拖回它自己所在的格子再选方向。朝向会一直保留到下一回合。
- **点选按地上的方格**:点哪个方格就选中站在那个方格上的单位,点空方格不选任何单位(点到后一格干员画出来的头部,算作点了后面那一格)。作战中友方同样按方格;敌人在格子之间走动,点它的身体或脚下附近(0.6 格内)即可,飞行敌人点它的身体。
- 拖动时模型一直在指针 / 手指下(脚在指针下方约半格,鼠标和触屏一样),**指针所在的格子**就是落点并高亮;装备拖到哪个格子,就配发给那个格子上的干员;在商店栏上松手则放回原位。
+1 -1
View File
@@ -1981,7 +1981,7 @@ Consequences for the current pool:
- Tokens marked `DEFAULT` in `shopStateTokenDict` are these placeable pieces: 医疗探机, 诅咒娃娃, 狼群, 斯卡蒂的海嗣, 流形. `HIDDEN` tokens exist only in battle.
- [ASSUMED] Summons do **not** use one of the 8 deploy slots, as in the base game. Keep this as a config flag.
- **Battle start:** every board unit deploys for free, in order **top→bottom, then left→right** (下半). In the boss round the order is mirrored for the right-side player. After that, knocked-out units redeploy automatically on their tile when the respawn timer is done and DP ≥ `cost` (DP starts at 10, +1/s, cap 99).
- **Ranged units may stand on melee tiles** ("所有行动内远程干员可部署在近战位"). Melee units may **not** use ranged-only tiles (HIGH `h`) [ASSUMED standard]. Maps: see 05.
- **Ranged units may stand on melee tiles** ("所有行动内远程干员可部署在近战位"). Melee units may **not** use ranged-only tiles (HIGH `h`) [ASSUMED standard] — except the branches whose own trait reads 可以放置于远程位 (钩索师 歌蕾蒂娅 / 崖心, 推击手 见行者; PRTS 新人入门 "可部署在高台和地面"). That line is the branch trait, not a 部署效果 like the Y-module 教官's talent "可以额外部署在远程位" (the 帮助: "携带Y模组的教官仍无法部署至高台位"), so it still applies in prep — the remake's reading [ASSUMED], GitHub issue #32 item 4, DESIGN §22.6. Maps: see 05.
### C3. Pool membership per match
The per-match ban removes every visible chess whose bonds are **all** in the disabled set. The rule is verified; see 01 Addendum A2. Elites of banned chess are also unobtainable. Hidden chess (17) stay out of the shop as §1.2 says.
+12 -3
View File
@@ -4,7 +4,8 @@
// light legal tiles while dragging; the server stays authoritative and may still refuse a move.
//
// 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,
// (LOW, buildable ALL/MELEE) — a 钩索师 / 推击手 ("可以放置于远程位", chess.json `placement` 'all') on any deploy
// tile, the 高台 included (piecePosition 'ALL'); 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 狼群,
@@ -989,10 +990,18 @@ export function placementContext({ priv, stage, editable, field = 'normal', getC
return { priv, pieces, boardAt, handAt, deploy, cap, count, field, editable: !!editable, getChess, getToken, getItem };
}
/** Deploy position ('MELEE'|'RANGED') of a chess/token piece, or null for items. */
/**
* Deploy position ('MELEE'|'RANGED'|'ALL') of a chess/token piece, or null for items. A MELEE operator whose branch
* trait reads "可以放置于远程位" (钩索师, 推击手: chess.json `placement` 'all') is 'ALL': any deployable tile, the 高台
* included (server/match/board.js positionClass; GitHub issue #32 item 4, DESIGN §22.6).
*/
export function piecePosition(ctx, piece) {
if (!isObj(piece)) return null;
if (piece.kind === 'chess') return ctx.getChess(piece.id)?.position === 'MELEE' ? 'MELEE' : 'RANGED';
if (piece.kind === 'chess') {
const rec = ctx.getChess(piece.id);
if (rec?.placement === 'all') return 'ALL';
return rec?.position === 'MELEE' ? 'MELEE' : 'RANGED';
}
// tokens: MELEE → ground only; RANGED / ALL → any deployable tile
if (piece.kind === 'token') return ctx.getToken(piece.id)?.position === 'MELEE' ? 'MELEE' : 'RANGED';
return null;
+19 -2
View File
@@ -4,7 +4,8 @@
// stage legend (data/stages.json `tiles[glyph]`: height + buildable) and the stage devices:
// 'melee' — LOW tile with buildable ALL/MELEE, or a 深水区 under an active 特制水上平台 (waterPlatform, "在水上建立可以
// 部署任意单位的平台"): melee AND ranged chess may stand here ("所有行动内远程干员可部署在近战位").
// 'ranged' — HIGH tile with buildable ALL/RANGED, or a tile under an active platform (射击台): ranged only.
// 'ranged' — HIGH tile with buildable ALL/RANGED, or a tile under an active platform (射击台): ranged only (and the
// 钩索师 / 推击手, placement 'all' below).
// (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
@@ -22,6 +23,9 @@
// (10–12, 8), which are '#' on the normal field. The result equals stages[id].deployTiles.bossLeft / bossRight
// mapped to board coordinates (test/match/playtest5-deploy.test.js).
//
// Chess follow their `position` (MELEE ⇒ melee tiles, RANGED ⇒ any deployable), except a MELEE operator whose branch
// trait reads "可以放置于远程位" (钩索师, 推击手: chess.json `placement` 'all' ⇒ any deployable tile, `positionClass`;
// GitHub issue #32 item 4, DESIGN §22.6).
// Tokens follow their own `position` (ALL ⇒ any deployable tile, MELEE ⇒ melee tiles, RANGED ⇒ any deployable).
// A token whose text reads "只能部署在召唤者攻击范围内" (tokens.json `ownerRange`: the tacticians' 援军 — 伺夜's 狼群,
// 缪尔赛思's 流形; PRTS 狼群 特性) also needs a tile of its owner's attack range: `ownerRangeKeys` = the owner's range
@@ -128,8 +132,21 @@ export function buildDeployMap(stage, { deviceOverrides = {}, tileOverrides = {}
return map;
}
/** Placement class of a chess / token record: 'melee' | 'ranged' | 'all'. */
/**
* Placement class of a chess / token record: 'melee' | 'ranged' | 'all' — its position class, widened to 'all' for a
* chess whose branch trait reads "可以放置于远程位" (钩索师, 推击手: chess.json `placement` 'all', DESIGN §22.6), which may
* stand on the ranged (高台) tiles too.
*/
export function positionClass(rec) {
return rec && rec.placement === 'all' ? 'all' : basePositionClass(rec);
}
/**
* The record's own position class ('melee' | 'ranged' | 'all'), without the placement widening — the class the battle
* plays (a MELEE operator still blocks on a ground tile; on a 高台 it blocks nothing): the bots' blocker test and the
* tiles they plan for it (bot.js).
*/
export function basePositionClass(rec) {
const p = rec && typeof rec.position === 'string' ? rec.position.toUpperCase() : 'ALL';
if (p === 'MELEE') return 'melee';
if (p === 'RANGED') return 'ranged';
+8 -4
View File
@@ -33,7 +33,9 @@
// the ground path but count as flyers) and weighted by their enemies; an exposure model (tile time × DPS of the
// covering units against the round's DEF / RES, blocker hold time, flyers only for anti-air) is maximized
// greedily — blockers first, then damage dealers by DPS, then healers — over every
// (legal tile, direction) pair of the server's deploy map (no 深水区): each unit's range grid — the one it is
// (legal tile, direction) pair of the server's deploy map (no 深水区; a 钩索师 / 推击手, which may also stand on
// a 高台 — placement 'all', DESIGN §22.6 —, is planned on the ground tiles like any blocker, as in 0.1.1
// [ASSUMED: the lineup counts it as a blocker]): each unit's range grid — the one it is
// deployed with, rangeRec (loadoutRecord attackRangeGrid) — is rotated per direction (DESIGN §3; RIGHT is tried first
// and kept on ties, so symmetric ranges and melee units whose front adds nothing stay facing the gates), so
// ranged units turn toward the enemy path tiles they cover best and blockers toward the road; on 气流 tiles
@@ -75,7 +77,7 @@
import { GEO } from '../../shared/constants.js';
import { deriveSeed } from '../sim/rng.js';
import { ASPD_MIN } from '../sim/constants.js';
import { freeSlot, countFree, legalTiles, canPlace, positionClass, parseKey, tileKey, FIELD, pieceDir, boardTileOf, BOSS_MIRROR_COL } from './board.js';
import { freeSlot, countFree, legalTiles, canPlace, positionClass, basePositionClass, parseKey, tileKey, FIELD, pieceDir, boardTileOf, BOSS_MIRROR_COL } from './board.js';
import { rotateOffset, normDir, mirrorDir, oppositeDir } from '../sim/dir.js';
import { itemKey } from './gamedata.js';
import { computeBonds } from './bondsMeta.js';
@@ -274,7 +276,8 @@ function tacticScore(m, ps, card) {
const chessRec = (m, id) => m.gd.chess(id);
const isHealer = (c) => !!c && (c.dmgType === 'heal' || c.attackKind === 'heal');
const isBlocker = (c) => !!c && positionClass(c) === 'melee' && (c.stats?.blockCnt ?? 1) > 0 && c.attackKind !== 'none';
// the record's own position: a 钩索师 / 推击手 (placement 'all', DESIGN §22.6) is a MELEE blocker like any other
const isBlocker = (c) => !!c && basePositionClass(c) === 'melee' && (c.stats?.blockCnt ?? 1) > 0 && c.attackKind !== 'none';
/** 近地悬浮 enemies walk a ground route but are air units (no block, anti-air only — DESIGN §19). */
const HOVER = new Set(HOVER_KEYS);
const hitsFly = (c) => !!c && !!c.canHitFly && !isHealer(c) && c.attackKind !== 'none';
@@ -946,7 +949,8 @@ export function* planLayoutSteps(m, ps, pieces, params = LAYOUT_PARAMS, { occupi
let bestV = -Infinity;
// a "只能部署在召唤者攻击范围内" summon (伺夜's 狼群, 缪尔赛思's 流形): only the tiles of its owner's range
const within = p.kind === 'token' && typeof ps.summonRange === 'function' ? ps.summonRange(p) : null;
for (const [r, c] of legalTiles(map, positionClass(r0))) {
// the record's own position class: a 钩索师 / 推击手 (placement 'all') stays on the ground tiles, where it blocks
for (const [r, c] of legalTiles(map, basePositionClass(r0))) {
const k = tileKey(r, c);
if (taken.has(k) || (within && !within.has(k))) continue;
const noise = m.rngBots() * 1e-6;
+13 -1
View File
@@ -1,7 +1,7 @@
// Board placement legality (stage legend), deploy cap, move semantics, placeable summons (DESIGN §3, research 03 C2).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { buildDeployMap, canPlace, legalTiles, tileKey } from '../../server/match/board.js';
import { buildDeployMap, canPlace, legalTiles, tileKey, positionClass, basePositionClass } from '../../server/match/board.js';
import { applyCard } from '../../server/match/choices.js';
import { ERR } from '../../shared/constants.js';
import { DATA, makeMatch, give, giveItem, checkInvariants, chessOfTier } from './harness.js';
@@ -55,6 +55,18 @@ test('glyph rules: melee only on LOW buildable ALL/MELEE; ranged also on HIGH/RA
assert.ok(canPlace(buildDeployMap(null), 'melee', 10, 5));
});
test('positionClass: the position class, widened to \'all\' by chess.json placement (钩索师 / 推击手, DESIGN §22.6); basePositionClass ignores it', () => {
assert.equal(positionClass({ position: 'MELEE' }), 'melee');
assert.equal(positionClass({ position: 'MELEE', placement: 'all' }), 'all');
assert.equal(basePositionClass({ position: 'MELEE', placement: 'all' }), 'melee');
assert.equal(positionClass({ position: 'RANGED' }), 'ranged');
assert.equal(positionClass({ position: 'ALL' }), 'all');
assert.equal(positionClass(null), 'all');
const map = buildDeployMap(DATA.stages.act2autochess_m01);
assert.ok(canPlace(map, positionClass(DATA.chess.chess_char_4_12_a), 10, 4), '歌蕾蒂娅 on the 高台');
assert.ok(!canPlace(map, positionClass(DATA.chess.chess_char_1_02_a), 10, 4), '角峰 (重装) not');
});
test('g.move: legality per chess position, BAD_TILE outside, deploy cap 8, swaps allowed at cap', () => {
const { h, m, ps } = prepMatch();
const meleeId = chessOfTier(1, MELEE).find((id) => m.pool.has(id));
+185 -2
View File
@@ -11,13 +11,21 @@
// 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.
// After 0.1.1, GitHub issue #32 item 4 "哥蕾蒂娅无法部署到高台" (DESIGN §22.6): the 钩索师 (歌蕾蒂娅, 崖心) and 推击手
// (见行者) branch trait reads "可以放置于远程位" (character_table; PRTS 新人入门 "可部署在高台和地面"): chess.json
// `placement` 'all' lets them stand on a 高台 too — server, client highlights, swaps, merges —; the battle keeps position
// MELEE (on a 高台 they attack but block nothing); the bots keep planning them on the ground [ASSUMED].
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 { existsSync, readFileSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { buildDeployMap, canPlace as boardCanPlace, legalTiles, positionClass, basePositionClass, tileKey, ownerRangeKeys } from '../../server/match/board.js';
import { ERR } from '../../shared/constants.js';
import { DATA, makeMatch, give, giveItem, checkInvariants, chessOfTier } from './harness.js';
import { botPrep, planLayout } from '../../server/match/bot.js';
import { botPrep, planLayout, fieldModel } from '../../server/match/bot.js';
import { placementContext, canPlace as clientCanPlace, boardTargets, deployMap as clientDeployMap } from '../../public/js/ui/gameLogic.js';
import { makeBattle } from '../helpers/battleHarness.js';
const STAGE = 'act2autochess_m04';
const WATER = ['10,6', '11,6', '12,6'];
@@ -297,3 +305,178 @@ test('#9 bots place 狼群 / 流形 only inside their owner\'s range', () => {
}
assert.ok(placed > 0, 'the bots placed some summons');
});
// ---------------------------------------------------------------------------------------------------------------------
// GitHub #32 item 4 (after 0.1.1): 钩索师 / 推击手 on a 高台
const HIGH_STAGE = 'act2autochess_m01';
const HIGH = ['10,4', '11,4', '12,4']; // 战场#01(下半)'s ranged-only (高台) tiles
// 崖心 and 歌蕾蒂娅 (钩索师), 见行者 (推击手), normal and elite — 崖心 and 见行者 are hidden chess (not in the shop pool)
const ANY_TILE = ['chess_char_2_03_a', 'chess_char_2_03_b', 'chess_char_3_07_a', 'chess_char_3_07_b', 'chess_char_4_12_a', 'chess_char_4_12_b'];
const GLADIIA = 'chess_char_4_12_a';
const TANK = (c) => c.profession === 'TANK' && c.position === 'MELEE';
const CHAR_TABLE = join(dirname(fileURLToPath(import.meta.url)), '../..', '.cache', 'gamedata', 'excel', 'character_table.json');
const rc = (k) => k.split(',').map(Number);
const toHand = (m, ps, p) => move(m, p.uid, { area: 'hand', idx: ps.hand.findIndex((x) => x == null) });
test('#32-4 data: placement \'all\' on exactly the chess whose branch trait reads "可以放置于远程位" (MELEE 钩索师 / 推击手, normal and elite); no token', () => {
const branchTrait = (c) => (c.traitBase || c.trait)?.desc || ''; // an elite's trait without its module
assert.deepEqual(Object.values(DATA.chess).filter((c) => c.placement !== undefined).map((c) => c.chessId).sort(), ANY_TILE);
assert.deepEqual(Object.values(DATA.chess).filter((c) => branchTrait(c).includes('可以放置于远程位')).map((c) => c.chessId).sort(), ANY_TILE);
for (const id of ANY_TILE) {
const c = DATA.chess[id];
assert.equal(c.placement, 'all', id);
assert.equal(c.position, 'MELEE', `${id}: the battle keeps position MELEE`);
assert.ok(['hookmaster', 'pusher'].includes(c.subProfessionId), id);
assert.equal(positionClass(c), 'all');
assert.equal(basePositionClass(c), 'melee');
}
for (const t of Object.values(DATA.tokens)) assert.equal(t.placement, undefined, t.tokenId);
});
test('#32-4 data = the official character_table: a chess is flagged iff its character\'s own trait text (no module) reads 可以放置于远程位', { skip: !existsSync(CHAR_TABLE) && 'no .cache/gamedata (run node tools/build-data.mjs once)' }, () => {
const ct = JSON.parse(readFileSync(CHAR_TABLE, 'utf8'));
let checked = 0;
for (const c of Object.values(DATA.chess)) {
const ch = c.charId ? ct[c.charId] : null;
if (!ch) continue;
const own = [ch.description || '', ...(ch.trait?.candidates || []).map((x) => x.overrideDescripton || '')];
assert.equal(c.placement === 'all', own.some((t) => t.includes('可以放置于远程位')), c.chessId);
checked++;
}
assert.ok(checked > 200, `${checked} chess checked`);
});
test('#32-4 g.move: 歌蕾蒂娅 / 崖心 / 见行者 (normal and elite) onto every 高台 tile of 战场#01(下半) — accepted; a 重装 still refused, a ranged operator unchanged', () => {
const { m, ps } = prep(HIGH_STAGE);
assert.deepEqual([...ps.deployMap()].filter(([, cls]) => cls === 'ranged').map(([k]) => k).sort(), HIGH);
for (const id of ANY_TILE) {
const p = give(m, ps, id);
for (const k of HIGH) {
const [r, c] = rc(k);
assert.deepEqual(move(m, p.uid, board(r, c), 'DOWN'), { ok: true }, `${id} → ${k}`);
assert.equal(ps.board.get(k), p);
checkInvariants(m);
}
assert.deepEqual(move(m, p.uid, board(9, 3)), { ok: true }, `${id}: the ground stays legal`);
assert.deepEqual(toHand(m, ps, p), { ok: true });
}
const tank = give(m, ps, chessOfTier(1, TANK).find((x) => m.pool.has(x)));
const ranged = give(m, ps, chessOfTier(1, RANGED).find((x) => m.pool.has(x)));
for (const k of HIGH) {
const [r, c] = rc(k);
assert.equal(move(m, tank.uid, board(r, c)).error, ERR.BAD_TILE, `重装 → ${k}`);
}
assert.deepEqual(move(m, tank.uid, board(9, 3)), { ok: true }, 'the 重装 on the ground');
assert.deepEqual(move(m, ranged.uid, board(10, 4)), { ok: true }, 'ranged on the 高台');
assert.deepEqual(move(m, ranged.uid, board(9, 4)), { ok: true }, 'ranged on the ground');
checkInvariants(m);
m.dispose();
});
test('#32-4 the client mirrors it: the drag highlights of 歌蕾蒂娅 / 崖心 / 见行者 include the 高台 (= the server\'s legal tiles); the 重装 keeps "近战单位只能部署在地面"', () => {
const { m, ps } = prep(HIGH_STAGE);
const pieces = ANY_TILE.map((id) => give(m, ps, id));
const tank = give(m, ps, chessOfTier(1, TANK).find((x) => m.pool.has(x)));
const ranged = give(m, ps, chessOfTier(1, RANGED).find((x) => m.pool.has(x)));
const ctx = clientCtx(m, ps);
const map = ps.deployMap();
for (const p of [...pieces, tank, ranged]) {
const lit = boardTargets(ctx, p.uid).legal.map(([r, c]) => tileKey(r, c)).sort();
assert.deepEqual(lit, legalTiles(map, positionClass(DATA.chess[p.id])).map(([r, c]) => tileKey(r, c)).sort(), `${p.id}: highlights = the server's tiles`);
for (const k of HIGH) {
const [r, c] = rc(k);
const res = clientCanPlace(ctx, p.uid, board(r, c));
assert.equal(res.ok, p !== tank, `${p.id} → ${k}`);
assert.equal(lit.includes(k), p !== tank);
if (p === tank) assert.equal(res.reason, '近战单位只能部署在地面');
}
}
m.dispose();
});
test('#32-4 swaps: a 高台 歌蕾蒂娅 trades places with a ranged operator but not with a 重装 (it may not take the 高台); the client agrees', () => {
const { m, ps } = prep(HIGH_STAGE);
const glad = give(m, ps, GLADIIA);
const tank = give(m, ps, chessOfTier(1, TANK).find((x) => m.pool.has(x)));
const ranged = give(m, ps, chessOfTier(1, RANGED).find((x) => m.pool.has(x)));
assert.deepEqual(move(m, glad.uid, board(10, 4), 'DOWN'), { ok: true });
assert.deepEqual(move(m, tank.uid, board(9, 3)), { ok: true });
assert.deepEqual(move(m, ranged.uid, board(11, 4)), { ok: true });
let ctx = clientCtx(m, ps);
// 歌蕾蒂娅 onto the 重装 would send it to (10,4); the 重装 onto her would put it there itself: both refused
assert.equal(clientCanPlace(ctx, glad.uid, board(9, 3)).ok, false);
assert.equal(move(m, glad.uid, board(9, 3)).error, ERR.BAD_TILE);
assert.equal(clientCanPlace(ctx, tank.uid, board(10, 4)).ok, false);
assert.equal(move(m, tank.uid, board(10, 4)).error, ERR.BAD_TILE);
// onto the ranged operator on the next 高台 tile: both stay legal
assert.equal(clientCanPlace(ctx, glad.uid, board(11, 4)).ok, true);
assert.deepEqual(move(m, glad.uid, board(11, 4)), { ok: true });
assert.equal(ps.board.get('11,4'), glad);
assert.equal(ps.board.get('10,4'), ranged);
// on the ground she swaps with the 重装 like any melee operator
assert.deepEqual(move(m, glad.uid, board(12, 5)), { ok: true });
ctx = clientCtx(m, ps);
assert.equal(clientCanPlace(ctx, tank.uid, board(12, 5)).ok, true);
assert.deepEqual(move(m, tank.uid, board(12, 5)), { ok: true });
assert.equal(ps.board.get('9,3'), glad);
assert.equal(ps.board.get('12,5'), tank);
checkInvariants(m);
m.dispose();
});
test('#32-4 merge: the elite of a 高台 歌蕾蒂娅 takes her 高台 tile and facing (PlayerState._mergeChess, board.js mergeTile)', () => {
const { m, ps } = prep(HIGH_STAGE);
const a = give(m, ps, GLADIIA);
assert.deepEqual(move(m, a.uid, board(12, 4), 'DOWN'), { ok: true });
give(m, ps, GLADIIA);
// the third copy gained like any other (her bond may be banned in this match, so not through the shop)
assert.ok(ps.acquireChess(GLADIIA));
const elite = ps.board.get('12,4');
assert.equal(elite?.id, DATA.chess[GLADIIA].goldenId, 'the elite stands on the 高台');
assert.equal(elite.dir, 'DOWN');
checkInvariants(m);
m.dispose();
});
test('#32-4 battle: the board fields 歌蕾蒂娅 on (10,4); there she attacks the lane but blocks nothing, on the ground she blocks (position MELEE)', () => {
const { m, ps } = prep(HIGH_STAGE);
const glad = give(m, ps, GLADIIA);
assert.deepEqual(move(m, glad.uid, board(10, 4), 'DOWN'), { ok: true });
const u0 = ps.battleInput().units.find((u) => u.uid === glad.uid);
assert.deepEqual([u0.row, u0.col, u0.dir], [10, 4, 'DOWN']);
m.dispose();
for (const [r, c, dir, high] of [[10, 4, 'DOWN', true], [9, 5, 'RIGHT', false]]) {
const h = makeBattle({
stageId: HIGH_STAGE, seed: 3, timeLimit: 60, autoFinish: false,
units: [{ chessId: GLADIIA, row: r, col: c, dir, carryState: { sp: 0 } }],
enemies: [{ key: 'enemy_1422_lrsldr', route: 0, time: 1, mods: { hpMul: 50 } }],
});
const u = h.unit(GLADIIA);
let hits = 0;
let blocked = false;
h.b.on('damaged', (x) => { if (x.source === u) hits++; });
for (let i = 0; i < 30 * 30; i++) { h.step(1); if (u.blocking.length) blocked = true; }
assert.ok(u.deployed, `deployed on ${r},${c}`);
assert.equal(u.ground, !high, `${r},${c}: ground unit`);
assert.ok(hits > 0, `attacks from ${r},${c}`);
assert.equal(blocked, !high, high ? 'blocks nothing on the 高台' : 'blocks on the ground');
assert.equal(h.b.errors.length, 0);
}
});
test('#32-4 bots still count 歌蕾蒂娅 / 崖心 / 见行者 as blockers and plan them on the ground road, never on a 高台 [ASSUMED]', () => {
for (const seed of [11, 12]) {
const { m, ps } = prep(HIGH_STAGE, seed);
const pieces = ['chess_char_4_02_a', 'chess_char_4_12_a', 'chess_char_3_07_a', 'chess_char_2_03_a'].map((id) => give(m, ps, id));
const plan = planLayout(m, ps, pieces);
const map = ps.deployMap();
const road = fieldModel(m, ps).ground;
for (const p of pieces.slice(1)) {
const k = plan.get(p.uid);
assert.equal(map.get(k), 'melee', `seed ${seed}: ${p.id} on a ground tile (${k})`);
assert.ok(road.has(k), `seed ${seed}: ${p.id} on an enemy road tile (${k}), where it blocks`);
}
m.dispose();
}
});
+12
View File
@@ -208,6 +208,18 @@ describe('placement mirror (canPlace)', () => {
assert.equal(canPlace(ctx, m.uid, null).ok, false);
assert.equal(canPlace(ctx, m.uid, { area: 'temp', idx: 0 }).ok, false, 'no temp target');
});
test('a 钩索师 / 推击手 (chess.json placement all: "可以放置于远程位") may also use the high ground; a plain melee keeps the refusal', () => {
const glad = piece('chess_char_4_12_a'); // 歌蕾蒂娅 (钩索师)
const forcer = piece('chess_char_3_07_b'); // 见行者 (推击手), elite
const m = piece(MELEE);
const ctx = ctxFor(privWith({ hand: [glad, forcer, m] }));
for (const p of [glad, forcer]) {
for (const [row, col] of [[10, 4], [11, 4], [12, 4], [9, 3]]) assert.equal(canPlace(ctx, p.uid, { area: 'board', row, col }).ok, true, `${p.id} on ${row},${col}`);
const lit = boardTargets(ctx, p.uid).legal.map(([a, b]) => tileKey(a, b));
assert.equal(lit.length, STAGE.deployTiles.normal.melee.length + STAGE.deployTiles.normal.rangedOnly.length, `${p.id}: every deploy tile lit`);
}
assert.deepEqual(canPlace(ctx, m.uid, { area: 'board', row: 10, col: 4 }), { ok: false, code: 'BAD_TILE', reason: '近战单位只能部署在地面' });
});
test('not editable ⇒ nothing is legal', () => {
const m = piece(MELEE);
assert.equal(canPlace(ctxFor(privWith({ hand: [m] }), false), m.uid, { area: 'board', row: 9, col: 3 }).code, 'WRONG_PHASE');
+3 -2
View File
@@ -51,7 +51,7 @@ import { getData } from '../server/data.js';
import { buildNormalWave, buildBossWave } from '../server/match/waves.js';
import { arrange } from '../server/match/bot.js';
import { computeBonds } from '../server/match/bondsMeta.js';
import { positionClass } from '../server/match/board.js';
import { basePositionClass } from '../server/match/board.js';
import { pairPlayers, bossPoolHp, SharedBossPool } from '../server/match/finalAssault.js';
import { planUnite, uniteBattleOpts, uniteSurvivors } from '../server/match/unite.js';
import { createRng, deriveSeed } from '../server/sim/rng.js';
@@ -92,7 +92,8 @@ const frac = (x, rng) => Math.max(0, Math.floor(x + rng()));
// ---- chess helpers ----------------------------------------------------------------------------------------------
const isHealer = (c) => !!c && (c.dmgType === 'heal' || c.attackKind === 'heal');
const isBlocker = (c) => !!c && positionClass(c) === 'melee' && (c.stats?.blockCnt ?? 1) > 0 && c.attackKind !== 'none' && !isHealer(c);
// the record's own position (bot.js isBlocker): a 钩索师 / 推击手 (placement 'all', DESIGN §22.6) still blocks
const isBlocker = (c) => !!c && basePositionClass(c) === 'melee' && (c.stats?.blockCnt ?? 1) > 0 && c.attackKind !== 'none' && !isHealer(c);
const hitsFly = (c) => !!c && !!c.canHitFly && !isHealer(c) && c.attackKind !== 'none';
const isDealer = (c) => !!c && !isHealer(c) && c.attackKind !== 'none';
+19
View File
@@ -724,6 +724,21 @@ function hasE2Art(ctx, charId, kind) {
return (ctx.charTable[charId]?.phases?.length || 0) >= 3;
}
/**
* The branch-trait line that lets a MELEE operator stand on the ranged (高台) tiles too: 钩索师 "技能可以使敌人产生位移\n
* 可以放置于远程位" and 推击手 "同时攻击阻挡的所有敌人\n可以放置于远程位" (character_table description; PRTS 新人入门:
* both branches "可部署在高台和地面"). Chess records get `placement: 'all'` (server/match/board.js positionClass); the
* position stays MELEE for the battle. Only the no-module trait counts: the Y-module 教官's "可以额外部署在远程位" is a
* module talent (buildable_type), a 部署效果 the mode switches off in the prep placement (PRTS 卫戍协议/帮助 §战斗部署
* "携带Y模组的教官仍无法部署至高台位"). DESIGN §22.6.
*/
const PLACE_ON_RANGED_RE = /可以放置于远程位/;
/** The branch trait text of a character at (phase, level), without module parts (traitRecord's template). */
function branchTraitText(char, phase, level) {
return bestCandidate(char.trait?.candidates, phase, level)?.overrideDescripton || char.description || '';
}
/**
* Build data/chess.json: every chess (normal + golden) of the season, keyed by chessId, with its loadout
* choices (DESIGN §16: `skills[]`; golden `modules[]` + `statsBase`/`traitBase`/`talentsBase`).
@@ -788,6 +803,8 @@ function buildChess(ctx) {
rec.subProfessionName = uniequip.subProfDict?.[char.subProfessionId]?.subProfessionName || null;
rec.position = char.position;
rec.nationId = char.nationId || null;
// 可以放置于远程位 (钩索师, 推击手): the prep placement also allows the ranged tiles (DESIGN §22.6)
if (PLACE_ON_RANGED_RE.test(branchTraitText(char, phase, level))) rec.placement = 'all';
// Module (only active on golden chess: equipLevel > 0).
const modId = shop.defaultUniEquipId || null;
@@ -3090,6 +3107,8 @@ function validateAll(f) {
// DESIGN §16 loadout choices
if (!Array.isArray(c.skills) || c.skills.filter((s) => s.isDefault).length !== 1 || c.skills.find((s) => s.isDefault)?.skillId !== c.skill?.skillId) err(`chess ${c.chessId}: skills[] without exactly one default = skill`);
if (c.modules && (c.modules.filter((m) => m.isDefault).length !== (c.module?.active ? 1 : 0) || !c.statsBase || !c.traitBase || !c.talentsBase)) err(`chess ${c.chessId}: inconsistent module choices`);
// DESIGN §22.6: only a MELEE operator is widened to every deployable tile
if (c.placement !== undefined && (c.placement !== 'all' || c.position !== 'MELEE')) err(`chess ${c.chessId}: placement ${c.placement} on position ${c.position}`);
}
// the deliberate trigger deviations (DESIGN §21.29) still override an official TAKE_DAMAGE row, on the normal chess
// and its elite alike