feedback3: merge WJ — 高台 only for elite 歌蕾蒂娅 with HOK-Y, live prep watch, silenced 折射, grant toasts, emote timeout glyph
This commit is contained in:
commit
c9dd2a6392
41 files changed
+700
-216
No files matched your search
+1
-1
@@ -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
-1
@@ -150,7 +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 |
|
||||
| `placement` | | not stored. A MELEE chess may stand on a 高台 only when it is elite 歌蕾蒂娅 (`char_474_glady`, `isGolden`) carrying HOK-Y 淡金坠饰 (`uniequip_003_glady`), decided at place time from the loadout (`shared/highGround.js`, `board.js placeClass`). The trait 「可以放置于远程位」 is not read. Owner's decision 2026-10-04; §22.6's `placement: "all"` is gone. `position` stays MELEE for the battle |
|
||||
| `nationId` | `"laterano"` | |
|
||||
| `bonds[]` | `["lateranoShip","swiftShip"]` | bondIds (→ `bonds.json`) |
|
||||
| `garrisonIds[]` | `["garrison_16_b"]` | 特质 (→ `garrisons.json`); first = displayed trait |
|
||||
|
||||
+2
-2
@@ -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 — 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).
|
||||
- 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 elite 歌蕾蒂娅 carrying module HOK-Y 淡金坠饰 (`uniequip_003_glady`), who may use the ranged tiles too (owner's decision 2026-10-04; the branch trait 「可以放置于远程位」 is not read, §22.6 reversed); 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.
|
||||
@@ -561,7 +561,7 @@ Audio: autochess BGM per phase, UI SFX (buy/sell/refresh/level/merge/ready/timer
|
||||
|
||||
**Solo pause (official `PauseUp` / `ResumeUp`; `Match.setPause`).** C→S `g.pause { on }`: solo matches only (co-op → `WRONG_PHASE 'co-op battles never pause'`), and `on: true` only while a battle runs (COMBAT / FINAL_ASSAULT / HIDDEN_CORE with a live field, not once the Final Assault is ending — else `WRONG_PHASE 'no battle running'`); `on: false` is always accepted. While `m.public.paused` is true everything of the battle is frozen: the field clock (`b.start elapsed`, the boss plausibility budgets and the overtime drain), the authority deadline and the server-run release timers, the boss clock, the HUD `deadline` / `overtimeAt` (shifted by the paused time on resume) and the server-run pacers (FieldRunner / HeadlessPacer skip their intervals); the browser's runner stops its local clock. In solo battles the top bar shows a 暂停 / 继续作战 button (`ui/hud.js PauseButton`, `ui/matchStatus.js pauseAvailable`; `Space` toggles it), a paused overlay offers 继续作战 / 放弃模拟, and the HUD clocks freeze at the pause moment. A disconnect or leave resumes (the server takes the field over — nobody is left to resume it); the battle phase ending drops the pause.
|
||||
|
||||
**Spectating (research 09 §3.1 / §6.3).** No live spectating while your own normal battle runs (`g.watch` → `WRONG_PHASE 'own battle running'`; the UI toasts "作战中无法查看队友"). After your own battle ends you see "⌛ 作战结束,等待队友完成作战" with each teammate's live progress (`m.public.fields[].progress`); tapping a teammate's avatar expands **前往查看** → `g.watch` → `b.start` (display replica fast-forwarded to the field clock); the own row shows **返回战场**. Prep: the same flow shows the teammate's board (`m.field` with `prep: true`, unchanged). 联防: every human receives the unite spec (helpers + observers). Final Assault: each human sees its own pair field; the other pair → `BAD_TARGET 'other group hidden'`. In 联防/FA fields the ‹ › pill switches the camera LEFT half / 全景 / RIGHT half ("你自己" / "👁 name" / "全景" / "无人在家"); there is **no** view switcher during normal combat. Eliminated players start each combat phase on the first field (auto-observe) and may watch any field.
|
||||
**Spectating (research 09 §3.1 / §6.3).** No live spectating while your own normal battle runs (`g.watch` → `WRONG_PHASE 'own battle running'`; the UI toasts "作战中无法查看队友"). After your own battle ends you see "⌛ 作战结束,等待队友完成作战" with each teammate's live progress (`m.public.fields[].progress`); tapping a teammate's avatar expands **前往查看** → `g.watch` → `b.start` (display replica fast-forwarded to the field clock); the own row shows **返回战场**. Prep: the same flow shows the teammate's board (`m.field` with `prep: true`) and sends it again when that board changes (GitHub #87). 联防: every human receives the unite spec (helpers + observers). Final Assault: each human sees its own pair field; the other pair → `BAD_TARGET 'other group hidden'`. In 联防/FA fields the ‹ › pill switches the camera LEFT half / 全景 / RIGHT half ("你自己" / "👁 name" / "全景" / "无人在家"); there is **no** view switcher during normal combat. Eliminated players start each combat phase on the first field (auto-observe) and may watch any field.
|
||||
|
||||
**Client runner.** `public/js/battle/runner.js` owns the local Battles: fixed-step clock at the battle speed (60 ticks per real second at 2×, at most `max(8, 4·speed)` ticks per frame, bounded fast-forward of ≤ 240 ticks per frame when far behind; a new battle is caught up silently before it is shown), feeds the existing renderer through the same `snapshot()`/`drainEvents()` wire formats every frame (`view.setLocalFeed` shrinks the interpolation buffer to ~2 frames), publishes the field meta (m.field shape + `local`, `battleId`, `players`, `sides`, `speed`) into `store.match.field` and its state into `store.match.battle` (with `leaks: { [fieldId]: counted leaks so far }` of every normal field it simulates and, while the 联防 field is on screen, `uniteLeft` per leaker (§20.6), authoritative or display replica — the settle rule's count, `/sim/spec.js battleProgress`; published in the frame a count changes: the live LP of §17.5), sends `b.progress`/`b.result` when authoritative (results retried once on timeout; a result lost with the socket — the request failed `DISCONNECTED` / `OFFLINE` or timed out twice — is kept and sent again when the session is back online and whenever a `b.start` still names that finished battle `authoritative: true`; the server answers a duplicate `ok` and changes nothing; a server refusal is final), follows `m.public.paused` (solo pause: every local clock stands still, resume shifts it by the paused time), handles `b.pool`/`b.end`, fast-forwards on reconnect/observe, and survives tab backgrounding (a 250 ms interval pump keeps authoritative battles on their clock while hidden; the server deadline + takeover cover extreme cases). Catch-up frames keep the state events and every enemy form fx (`keepsState`); while hidden, the battle on screen holds them for the first frame back (`hold`; past `HELD_MAX` the superseded status / skill toggles are dropped, `compactHeld`, and only a backlog still past it re-enters the view from the field meta), and a battle that ended while hidden delivers them when the tab is shown; a catch-up frame or a hidden step drains its events every `EV_SLICE` (15) ticks, each batch stamped with its own game time (`gt`), so a late form fx reaches the render engine as late as it is; the game screen's pre-entry buffer (`keepEarly`, also while a re-sent field meta re-enters the field on screen) and the render engine's event queue (`render/interp.js isCosmeticEvent`) never drop a form fx either (§21.4). Sim content errors are logged as console warnings (the server logs its own). The sim loader fetches the ten data files (one retry each) and fails when any is missing — a battle on partial data would report a plausible wrong result — so the next `b.start` retries and the server's deadline covers the field meanwhile. `public/js/battle/observe.js` holds the observing rules and labels.
|
||||
|
||||
|
||||
+5
-4
@@ -11,7 +11,8 @@ server/match/
|
||||
gamedata.js typed, defaulted view of data/*.json (config tunables with research defaults) + the balance layer
|
||||
(data/tuning.json, §3.1)
|
||||
pool.js SharedPool (copies per base chess, across players), per-match bans, copy-weighted rolls
|
||||
board.js placement legality from the stage legend on the deploy field (own board / boss half), slot helpers,
|
||||
board.js placement legality from the stage legend on the deploy field (own board / boss half); a 高台
|
||||
also takes elite 歌蕾蒂娅 with HOK-Y (shared/highGround.js), slot helpers,
|
||||
reading order (boardOrder), a merge's elite tile in deployment order (mergeTile)
|
||||
bondsMeta.js bond counting modes, tiers, 调和 / 独行 / 助力 / 绝技, layers
|
||||
effectsMeta.js MetaRegistry + EffectDispatcher + the handler ctx (this document, §2)
|
||||
@@ -212,7 +213,7 @@ no battle effect), it gets the settlement's `m.result`, and `handle()` answers o
|
||||
The rest of this section is the legacy server-run mode
|
||||
(`SP_COMBAT=server`):
|
||||
`g.watch { fieldId }`: any live field during COMBAT / 联防; while no battle field is up (PREP, drafts, SETTLE)
|
||||
`'n:<pid>'` returns a one-shot board view — during a battle phase an `'n:<pid>'` id must name a live field
|
||||
`'n:<pid>'` keeps the viewer scouting that board and pushes `prepFieldMeta` again when it changes (GitHub #87) — during a battle phase an `'n:<pid>'` id must name a live field
|
||||
(`BAD_TARGET 'no such field'` otherwise, and the viewer keeps its stream). In the
|
||||
最终攻势 / 隐秘核心 a player fighting in a boss field may only watch its own field ("两名参与者会处于同一个战场,但无法查看
|
||||
另一组队友的战场情况" → `BAD_TARGET 'other group hidden'`); eliminated / departed players spectate any field.
|
||||
@@ -709,8 +710,8 @@ same counts (its input's `bonds` come from `bondsView()`, PlayerState.battleInpu
|
||||
`ps.layers` (rules, `activatedLayers`) are untouched; SETTLE clears the pending gains as it adds them to `ps.layers`
|
||||
(once); the next round start clears them too.
|
||||
|
||||
`m.field` = `{ fieldId, kind, rect, stageId, units, live }`; during prep `g.watch 'n:<pid>'` returns a one-shot board
|
||||
view with `prep: true` (scouting a teammate).
|
||||
`m.field` = `{ fieldId, kind, rect, stageId, units, live }`; during prep `g.watch 'n:<pid>'` returns that board
|
||||
with `prep: true` and sends it again when the board changes (GitHub #87).
|
||||
|
||||
**`b.snap` / `b.ev` game time**: every frame is `{ t: '<type>', … }`, so the snapshot's game time (DESIGN `b.snap.t`)
|
||||
is sent as **`gt`** (game seconds); `b.ev` carries the same `gt`. The client reads `gt` (`render/interp.js frameTime`,
|
||||
|
||||
+1
-1
@@ -62,7 +62,7 @@
|
||||
|
||||
## 4. 摆阵与朝向
|
||||
|
||||
- 从整备区把干员拖到棋盘:近战位 / 远程位高亮为可放置格。**钩索师和推击手**的特性写着「可以放置于远程位」,地面和高台都能放(本赛季卡池里只有歌蕾蒂娅,崖心、见行者是隐藏干员);站在高台上时照常攻击,但不阻挡敌人。**水里不能部署**:战场#08(涨潮控制)中间的那条水池(深水区)和官方一样拒绝部署,任何干员和召唤物都放不上去;突袭盟约的干员再部署、战术家的援军自动选战术点时也只会落在岸上。松手后出现**方向轮盘**,向上 / 右 / 下 / 左滑动,棋盘上会用橙色条纹预览旋转后的攻击范围,松手确认;松在中心或点「✕ 点击取消」取消。
|
||||
- 从整备区把干员拖到棋盘:近战位 / 远程位高亮为可放置格。**只有精锐歌蕾蒂娅、并且装备了模组 HOK-Y「淡金坠饰」,才能放到高台上**;崖心、见行者、没晋升的歌蕾蒂娅、换了别的模组或者不装备模组,都只能放在地面。站在高台上时照常攻击,但不阻挡敌人。**水里不能部署**:战场#08(涨潮控制)中间的那条水池(深水区)和官方一样拒绝部署,任何干员和召唤物都放不上去;突袭盟约的干员再部署、战术家的援军自动选战术点时也只会落在岸上。松手后出现**方向轮盘**,向上 / 右 / 下 / 左滑动,棋盘上会用橙色条纹预览旋转后的攻击范围,松手确认;松在中心或点「✕ 点击取消」取消。
|
||||
- 想只改朝向:把干员拖回它自己所在的格子再选方向。朝向会一直保留到下一回合。
|
||||
- **点选按地上的方格**:点哪个方格就选中站在那个方格上的单位,点空方格不选任何单位(点到后一格干员画出来的头部,算作点了后面那一格)。作战中友方同样按方格;敌人在格子之间走动,点它的身体或脚下附近(0.6 格内)即可,飞行敌人点它的身体。
|
||||
- 拖动时模型一直在指针 / 手指下(脚在指针下方约半格,鼠标和触屏一样),**指针所在的格子**就是落点并高亮;装备拖到哪个格子,就配发给那个格子上的干员;在商店栏上松手则放回原位。
|
||||
|
||||
@@ -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 **从上到下>从左到右** (下半): down each column, the columns left to right (research 01 §4.3). 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] — 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.
|
||||
- **Ranged units may stand on melee tiles** ("所有行动内远程干员可部署在近战位"). Melee units may **not** use ranged-only tiles (HIGH `h`) — except elite 歌蕾蒂娅 carrying HOK-Y 淡金坠饰 (`uniequip_003_glady`). The branch trait 「可以放置于远程位」 (钩索师 / 推击手; PRTS 新人入门 "可部署在高台和地面") is on every variant and is **not** read (owner's decision 2026-10-04, reversing the §22.6 reading of GitHub #32 item 4). 崖心 has only HOK-X; 见行者, a normal record, any other module and no module stay on the ground. 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.
|
||||
|
||||
@@ -236,6 +236,8 @@
|
||||
.dfaction { display: inline-flex; align-items: center; gap: .04rem; font-size: .13rem; color: var(--amber); padding: 0 .08rem 0 .02rem; border: 1px solid rgba(246, 163, 41, .45); }
|
||||
.dfaction img { width: .22rem; height: .22rem; object-fit: contain; }
|
||||
.dabil { margin: 0; padding-left: .2rem; display: flex; flex-direction: column; gap: .05rem; font-size: .15rem; line-height: 1.6; color: var(--text-md); }
|
||||
.dabil li.is-off { color: var(--text-dim); }
|
||||
.dabil li.is-off .rt-term { color: var(--text-dim); border-bottom-color: transparent; }
|
||||
|
||||
/* ---- bond popup ---- */
|
||||
.bpop {
|
||||
|
||||
+83
-17
@@ -6,7 +6,10 @@
|
||||
// GAME_FILES) in the background once the player is in a room, and the match screen waits for them, so no text of the
|
||||
// match UI ever appears late. A transient failure (network error, HTTP 5xx) is retried twice (RETRY_DELAYS_MS); files
|
||||
// that stay unavailable (404, repeated failures, bad JSON) resolve to `null` and are reported once on the console — the
|
||||
// UI must degrade gracefully while data is being generated.
|
||||
// UI must degrade gracefully while data is being generated. The emote manifests (`local`, `assets`) are the exception:
|
||||
// one attempt is abandoned after ART_MANIFEST_TIMEOUT_MS (GitHub #99) and reported `missing` at once, so the 交流 button
|
||||
// draws its glyph instead of staying blank; a later retry that succeeds replaces the glyph. Other files stay `loading`
|
||||
// across their retries.
|
||||
//
|
||||
// Each file is indexed tolerantly so the getters work whether a file is
|
||||
// - an array of records carrying an id field (id / chessId / bondId / itemId / …),
|
||||
@@ -78,6 +81,16 @@ export function buildIndex(name, json) {
|
||||
/** Waits (ms) before retrying a data file whose download failed transiently (network error, HTTP 5xx / 408 / 429). */
|
||||
export const RETRY_DELAYS_MS = Object.freeze([600, 2000]);
|
||||
|
||||
/**
|
||||
* How long one attempt at an emote manifest (`local`, `assets`) may hang before it counts as a failure.
|
||||
* [ASSUMED] 8 s: long enough for a slow link, short enough that the 交流 button does not stay blank for the session
|
||||
* (GitHub #99). Other files are not on this clock.
|
||||
*/
|
||||
export const ART_MANIFEST_TIMEOUT_MS = 8000;
|
||||
|
||||
/** Manifests the emote button waits on. A hang here used to leave every cell blank (GitHub #99). */
|
||||
const ART_MANIFESTS = new Set(['local', 'assets']);
|
||||
|
||||
/**
|
||||
* A failed load worth retrying: the request itself failed (network error) or the server answered 5xx / 408 / 429 — not a
|
||||
* definite 4xx (the file is not there) nor a delivered file that is not valid JSON (`badJson`).
|
||||
@@ -92,14 +105,19 @@ const transientFailure = (err) => {
|
||||
* Create a data store bound to a fetch implementation (injectable for tests).
|
||||
* A file is downloaded once per page (the texts of the game are static data, never fetched again during a match —
|
||||
* user playtest #3 item 9); a transient failure is retried (RETRY_DELAYS_MS) while the file stays 'loading', so a
|
||||
* network hiccup does not leave the texts of a whole session missing.
|
||||
* @param {{ fetch?: typeof fetch, base?: string, retryDelays?: number[], wait?: (ms: number) => Promise<void> }} [opts]
|
||||
* network hiccup does not leave the texts of a whole session missing. `local` and `assets` are reported `missing` on
|
||||
* the first failure or timeout (the emote glyph) and stay that way through a retry; a later success is `ready`.
|
||||
* @param {{ fetch?: typeof fetch, base?: string, retryDelays?: number[], wait?: (ms: number) => Promise<void>, timeoutMs?: number, setTimeout?: typeof setTimeout, clearTimeout?: typeof clearTimeout }} [opts]
|
||||
* `timeoutMs` 0 turns the art-manifest clock off. `setTimeout` / `clearTimeout` let a test fire that clock.
|
||||
*/
|
||||
export function createDataStore(opts = {}) {
|
||||
const base = opts.base ?? '/data/';
|
||||
const doFetch = opts.fetch || ((...a) => globalThis.fetch(...a));
|
||||
const retryDelays = Array.isArray(opts.retryDelays) ? opts.retryDelays : RETRY_DELAYS_MS;
|
||||
const wait = opts.wait || ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
||||
const timeoutMs = opts.timeoutMs === undefined ? ART_MANIFEST_TIMEOUT_MS : Number(opts.timeoutMs);
|
||||
const armTimer = opts.setTimeout || ((fn, ms) => setTimeout(fn, ms));
|
||||
const disarmTimer = opts.clearTimeout || ((id) => clearTimeout(id));
|
||||
/** @type {Map<string, { status: 'loading'|'ready'|'missing', promise: Promise<any>, value: any, index: Map<string, any>|null }>} */
|
||||
const entries = new Map();
|
||||
const listeners = new Set();
|
||||
@@ -113,40 +131,88 @@ export function createDataStore(opts = {}) {
|
||||
|
||||
const urlFor = (name) => base + (DATA_FILES[name] || `${name}.json`);
|
||||
|
||||
/**
|
||||
* One attempt at a file. Art manifests reject with a transient `{ timeout: true }` when the clock fires; a response
|
||||
* that arrives after that is ignored (the retry is a new request). [ASSUMED]
|
||||
*/
|
||||
function readJson(name) {
|
||||
const run = async () => {
|
||||
const res = await doFetch(urlFor(name), { cache: 'no-cache' });
|
||||
if (!res || !res.ok) throw Object.assign(new Error(`HTTP ${res ? res.status : '???'}`), { status: res ? res.status : null });
|
||||
try {
|
||||
return await res.json();
|
||||
} catch (err) {
|
||||
throw Object.assign(err instanceof Error ? err : new Error(String(err)), { badJson: true });
|
||||
}
|
||||
};
|
||||
if (!ART_MANIFESTS.has(name) || !(timeoutMs > 0)) return run();
|
||||
let timer = null;
|
||||
let done = false;
|
||||
const finish = () => {
|
||||
if (done) return false;
|
||||
done = true;
|
||||
if (timer != null) disarmTimer(timer);
|
||||
return true;
|
||||
};
|
||||
const timed = new Promise((_, reject) => {
|
||||
timer = armTimer(() => {
|
||||
if (!finish()) return;
|
||||
reject(Object.assign(new Error('timeout'), { timeout: true }));
|
||||
}, timeoutMs);
|
||||
});
|
||||
const req = run().then(
|
||||
(json) => { finish(); return json; },
|
||||
(err) => { finish(); throw err; },
|
||||
);
|
||||
return Promise.race([req, timed]);
|
||||
}
|
||||
|
||||
function load(name) {
|
||||
if (typeof name !== 'string' || !/^[A-Za-z0-9_-]+$/.test(name)) return Promise.resolve(null);
|
||||
const cur = entries.get(name);
|
||||
if (cur) return cur.promise;
|
||||
const entry = { status: 'loading', promise: null, value: null, index: null };
|
||||
const art = ART_MANIFESTS.has(name);
|
||||
entry.promise = (async () => {
|
||||
let toldMissing = false;
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
try {
|
||||
const res = await doFetch(urlFor(name), { cache: 'no-cache' });
|
||||
if (!res || !res.ok) throw Object.assign(new Error(`HTTP ${res ? res.status : '???'}`), { status: res ? res.status : null });
|
||||
let json;
|
||||
try { json = await res.json(); } catch (err) { throw Object.assign(err instanceof Error ? err : new Error(String(err)), { badJson: true }); }
|
||||
entry.value = json;
|
||||
entry.value = await readJson(name);
|
||||
entry.status = 'ready';
|
||||
break;
|
||||
} catch (err) {
|
||||
// a transient failure is tried again (still 'loading'), unless the load was superseded meanwhile
|
||||
if (transientFailure(err) && attempt < retryDelays.length && entries.get(name) === entry) {
|
||||
const current = entries.get(name) === entry;
|
||||
// Decided before notify. Invalidating inside that notify leaves `again` true; the wait then sees
|
||||
// the superseded entry and stops, so this attempt does not fetch again.
|
||||
const again = transientFailure(err) && attempt < retryDelays.length && current;
|
||||
// Emote art: glyph now. Stay `missing` through the retry wait — flipping back to `loading` blanks the button.
|
||||
if (art && current && entry.status !== 'missing') {
|
||||
entry.value = null;
|
||||
entry.index = null;
|
||||
entry.status = 'missing';
|
||||
toldMissing = true;
|
||||
notify(name);
|
||||
}
|
||||
if (again) {
|
||||
await wait(retryDelays[attempt]);
|
||||
if (entries.get(name) === entry) continue;
|
||||
entry.status = 'missing'; // superseded by invalidate() meanwhile: the new load reports for itself
|
||||
break;
|
||||
}
|
||||
if (!warned.has(name)) {
|
||||
warned.add(name);
|
||||
console.warn(`[data] ${urlFor(name)} unavailable (${err?.message || err}); continuing without it`);
|
||||
if (current) {
|
||||
if (!warned.has(name)) {
|
||||
warned.add(name);
|
||||
console.warn(`[data] ${urlFor(name)} unavailable (${err?.message || err}); continuing without it`);
|
||||
}
|
||||
entry.value = null;
|
||||
entry.index = null;
|
||||
entry.status = 'missing';
|
||||
}
|
||||
entry.value = null;
|
||||
entry.status = 'missing';
|
||||
break;
|
||||
}
|
||||
}
|
||||
// A load superseded by invalidate() must not announce itself (its entry is no longer cached).
|
||||
if (entries.get(name) === entry) notify(name);
|
||||
// An art manifest already announced `missing` does not announce that same status again.
|
||||
if (entries.get(name) === entry && !(toldMissing && entry.status === 'missing')) notify(name);
|
||||
return entry.value;
|
||||
})();
|
||||
entries.set(name, entry);
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
// (left_prepare / *_boss_prepare) clear of the folded shop's band (public
|
||||
// issue #5: folding the shop did not grow the board)
|
||||
// view.setPrep(privateState, { editable, canPlace }) hand/temp/board pieces; editable enables drag & drop; a
|
||||
// new board piece flashes (fx.deploy); a merge's elite — on the tile of
|
||||
// new board piece flashes (fx.deploy), and so does a card that just arrived in the hand or temp; a merge's elite — on the tile of
|
||||
// the deployed copy it replaced, or on its bench slot — gets the
|
||||
// promotion cue instead (render/promote.js, fx.promote)
|
||||
// view.enterBattle(fieldMeta) m.field { fieldId, kind, rect, stageId, units: [UnitInfo] } — each
|
||||
@@ -975,6 +975,7 @@ export async function createFieldView(host, options = {}) {
|
||||
promotions.push({ uid: e.uid, id: e.piece.id, area: e.area, row: e.row ?? null, col: e.col ?? null, idx: e.idx ?? null, copies: promoFrom.get(e.uid).length });
|
||||
if (promotions.length > 20) promotions.shift();
|
||||
} else if (e.area === 'board' && prevBoard.size && !prevBoard.has(e.uid)) { v.onDeploy?.(); fx.deploy(v); }
|
||||
else if (before.length && (e.area === 'hand' || e.area === 'temp') && !before.some((g) => g.uid === e.uid)) fx.deploy(v);
|
||||
} else {
|
||||
const prevHome = v._home;
|
||||
const moved = !prevHome || prevHome.x !== w.x || prevHome.y !== w.y || prevHome.z !== w.z;
|
||||
|
||||
@@ -129,6 +129,8 @@ export const STATUS_ICON = Object.freeze({
|
||||
substitute: 'doll',
|
||||
// 禁疗 (sim status 'healFree': 史尔特尔's 余烬 — no heal reaches her until she leaves)
|
||||
healFree: 'healFree',
|
||||
// 折射 (sim buff 'ab:refraction', visible while the RES bonus is on). The tail of 'ab:refraction' hits this key.
|
||||
refraction: 'refraction',
|
||||
});
|
||||
|
||||
/** Keyword fallbacks for namespaced / content status keys ('ab:frost', 'reed2:scorch', 'skill:shotst_shred' …). */
|
||||
@@ -140,6 +142,18 @@ const STATUS_GUESS = [
|
||||
[/neural/i, 'neural'], [/necro|apopt|erosion/i, 'necrosis'],
|
||||
];
|
||||
|
||||
/**
|
||||
* The 折射 icon is not drawn while the unit is silenced: the RES bonus is already off (enemies.js refraction)
|
||||
* and the status must not keep looking active. Other icons stay.
|
||||
* @param {string} key a b.ev status key
|
||||
* @param {Set<string>|string[]|null} statuses
|
||||
*/
|
||||
export function statusIconSuppressed(key, statuses) {
|
||||
if (key !== 'ab:refraction' && key !== 'refraction') return false;
|
||||
if (!statuses) return false;
|
||||
return typeof statuses.has === 'function' ? statuses.has('silence') : Array.isArray(statuses) && statuses.includes('silence');
|
||||
}
|
||||
|
||||
/** Icon key (textures STATUS_KEYS) for a b.ev status key or flag name; null when it has no icon. */
|
||||
export function statusIconKey(key) {
|
||||
if (typeof key !== 'string' || !key) return null;
|
||||
|
||||
@@ -835,7 +835,7 @@ export function fxAtlas() {
|
||||
return _fx;
|
||||
}
|
||||
|
||||
export const STATUS_KEYS = ['stun', 'freeze', 'cold', 'stealth', 'shield', 'fragile', 'sleep', 'invuln', 'silence', 'slow', 'bind', 'fear', 'weaken', 'levitate', 'taunt', 'burn', 'neural', 'necrosis', 'blocked', 'skill', 'doll', 'healFree'];
|
||||
export const STATUS_KEYS = ['stun', 'freeze', 'cold', 'stealth', 'shield', 'fragile', 'sleep', 'invuln', 'silence', 'slow', 'bind', 'fear', 'weaken', 'levitate', 'taunt', 'burn', 'neural', 'necrosis', 'blocked', 'skill', 'doll', 'healFree', 'refraction'];
|
||||
|
||||
function drawStatusIcon(c, key, x, y, s) {
|
||||
const cx = x + s / 2, cy = y + s / 2;
|
||||
@@ -883,6 +883,13 @@ function drawStatusIcon(c, key, x, y, s) {
|
||||
c.fillStyle = '#d8e0ff'; c.font = 'bold 14px sans-serif'; c.textAlign = 'center'; c.textBaseline = 'middle'; c.fillText('Zz', cx, cy + 1);
|
||||
break;
|
||||
}
|
||||
case 'refraction': {
|
||||
// 折射: a split beam. Drawn only while the buff is on; units.js drops it under silence.
|
||||
disc('rgba(12,36,48,0.92)', '#7ee0ff');
|
||||
c.strokeStyle = '#d8f7ff'; c.lineWidth = 2;
|
||||
c.beginPath(); c.moveTo(cx - 8, cy + 6); c.lineTo(cx - 1, cy - 8); c.lineTo(cx + 8, cy + 6); c.moveTo(cx + 2, cy - 2); c.lineTo(cx + 9, cy - 8); c.stroke();
|
||||
break;
|
||||
}
|
||||
case 'silence': {
|
||||
disc('rgba(45,20,55,0.92)', '#e08bff');
|
||||
c.strokeStyle = '#f0c4ff'; c.lineWidth = 2.2;
|
||||
|
||||
@@ -68,7 +68,7 @@
|
||||
import { UF, ANIM } from '../../../shared/constants.js';
|
||||
import { SpineActor } from './spine.js';
|
||||
import { diamondTexture, shadowTexture, fxAtlas, tierChip, statusTexture, itemTexture, hudRings, ringArc, HUD_DISC, ELEMENT_RING } from './textures.js';
|
||||
import { COLORS, TIER_COLORS, ENEMY_FRAME, UNIT, PROJ, statusIconKey } from './style.js';
|
||||
import { COLORS, TIER_COLORS, ENEMY_FRAME, UNIT, PROJ, statusIconKey, statusIconSuppressed } from './style.js';
|
||||
import { drawCrate, rowDepthKey, ROW_KEY, deviceBoxOf, DEVICE_BOX } from './tiles.js';
|
||||
|
||||
const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v);
|
||||
@@ -1359,6 +1359,7 @@ export class UnitView {
|
||||
// a burst's lock ('burnBurst', 'neuralBurst' … — the 爆发冷却) is shown by the element gauge row under the bars
|
||||
// (b.snap `elem`); only a feed without gauges (an older recording) shows it as a status
|
||||
if (this.el && k.endsWith('Burst')) continue;
|
||||
if (statusIconSuppressed(k, this.statuses)) continue; // 折射 while silenced
|
||||
const icon = statusIconKey(k);
|
||||
// flag-driven states are authoritative (a stale 'stun' status must not outlive the flag)
|
||||
if (!icon || icon === 'stun' || icon === 'freeze' || icon === 'sleep' || icon === 'stealth' || icon === 'invuln') continue;
|
||||
|
||||
@@ -422,8 +422,9 @@ function MatchScreen() {
|
||||
view.pushEvents(early);
|
||||
audio.handleBattleEvents(early.filter((e) => e[0] === 'spawn'));
|
||||
}
|
||||
if (!earlySnap && (field.prep || !combat) && Array.isArray(field.units) && field.units.length) {
|
||||
// prep scouting (read-only teammate board): no snapshots follow, so place the units once
|
||||
if (!earlySnap && (field.prep || !combat) && Array.isArray(field.units)) {
|
||||
// prep scouting: no battle snapshots follow. A later m.field for this board (the teammate moved) re-enters
|
||||
// above and places the units again, including an empty board (GitHub #87).
|
||||
view.pushSnapshot({
|
||||
fieldId: field.fieldId, gt: 0,
|
||||
units: field.units.filter((u) => u && u.id != null).map((u) => [u.id, Number(u.x) || 0, Number(u.y) || 0, u.maxHp || 1, u.maxHp || 1, 0, 0, 0, 0]),
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
// Enemy-card ability lines (detailPanel EnemyDetail). A line whose data format is SILENCE
|
||||
// (折射: `<$ba.refraction>折射</>`, format "SILENCE") is off while the enemy is silenced: the card greys it.
|
||||
// The sim already drops the RES bonus; this is only the line.
|
||||
|
||||
/**
|
||||
* @param {any[]|null} abilities enemies.json abilities
|
||||
* @param {boolean} silenced live unit (`unitStatsEntry.silenced`)
|
||||
* @returns {Array<{ text: string, off: boolean }>}
|
||||
*/
|
||||
export function abilityRows(abilities, silenced) {
|
||||
const list = Array.isArray(abilities) ? abilities : [];
|
||||
return list.map((a) => {
|
||||
const text = typeof a === 'string' ? a : (a && (a.textRaw || a.text)) || '';
|
||||
const off = !!(silenced && a && typeof a === 'object' && a.format === 'SILENCE');
|
||||
return { text, off };
|
||||
});
|
||||
}
|
||||
@@ -35,6 +35,7 @@ import { html, Icon, TierChip, MicroLabel, Button, confirmDialog, useTicker } fr
|
||||
import { Img, RichText, UnitThumb, BondGlyph, GIcon } from './gameComponents.js';
|
||||
import { attackInterval, rangeGridBox, fmtNum, tileKey, chessLoadout, nextThreshold, bondTier, briefingBondTip, pieceBondIds, grantedBonds, morphPairings } from './gameLogic.js';
|
||||
import { chessPortraitUrl, skillIconUrl, skillRecordIconUrl, profIconUrl, subProfIconUrl, itemIconUrl, enemyIconUrl, tokenAvatarUrl, factionIconUrl, uiUrl, moduleTypeIconUrl } from './assetUrls.js';
|
||||
import { abilityRows } from './abilityLines.js';
|
||||
import { data } from '../data.js';
|
||||
import { attackRangeGrid } from '../../../shared/loadoutRecord.js';
|
||||
import { SKILL_SUMMON_START_DEPLOY } from '../../../shared/constants.js';
|
||||
@@ -522,7 +523,7 @@ function EnemyDetail({ enemy, snapHp, count, live = null }) {
|
||||
</div>
|
||||
${imm.length ? html`<p class="dhint"><${Icon} name="shield" />免疫:${imm.join('、')}</p>` : null}
|
||||
${Array.isArray(enemy.abilities) && enemy.abilities.length ? html`<${Section} title="能力" micro="ABILITIES">
|
||||
<ul class="dabil">${enemy.abilities.map((a, i) => html`<li key=${i}><${RichText} text=${typeof a === 'string' ? a : a.textRaw || a.text} /></li>`)}</ul>
|
||||
<ul class="dabil">${abilityRows(enemy.abilities, !!live?.silenced).map((a, i) => html`<li key=${i} class=${a.off ? 'is-off' : null}><${RichText} text=${a.text} /></li>`)}</ul>
|
||||
<//>` : enemy.descRaw || enemy.desc ? html`<${Section} title="说明"><${RichText} as="p" text=${enemy.descRaw || enemy.desc} class="dtext" /><//>` : null}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,8 +4,9 @@
|
||||
//
|
||||
// EmoteArt the emote picture: the local-client art (data/local-assets.json → emoticon/<dir>/<picId>) first, else
|
||||
// the copy setup downloads from the public mirror (data/assets.json → ui['emoticon/<dir>/<picId>'];
|
||||
// GitHub issue #42), each tried in turn when one fails to load; a neutral glyph when neither is there,
|
||||
// an empty box while a manifest is still loading.
|
||||
// GitHub issue #42), each tried in turn when one fails to load; a neutral glyph when neither is there.
|
||||
// An empty box only while a manifest is still in flight; a timeout or a failed manifest (GitHub #99)
|
||||
// uses that glyph, and a later success replaces it.
|
||||
// EmoteBubble the pop bubble beside the sender's avatar in the team panel (official emoji_bubble_bkg: a dark rounded
|
||||
// square with a tail pointing left + the icon only); pop-in, 3 s, fade. The parent keys it by the emote's
|
||||
// seq so a newer emote replaces the old one and pops again, and passes the arrival time (`at`) so a
|
||||
@@ -68,7 +69,7 @@ export function emoteArtUrl(id) {
|
||||
return emoteArtUrls(id)[0] || null;
|
||||
}
|
||||
|
||||
/** True while the local-art manifest or the asset manifest has not settled (EmoteArt shows an empty box meanwhile). */
|
||||
/** True while local or assets is still in flight (idle or loading). A timeout or a failure is `missing`: the glyph. */
|
||||
const artManifestsPending = () => ['local', 'assets'].some((n) => { const st = data.status(n); return st === 'loading' || st === 'idle'; });
|
||||
|
||||
/** Official emote UI sprite (ui/battle: emoji_bubble_bkg, emoji_bkg, emoji_cell_bkg, emoji_btn, emoji_btn_disable). */
|
||||
|
||||
@@ -4,8 +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) — a 钩索师 / 推击手 ("可以放置于远程位", chess.json `placement` 'all') on any deploy
|
||||
// tile, the 高台 included (piecePosition 'ALL'); ranged chess on `melee ∪ rangedOnly` (stages.json → deployTiles.normal,
|
||||
// (LOW, buildable ALL/MELEE) — elite 歌蕾蒂娅 carrying HOK-Y (shared/highGround.js, the player's loadout) 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 狼群,
|
||||
@@ -24,6 +24,7 @@
|
||||
import { GEO, PHASE, UF } from '../../../shared/constants.js';
|
||||
import { resolveLoadout, loadoutOptions, MODULE_NONE } from '../../../shared/protocol.js';
|
||||
import { resolveRecordLoadout, loadoutRecord, attackRangeGrid } from '../../../shared/loadoutRecord.js';
|
||||
import { meleeOnHighGround } from '../../../shared/highGround.js';
|
||||
import { rangeTiles, pieceDir } from './facing.js';
|
||||
import { layoutPen } from '../render/pen.js';
|
||||
import { BOSS_ROW_SHIFT, MAX_COL } from '../render/prepfield.js';
|
||||
@@ -1041,15 +1042,17 @@ export function placementContext({ priv, stage, editable, field = 'normal', getC
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 [ASSUMED]).
|
||||
* Deploy position ('MELEE'|'RANGED'|'ALL') of a chess/token piece, or null for items. Elite 歌蕾蒂娅 carrying HOK-Y
|
||||
* (the viewer's loadout, shared/highGround.js) is 'ALL': any deployable tile, the 高台 included
|
||||
* (server/match/board.js placeClass; owner's decision 2026-10-04). Every other MELEE chess is ground-only.
|
||||
*/
|
||||
export function piecePosition(ctx, piece) {
|
||||
if (!isObj(piece)) return null;
|
||||
if (piece.kind === 'chess') {
|
||||
const rec = ctx.getChess(piece.id);
|
||||
if (rec?.placement === 'all') return 'ALL';
|
||||
let moduleId = null;
|
||||
try { moduleId = resolveLoadout(ctx.priv?.loadout ?? null, rec, ctx.getChess)?.moduleId ?? null; } catch { moduleId = null; }
|
||||
if (meleeOnHighGround(rec, moduleId)) return 'ALL';
|
||||
return rec?.position === 'MELEE' ? 'MELEE' : 'RANGED';
|
||||
}
|
||||
// tokens: MELEE → ground only; RANGED / ALL → any deployable tile
|
||||
|
||||
+37
-7
@@ -781,8 +781,8 @@ export class Match {
|
||||
const list = [...this._privDirty];
|
||||
this._privDirty.clear();
|
||||
for (const ps of list) {
|
||||
if (ps.isBot || ps.left || !ps.connected) continue;
|
||||
this._sendPrivate(ps, false);
|
||||
if (!(ps.isBot || ps.left || !ps.connected)) this._sendPrivate(ps, false);
|
||||
this._notifyPrepScouts(ps);
|
||||
}
|
||||
}
|
||||
if (this._pubDirty || forcePublic) this._maybeSendPublic(forcePublic);
|
||||
@@ -970,6 +970,35 @@ export class Match {
|
||||
return { t: 'm.field', fieldId: `n:${ps.playerId}`, kind: 'normal', rect: { ...GEO.NORMAL_RECT }, stageId: this.stageId, units, prep: true, nextEnemies };
|
||||
}
|
||||
|
||||
/** Board signature of a prep scout view (units only: a shop or funds change is not a board change). */
|
||||
_prepScoutSig(ps) {
|
||||
const parts = [];
|
||||
for (const { r, c, piece } of boardOrder(ps.board)) {
|
||||
const items = piece.kind === 'chess' && Array.isArray(piece.items) ? piece.items.map((it) => `${it.uid}:${it.id}`).join(',') : '';
|
||||
parts.push(`${piece.uid}:${piece.id}@${r},${c}:${pieceDir(piece)}:${items}`);
|
||||
}
|
||||
return parts.join(';');
|
||||
}
|
||||
|
||||
/**
|
||||
* Push prepFieldMeta to whoever is scouting `ps` during prep (GitHub #87). `to` always receives the current board
|
||||
* (the player who just asked to watch); everyone scouting it receives a new `m.field` only when the board changed.
|
||||
* A live battle field owns the `n:<pid>` id, so this stays quiet once fields exist.
|
||||
*/
|
||||
_notifyPrepScouts(ps, { to = null } = {}) {
|
||||
if (!ps || !ps.alive || this.fields.length) return;
|
||||
const fid = `n:${ps.playerId}`;
|
||||
const watchers = this.watchersOf(fid);
|
||||
if (!watchers.length) return;
|
||||
const sig = this._prepScoutSig(ps);
|
||||
const changed = sig !== ps._prepScoutSig;
|
||||
ps._prepScoutSig = sig;
|
||||
const dest = changed ? watchers : (to ? [to] : []);
|
||||
if (!dest.length) return;
|
||||
const meta = this.prepFieldMeta(ps);
|
||||
for (const pid of dest) this.sendTo(pid, meta);
|
||||
}
|
||||
|
||||
_sendField(playerId, fieldId) {
|
||||
const f = this.fields.find((x) => x.fieldId === fieldId);
|
||||
if (f) {
|
||||
@@ -1053,14 +1082,15 @@ export class Match {
|
||||
return OK;
|
||||
}
|
||||
if (fieldId.startsWith('n:')) {
|
||||
// prep scouting — a one-shot board view — only while no battle field is up: during 各自行动 / 联防 / 最终攻势 /
|
||||
// 隐秘核心 an 'n:<pid>' id must name a live field (else the boss-group rule above could be bypassed, and the
|
||||
// viewer would stop receiving its own field's snapshots)
|
||||
// prep scouting, only while no battle field is up: during 各自行动 / 联防 / 最终攻势 / 隐秘核心 an 'n:<pid>' id
|
||||
// must name a live field (else the boss-group rule above could be bypassed, and the viewer would stop receiving
|
||||
// its own field's snapshots). The scout stays in `watchers` so a later board change pushes prepFieldMeta again
|
||||
// (GitHub #87); combat start clears the map and reassigns live fields, spectators included.
|
||||
if (this.fields.length) return fail(ERR.BAD_TARGET, 'no such field');
|
||||
const target = this.players.get(fieldId.slice(2));
|
||||
if (!target || !target.alive) return fail(ERR.BAD_TARGET);
|
||||
this.watchers.delete(ps.playerId);
|
||||
this.sendTo(ps.playerId, this.prepFieldMeta(target));
|
||||
this.watchers.set(ps.playerId, fieldId);
|
||||
this._notifyPrepScouts(target, { to: ps.playerId });
|
||||
return OK;
|
||||
}
|
||||
return fail(ERR.BAD_TARGET);
|
||||
|
||||
@@ -72,7 +72,7 @@
|
||||
|
||||
import { ERR, GEO, PHASE, layerGainRoom } from '../../shared/constants.js';
|
||||
import { checkLoadout, resolveLoadout } from '../../shared/protocol.js';
|
||||
import { FIELD, tileKey, parseKey, inField, canPlace, positionClass, boardOrder, freeSlot, pieceDir, parseDir, mergeTile, ownerRangeKeys } from './board.js';
|
||||
import { FIELD, tileKey, parseKey, inField, canPlace, placeClass, boardOrder, freeSlot, pieceDir, parseDir, mergeTile, ownerRangeKeys } from './board.js';
|
||||
import { attackRangeGrid, loadoutRecord, resolveRecordLoadout } from '../../shared/loadoutRecord.js';
|
||||
import { offsetTile } from '../sim/dir.js';
|
||||
import { computeBonds, bondList, bondSnapshot, activatedLayers, bondsWithGains, offBondCounts } from './bondsMeta.js';
|
||||
@@ -1009,7 +1009,8 @@ export class PlayerState {
|
||||
|
||||
_placementOf(piece) {
|
||||
const rec = piece.kind === 'token' ? this.gd.token(piece.id) : this.gd.chess(piece.id);
|
||||
return positionClass(rec);
|
||||
// elite 歌蕾蒂娅 + HOK-Y may use a 高台; the module is this player's loadout (owner's decision 2026-10-04)
|
||||
return placeClass(this, rec);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
|
||||
import { PHASE } from '../../shared/constants.js';
|
||||
import { collectViolations } from './invariants.js';
|
||||
import { mergeTile, pieceDir, canPlace, positionClass } from './board.js';
|
||||
import { mergeTile, pieceDir, canPlace, placeClass } from './board.js';
|
||||
import { pairPlayers, bossPoolHp, hiddenEligible } from './finalAssault.js';
|
||||
import { helperOrder } from './unite.js';
|
||||
import { BAND_TURN_SECONDS } from './Match.js';
|
||||
@@ -243,7 +243,7 @@ export function attachAudit(m, { invariants = true, limit = 200 } = {}) {
|
||||
if (ps.deployCount > deployed0) fail(`${id}: a merge of ${baseId} grew the deploy count ${deployed0} → ${ps.deployCount}`);
|
||||
// a pure read of the deploy field (Match.deployMapFor, as invariants.js): the audit must not refresh the cache
|
||||
const dmap = typeof m.deployMapFor === 'function' ? m.deployMapFor(ps) : ps.deployMap();
|
||||
const pos = positionClass(gd.chess(elite.id));
|
||||
const pos = placeClass(ps, gd.chess(elite.id));
|
||||
const want = mergeTile([...tiles.keys()].map((key) => ({ key })), (r, c) => canPlace(dmap, pos, r, c));
|
||||
if (want) {
|
||||
if (loc.area !== 'board' || loc.key !== want.key) fail(`${id}: the elite of ${baseId} went to ${loc.area} ${loc.key || ''}, expected the deployed copy's tile ${want.key}`);
|
||||
|
||||
+31
-10
@@ -4,8 +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 (and the
|
||||
// 钩索师 / 推击手, placement 'all' below).
|
||||
// 'ranged' — HIGH tile with buildable ALL/RANGED, or a tile under an active platform (射击台): ranged only (and elite
|
||||
// 歌蕾蒂娅 carrying HOK-Y, placeClass 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
|
||||
@@ -23,9 +23,10 @@
|
||||
// (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 [ASSUMED]: the branch trait read as no 部署效果, which the mode would switch off).
|
||||
// Chess follow their `position` (MELEE ⇒ melee tiles, RANGED ⇒ any deployable), except elite 歌蕾蒂娅 carrying module
|
||||
// HOK-Y 淡金坠饰 (uniequip_003_glady: `placeClass` / shared/highGround.js ⇒ any deployable tile). The branch trait
|
||||
// 「可以放置于远程位」 is not read (owner's decision 2026-10-04, reversing DESIGN §22.6): 崖心, 见行者, a normal record,
|
||||
// any other module and no module are ground-only. The equipped module is the player's loadout at place time.
|
||||
// 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
|
||||
@@ -35,6 +36,7 @@
|
||||
// `parseDir` validates an intent's optional direction.
|
||||
|
||||
import { GEO } from '../../shared/constants.js';
|
||||
import { meleeOnHighGround } from '../../shared/highGround.js';
|
||||
import { DEFAULT_DIR, isDir, rotateOffset } from '../sim/dir.js';
|
||||
import { BOSS_ROW_OFFSET, COLS } from '../sim/constants.js';
|
||||
|
||||
@@ -133,12 +135,31 @@ export function buildDeployMap(stage, { deviceOverrides = {}, tileOverrides = {}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Placement class of a chess / token record: 'melee' | 'ranged' | 'all'. Widened to 'all' only for elite 歌蕾蒂娅
|
||||
* carrying HOK-Y (`moduleId` = uniequip_003_glady, shared/highGround.js): she may stand on the ranged (高台) tiles.
|
||||
* Any other module, no module, and every other MELEE record stay 'melee'. `chess.json` has no `placement` field.
|
||||
* @param {object|null} rec
|
||||
* @param {string|null} [moduleId] equipped module; ignored for tokens and non-golden records
|
||||
*/
|
||||
export function positionClass(rec) {
|
||||
return rec && rec.placement === 'all' ? 'all' : basePositionClass(rec);
|
||||
export function positionClass(rec, moduleId = null) {
|
||||
return meleeOnHighGround(rec, moduleId) ? 'all' : basePositionClass(rec);
|
||||
}
|
||||
|
||||
/**
|
||||
* Placement class of a record under a player's loadout (the module `ps.loadoutFor` resolves, defaults included).
|
||||
* Tokens and records without a chess id ignore the loadout.
|
||||
* @param {{ loadoutFor?: (rec: object) => { moduleId?: string|null }|null }|null} ps
|
||||
*/
|
||||
export function placeClass(ps, rec) {
|
||||
if (!rec || !rec.chessId || !ps || typeof ps.loadoutFor !== 'function') return positionClass(rec);
|
||||
let moduleId = null;
|
||||
try {
|
||||
const lo = ps.loadoutFor(rec);
|
||||
moduleId = lo && typeof lo.moduleId === 'string' ? lo.moduleId : null;
|
||||
} catch {
|
||||
moduleId = null;
|
||||
}
|
||||
return positionClass(rec, moduleId);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+22
-12
@@ -33,9 +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 深水区; 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
|
||||
// (legal tile, direction) pair of the server's deploy map (no 深水区). A MELEE operator is planned on the ground
|
||||
// tiles, where it blocks — except elite 歌蕾蒂娅 carrying HOK-Y, who may also take a 高台 (placeClass; owner's
|
||||
// decision 2026-10-04). She stays a blocker in the lineup (basePositionClass). 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
|
||||
@@ -77,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, basePositionClass, parseKey, tileKey, FIELD, pieceDir, boardTileOf, BOSS_MIRROR_COL } from './board.js';
|
||||
import { freeSlot, countFree, legalTiles, canPlace, positionClass, placeClass, 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';
|
||||
@@ -276,7 +276,7 @@ function tacticScore(m, ps, card) {
|
||||
|
||||
const chessRec = (m, id) => m.gd.chess(id);
|
||||
const isHealer = (c) => !!c && (c.dmgType === 'heal' || c.attackKind === 'heal');
|
||||
// the record's own position: a 钩索师 / 推击手 (placement 'all', DESIGN §22.6) is a MELEE blocker like any other
|
||||
// the record's own position: a 钩索师 / 推击手 is a MELEE blocker like any other (a 高台 does not block)
|
||||
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);
|
||||
@@ -949,8 +949,13 @@ 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;
|
||||
// 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))) {
|
||||
// ground tiles for a MELEE blocker. placeClass 'all' is only elite 歌蕾蒂娅 + HOK-Y: she may stand on a 高台,
|
||||
// and when one of those tiles covers the enemy road she is planned there (owner 2026-10-04: the bot uses the 高台).
|
||||
const cls = p.kind === 'token' ? basePositionClass(r0) : placeClass(ps, m.gd.chess(p.id) || r0);
|
||||
const preferHigh = cls === 'all' && basePositionClass(r0) === 'melee';
|
||||
let bestHigh = null;
|
||||
let bestHighV = -Infinity;
|
||||
for (const [r, c] of legalTiles(map, cls)) {
|
||||
const k = tileKey(r, c);
|
||||
if (taken.has(k) || (within && !within.has(k))) continue;
|
||||
const noise = m.rngBots() * 1e-6;
|
||||
@@ -965,13 +970,18 @@ export function* planLayoutSteps(m, ps, pieces, params = LAYOUT_PARAMS, { occupi
|
||||
const v = layout.value() + noise;
|
||||
layout.units.pop();
|
||||
if (v > bestV) { bestV = v; best = [k, r, c, dir]; }
|
||||
if (preferHigh && map.get(k) === 'ranged' && [...u.cover].some((ck) => model.ground.has(ck)) && v > bestHighV) {
|
||||
bestHighV = v;
|
||||
bestHigh = [k, r, c, dir];
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!best) continue;
|
||||
taken.add(best[0]);
|
||||
layout.units.push(unitOf(r0, best[0], best[1], best[2], best[3], model));
|
||||
out.set(p.uid, best[0]);
|
||||
out.dirs.set(p.uid, best[3]);
|
||||
const pick = bestHigh || best;
|
||||
if (!pick) continue;
|
||||
taken.add(pick[0]);
|
||||
layout.units.push(unitOf(r0, pick[0], pick[1], pick[2], pick[3], model));
|
||||
out.set(p.uid, pick[0]);
|
||||
out.dirs.set(p.uid, pick[3]);
|
||||
yield;
|
||||
}
|
||||
return out;
|
||||
|
||||
@@ -412,6 +412,11 @@ export function offerLabel(gd, source) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Who a grantChess toast names: the same speaker as an offer label, or the choice card's name. */
|
||||
function grantSpeaker(gd, source) {
|
||||
return offerLabel(gd, source) || (source && source.kind === 'choice' && source.card && source.card.name) || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('./Match.js').Match} m
|
||||
* @param {import('./PlayerState.js').PlayerState} ps
|
||||
@@ -513,6 +518,16 @@ export function makeCtx(m, ps, source, hook, ev = null) {
|
||||
// "some effects fail when the cap is hit" (research 06 §7): by default a chess of the pool needs a free copy
|
||||
if (opts.requirePool !== false && m.pool.has(base) && m.pool.left(base) < 1) return null;
|
||||
const p = ps.acquireChess(id, { source: opts.source || source.key || 'effect', toTemp: !!opts.toTemp, fromPool: opts.fromPool !== false });
|
||||
// 「歌蕾蒂娅:获得斯卡蒂」 — every silent grantChess (a 特质, 余 SERVER_MOST_BOND, a band, an item, a choice).
|
||||
// opts.toast === false skips it. A caller that already says the same thing should pass that.
|
||||
if (p && opts.toast !== false) {
|
||||
const got = gd.chess(p.id);
|
||||
const name = got && got.name;
|
||||
if (name) {
|
||||
const who = grantSpeaker(gd, source);
|
||||
m.toast(ps, 'info', who ? `${who}:获得${name}` : `获得${name}`);
|
||||
}
|
||||
}
|
||||
return p ? view(p) : null;
|
||||
},
|
||||
grantItem: (itemId, opts = {}) => {
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
// match phase known; teamLp / boss pool within range; combat fields match the alive players
|
||||
|
||||
import { PHASE, BOND_LAYER_CAP } from '../../shared/constants.js';
|
||||
import { FIELD, canPlace, positionClass, parseKey } from './board.js';
|
||||
import { FIELD, canPlace, placeClass, positionClass, parseKey } from './board.js';
|
||||
import { computeBonds } from './bondsMeta.js';
|
||||
|
||||
const PHASES = new Set(Object.values(PHASE));
|
||||
@@ -111,7 +111,8 @@ export function collectViolations(m, { limit = 25 } = {}) {
|
||||
if (!(r >= FIELD.r0 && r <= FIELD.r1 && c >= FIELD.c0 && c <= FIELD.c1)) fail(`${id}: piece outside the board at ${k}`);
|
||||
if (p.kind === 'item') { fail(`${id}: item ${p.id} stands on the board`); continue; }
|
||||
const rec = p.kind === 'token' ? gd.token(p.id) : gd.chess(p.id);
|
||||
if (rec && !canPlace(dmap, positionClass(rec), r, c)) fail(`${id}: ${p.id} on an illegal tile ${k}`);
|
||||
const cls = p.kind === 'chess' ? placeClass(ps, rec) : positionClass(rec);
|
||||
if (rec && !canPlace(dmap, cls, r, c)) fail(`${id}: ${p.id} on an illegal tile ${k}`);
|
||||
// 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;
|
||||
if (range && !range.has(k)) fail(`${id}: ${p.id} on ${k}, outside its owner's attack range`);
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
// shared/highGround.js — which MELEE chess may stand on a 高台 (a ranged deploy tile).
|
||||
//
|
||||
// Owner's decision 2026-10-04, reversing DESIGN §22.6. The branch trait 「可以放置于远程位」 is written on
|
||||
// every 钩索师 / 推击手 variant (歌蕾蒂娅, 崖心, 见行者, normal and elite) and is not trusted. Only elite
|
||||
// 歌蕾蒂娅 carrying module HOK-Y 淡金坠饰 may use a ranged tile. 崖心 (HOK-X only), 见行者, a normal
|
||||
// record, any other module, and no module are ground-only. The golden record is the elite (`isGolden`);
|
||||
// the same module test applies to it. A missing module id is ground-only.
|
||||
|
||||
/** char_474_glady — 歌蕾蒂娅. */
|
||||
export const GLADIIA_CHAR_ID = 'char_474_glady';
|
||||
/** HOK-Y 淡金坠饰 (data/chess.json chess_char_4_12_b.modules, typeName HOK-Y). */
|
||||
export const GLADIIA_HOK_Y = 'uniequip_003_glady';
|
||||
|
||||
/**
|
||||
* @param {object|null} rec a chess record (normal or golden)
|
||||
* @param {string|null|undefined} moduleId the equipped module (`resolveLoadout().moduleId`)
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function meleeOnHighGround(rec, moduleId) {
|
||||
return !!(rec && rec.isGolden === true && rec.charId === GLADIIA_CHAR_ID && moduleId === GLADIIA_HOK_Y);
|
||||
}
|
||||
@@ -219,6 +219,8 @@ export function unitStatsEntry(u, s = null) {
|
||||
...statView(cur),
|
||||
base: statView(base),
|
||||
...(range ? { range } : {}),
|
||||
// the enemy card greys a SILENCE-format line (折射) from this; absent flags ⇒ not silenced
|
||||
silenced: !!(cur.flags && cur.flags.silence),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -696,6 +696,34 @@ test('pools: 凯瑟琳 127 odd rounds, 佩佩 94, 洛洛 91, 焰尾 149, 歌蕾
|
||||
}
|
||||
});
|
||||
|
||||
test('歌蕾蒂娅 / 余: granting a chess toasts 「名字:获得X」', () => {
|
||||
const glady = setup(11);
|
||||
const row = plain().slice(0, 2);
|
||||
give(glady.m, glady.ps, 'chess_char_4_12_a', 'board', [10, 4]);
|
||||
give(glady.m, glady.ps, row[0], 'board', [10, 6]);
|
||||
give(glady.m, glady.ps, row[1], 'board', [10, 8]);
|
||||
const n0 = glady.h.allTo('p_0', 'm.toast').length;
|
||||
glady.roundStart();
|
||||
const lines = glady.h.allTo('p_0', 'm.toast').slice(n0).map((t) => t.text);
|
||||
const line = lines.find((t) => t.startsWith('歌蕾蒂娅:获得'));
|
||||
assert.ok(line, lines.join(' | '));
|
||||
const gained = line.slice('歌蕾蒂娅:获得'.length);
|
||||
assert.ok(handChess(glady.ps).some((id) => CH(id).name === gained), `${line} not in ${handChess(glady.ps)}`);
|
||||
const yu = setup(12);
|
||||
give(yu.m, yu.ps, 'chess_char_6_03_a', 'board', [10, 4]);
|
||||
yu.ps.bondCountBonus.egirShip = 9;
|
||||
yu.ps.recompute();
|
||||
const fillers = plain((c) => !c.bonds.includes('egirShip')).slice(0, 2);
|
||||
give(yu.m, yu.ps, fillers[0], 'board', [10, 6]);
|
||||
give(yu.m, yu.ps, fillers[1], 'board', [10, 8]);
|
||||
const n1 = yu.h.allTo('p_0', 'm.toast').length;
|
||||
yu.roundStart();
|
||||
const yuLine = yu.h.allTo('p_0', 'm.toast').slice(n1).map((t) => t.text).find((t) => t.startsWith('余:获得'));
|
||||
assert.ok(yuLine, yu.h.allTo('p_0', 'm.toast').slice(n1).map((t) => t.text).join(' | '));
|
||||
const yuName = yuLine.slice('余:获得'.length);
|
||||
assert.ok(handChess(yu.ps).some((id) => CH(id).name === yuName), yuLine);
|
||||
});
|
||||
|
||||
test('余 37: a chess of the bond with the most members (normal: 3 in the row; 精锐: always)', () => {
|
||||
for (const { gid, g, owners } of idsOf('SERVER_PREP_START', 'SERVER_MOST_BOND')) {
|
||||
for (const owner of owners) {
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
|
||||
import { startServer } from '../../server/index.js';
|
||||
import { Match } from '../../server/match/Match.js';
|
||||
import { legalTiles, positionClass, tileKey } from '../../server/match/board.js';
|
||||
import { legalTiles, placeClass, tileKey } from '../../server/match/board.js';
|
||||
import { planLayout } from '../../server/match/bot.js';
|
||||
|
||||
if (!process.env.NODE_TEST_CONTEXT) {
|
||||
@@ -104,7 +104,7 @@ if (!process.env.NODE_TEST_CONTEXT) {
|
||||
const res = ps.move(piece.uid, { area: 'board', row, col }, plan.dirs.get(piece.uid) || 'RIGHT');
|
||||
if (!res || !res.error) continue;
|
||||
}
|
||||
for (const [row, col] of legalTiles(ps.deployMap(), positionClass(this.gd.chess(piece.id)))) {
|
||||
for (const [row, col] of legalTiles(ps.deployMap(), placeClass(ps, this.gd.chess(piece.id)))) {
|
||||
if (ps.board.has(tileKey(row, col))) continue;
|
||||
const res = ps.move(piece.uid, { area: 'board', row, col }, 'RIGHT');
|
||||
if (!res || !res.error) break;
|
||||
|
||||
@@ -55,16 +55,27 @@ 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', () => {
|
||||
test('positionClass: melee stays ground-only; only elite 歌蕾蒂娅 + HOK-Y is widened (owner 2026-10-04)', () => {
|
||||
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: 'MELEE', placement: 'all' }), 'melee', 'the old placement field is ignored');
|
||||
assert.equal(basePositionClass({ position: 'MELEE', isGolden: true, charId: 'char_474_glady' }), 'melee');
|
||||
assert.equal(positionClass({ position: 'RANGED' }), 'ranged');
|
||||
assert.equal(positionClass({ position: 'ALL' }), 'all');
|
||||
assert.equal(positionClass(null), 'all');
|
||||
const HOK_Y = 'uniequip_003_glady';
|
||||
const gladE = DATA.chess.chess_char_4_12_b;
|
||||
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_4_12_a, HOK_Y), 10, 4), 'normal 歌蕾蒂娅, even with the id, not');
|
||||
assert.ok(!canPlace(map, positionClass(gladE), 10, 4), 'elite with no module id (default is not passed) not');
|
||||
assert.ok(!canPlace(map, positionClass(gladE, 'uniequip_002_glady'), 10, 4), 'HOK-X not');
|
||||
assert.ok(!canPlace(map, positionClass(gladE, 'none'), 10, 4), 'no module not');
|
||||
assert.ok(canPlace(map, positionClass(gladE, HOK_Y), 10, 4), 'elite + HOK-Y on the 高台');
|
||||
assert.ok(canPlace(map, positionClass(gladE, HOK_Y), 9, 3), 'and still on the ground');
|
||||
assert.ok(!canPlace(map, positionClass(DATA.chess.chess_char_2_03_b, HOK_Y), 10, 4), '崖心 not');
|
||||
assert.ok(!canPlace(map, positionClass(DATA.chess.chess_char_3_07_b, HOK_Y), 10, 4), '见行者 not');
|
||||
assert.ok(!canPlace(map, positionClass(DATA.chess.chess_char_1_02_a), 10, 4), '角峰 (重装) not');
|
||||
assert.equal(DATA.chess.chess_char_4_12_a.placement, undefined);
|
||||
assert.equal(gladE.placement, undefined);
|
||||
});
|
||||
|
||||
test('g.move: legality per chess position, BAD_TILE outside, deploy cap 8, swaps allowed at cap', () => {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { PHASE } from '../../shared/constants.js';
|
||||
import { fieldModel, planLayout, rehearse, rangeTiles, REHEARSAL_VARIANTS, LAYOUT_PARAMS, botPickCard, botPickBand } from '../../server/match/bot.js';
|
||||
import { FIELD, canPlace, positionClass, parseKey } from '../../server/match/board.js';
|
||||
import { FIELD, canPlace, placeClass, parseKey } from '../../server/match/board.js';
|
||||
import { makeMatch, checkInvariants, give, DATA } from './harness.js';
|
||||
|
||||
const soloBot = (o = {}) => makeMatch({ mode: 'solo', difficulty: 'NORMAL', seats: [{ seat: 0, playerId: 'ai_0', name: 'AI', isBot: true, connected: true }], ...o });
|
||||
@@ -73,7 +73,7 @@ test('layout planner: legal distinct tiles; blockers on the enemy road, ranged u
|
||||
const model = fieldModel(m);
|
||||
for (const p of pieces) {
|
||||
const [r, c] = parseKey(plan.get(p.uid));
|
||||
assert.ok(canPlace(map, positionClass(m.gd.chess(p.id)), r, c), `${p.id} legal at ${r},${c}`);
|
||||
assert.ok(canPlace(map, placeClass(ps, m.gd.chess(p.id)), r, c), `${p.id} legal at ${r},${c}`);
|
||||
}
|
||||
for (const p of pieces.slice(0, 2)) assert.ok(model.ground.has(plan.get(p.uid)), 'a blocker stands on the enemy road');
|
||||
for (const p of pieces.slice(2)) {
|
||||
|
||||
@@ -11,16 +11,13 @@
|
||||
// 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].
|
||||
// Owner's decision 2026-10-04 reverses DESIGN §22.6: the trait 「可以放置于远程位」 is not trusted. Only elite
|
||||
// 歌蕾蒂娅 carrying HOK-Y 淡金坠饰 (uniequip_003_glady) may stand on a 高台. The battle keeps position MELEE
|
||||
// (on a 高台 she attacks but blocks nothing). A bot may plan that one loadout on a 高台; everyone else stays on the ground.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
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 { buildDeployMap, canPlace as boardCanPlace, legalTiles, positionClass, placeClass, tileKey, ownerRangeKeys } from '../../server/match/board.js';
|
||||
import { GLADIIA_HOK_Y } from '../../shared/highGround.js';
|
||||
import { ERR } from '../../shared/constants.js';
|
||||
import { DATA, makeMatch, give, giveItem, checkInvariants, chessOfTier } from './harness.js';
|
||||
import { botPrep, planLayout, fieldModel } from '../../server/match/bot.js';
|
||||
@@ -307,59 +304,76 @@ test('#9 bots place 狼群 / 流形 only inside their owner\'s range', () => {
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------------
|
||||
// GitHub #32 item 4 (after 0.1.1): 钩索师 / 推击手 on a 高台
|
||||
// 高台: only elite 歌蕾蒂娅 + HOK-Y (owner's decision 2026-10-04)
|
||||
|
||||
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 GLAD_N = 'chess_char_4_12_a';
|
||||
const GLAD_E = 'chess_char_4_12_b'; // the golden record
|
||||
const CLIFF_E = 'chess_char_2_03_b';
|
||||
const FORCER_N = 'chess_char_3_07_a';
|
||||
const FORCER_E = 'chess_char_3_07_b';
|
||||
const HOK_X = 'uniequip_002_glady';
|
||||
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) });
|
||||
const equip = (ps, module) => {
|
||||
assert.equal(ps.setLoadout({ [GLAD_N]: { module } }), true, module);
|
||||
};
|
||||
|
||||
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');
|
||||
}
|
||||
test('高台 data: no chess stores placement; HOK-Y is 淡金坠饰 on the elite 歌蕾蒂娅 only', () => {
|
||||
assert.equal(Object.values(DATA.chess).some((c) => c.placement !== undefined), false);
|
||||
for (const t of Object.values(DATA.tokens)) assert.equal(t.placement, undefined, t.tokenId);
|
||||
const e = DATA.chess[GLAD_E];
|
||||
const y = e.modules.find((m) => m.uniEquipId === GLADIIA_HOK_Y);
|
||||
assert.equal(e.charId, 'char_474_glady');
|
||||
assert.equal(e.isGolden, true);
|
||||
assert.equal(y.typeName, 'HOK-Y');
|
||||
assert.equal(y.name, '淡金坠饰');
|
||||
assert.equal(e.modules.find((m) => m.isDefault).uniEquipId, HOK_X, 'the default module is HOK-X, ground-only');
|
||||
assert.equal(DATA.chess[GLAD_N].isGolden, false);
|
||||
assert.ok(!DATA.chess[CLIFF_E].modules.some((m) => m.uniEquipId === GLADIIA_HOK_Y), '崖心 has no HOK-Y');
|
||||
assert.equal(DATA.chess[GLAD_N].position, 'MELEE');
|
||||
assert.equal(e.position, 'MELEE');
|
||||
});
|
||||
|
||||
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', () => {
|
||||
test('高台 table: (operator, module, tile) → allowed only for elite 歌蕾蒂娅 + HOK-Y on a 高台; ranged 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);
|
||||
// rows: chess id, module set on the 歌蕾蒂娅 loadout (null = leave the default), expect 高台
|
||||
const rows = [
|
||||
[GLAD_E, GLADIIA_HOK_Y, true],
|
||||
[GLAD_E, HOK_X, false],
|
||||
[GLAD_E, 'none', false],
|
||||
[GLAD_N, GLADIIA_HOK_Y, false],
|
||||
[GLAD_N, null, false],
|
||||
[CLIFF_E, GLADIIA_HOK_Y, false],
|
||||
[FORCER_N, null, false],
|
||||
[FORCER_E, null, false],
|
||||
];
|
||||
const owned = new Map();
|
||||
for (const [id, module, allowHigh] of rows) {
|
||||
if (module) equip(ps, module);
|
||||
else ps.setLoadout({});
|
||||
let p = owned.get(id);
|
||||
if (!p) { p = give(m, ps, id); owned.set(id, p); }
|
||||
else if (ps.find(p.uid)?.area === 'board') assert.deepEqual(toHand(m, ps, p), { ok: true });
|
||||
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);
|
||||
const res = move(m, p.uid, board(r, c), 'DOWN');
|
||||
if (allowHigh) {
|
||||
assert.deepEqual(res, { ok: true }, `${id} ${module} → ${k}`);
|
||||
assert.equal(ps.board.get(k), p);
|
||||
checkInvariants(m);
|
||||
assert.deepEqual(toHand(m, ps, p), { ok: true });
|
||||
} else {
|
||||
assert.equal(res.error, ERR.BAD_TILE, `${id} ${module || 'default'} → ${k}`);
|
||||
}
|
||||
}
|
||||
assert.deepEqual(move(m, p.uid, board(9, 3)), { ok: true }, `${id}: the ground stays legal`);
|
||||
assert.equal(placeClass(ps, DATA.chess[id]) === 'all', allowHigh, id);
|
||||
assert.deepEqual(toHand(m, ps, p), { ok: true });
|
||||
ps.setLoadout({});
|
||||
}
|
||||
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)));
|
||||
@@ -369,90 +383,83 @@ test('#32-4 g.move: 歌蕾蒂娅 / 崖心 / 见行者 (normal and elite) onto ev
|
||||
}
|
||||
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');
|
||||
assert.deepEqual(move(m, ranged.uid, board(9, 4)), { ok: true }, 'ranged on the ground (there is no ground-only tile)');
|
||||
assert.equal(move(m, ranged.uid, board(9, 6)).error, ERR.BAD_TILE, 'ranged refused on a non-deployable tile');
|
||||
checkInvariants(m);
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
test('#32-4 the client mirrors it: the drag highlights of 歌蕾蒂娅 / 崖心 / 见行者 include the 高台 (= the server\'s legal tiles); the 重装 keeps "近战单位只能部署在地面"', () => {
|
||||
test('高台 client: drag highlights follow the loadout; a 重装 keeps "近战单位只能部署在地面"', () => {
|
||||
const { m, ps } = prep(HIGH_STAGE);
|
||||
const pieces = ANY_TILE.map((id) => give(m, ps, id));
|
||||
equip(ps, GLADIIA_HOK_Y);
|
||||
const glad = give(m, ps, GLAD_E);
|
||||
const normal = give(m, ps, GLAD_N);
|
||||
const cliff = give(m, ps, CLIFF_E);
|
||||
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]) {
|
||||
for (const p of [glad, normal, cliff, 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);
|
||||
assert.deepEqual(lit, legalTiles(map, placeClass(ps, DATA.chess[p.id])).map(([r, c]) => tileKey(r, c)).sort(), `${p.id}: highlights = the server`);
|
||||
}
|
||||
for (const k of HIGH) {
|
||||
const [r, c] = rc(k);
|
||||
assert.equal(clientCanPlace(ctx, glad.uid, board(r, c)).ok, true, k);
|
||||
for (const p of [normal, cliff, tank]) {
|
||||
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, '近战单位只能部署在地面');
|
||||
assert.equal(res.ok, false, p.id);
|
||||
assert.equal(res.reason, '近战单位只能部署在地面');
|
||||
}
|
||||
assert.equal(clientCanPlace(ctx, ranged.uid, board(r, c)).ok, true);
|
||||
}
|
||||
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', () => {
|
||||
test('高台 swaps: elite 歌蕾蒂娅 + HOK-Y trades with a ranged operator, not with a 重装', () => {
|
||||
const { m, ps } = prep(HIGH_STAGE);
|
||||
const glad = give(m, ps, GLADIIA);
|
||||
equip(ps, GLADIIA_HOK_Y);
|
||||
const glad = give(m, ps, GLAD_E);
|
||||
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)', () => {
|
||||
test('高台 battle input: elite 歌蕾蒂娅 + HOK-Y is fielded on (10,4) and blocks nothing there; on the ground she blocks', () => {
|
||||
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);
|
||||
equip(ps, GLADIIA_HOK_Y);
|
||||
const glad = give(m, ps, GLAD_E);
|
||||
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']);
|
||||
assert.deepEqual([u0.row, u0.col, u0.dir, u0.moduleId], [10, 4, 'DOWN', GLADIIA_HOK_Y]);
|
||||
m.dispose();
|
||||
// position stays MELEE either way: the normal record (the same body the 0.1.2 measurement used) attacks the lane
|
||||
// from the 高台 and blocks nothing there. HOK-Y's own range is not what this asserts.
|
||||
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 } }],
|
||||
units: [{ chessId: GLAD_N, 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);
|
||||
const u = h.unit(GLAD_N);
|
||||
let hits = 0;
|
||||
let blocked = false;
|
||||
h.b.on('damaged', (x) => { if (x.source === u) hits++; });
|
||||
@@ -465,18 +472,42 @@ test('#32-4 battle: the board fields 歌蕾蒂娅 on (10,4); there she attacks t
|
||||
}
|
||||
});
|
||||
|
||||
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();
|
||||
test('高台 bots: elite 歌蕾蒂娅 + HOK-Y takes a 高台 that covers the road while the ground is still free', () => {
|
||||
const { m, ps } = prep(HIGH_STAGE, 11);
|
||||
equip(ps, GLADIIA_HOK_Y);
|
||||
const elite = give(m, ps, GLAD_E);
|
||||
const k = planLayout(m, ps, [elite]).get(elite.uid);
|
||||
assert.equal(ps.deployMap().get(k), 'ranged', `planned on a 高台 with the road open (${k})`);
|
||||
const [r, c] = rc(k);
|
||||
assert.deepEqual(move(m, elite.uid, board(r, c)), { ok: true });
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
test('高台 bots: everyone else stays on the road; elite 歌蕾蒂娅 + HOK-Y may take a 高台 when the ground is full', () => {
|
||||
const { m, ps } = prep(HIGH_STAGE, 11);
|
||||
const pieces = ['chess_char_4_02_a', GLAD_N, FORCER_N, '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', `${p.id} on a ground tile (${k})`);
|
||||
assert.ok(road.has(k), `${p.id} on an enemy road tile (${k})`);
|
||||
assert.equal(move(m, p.uid, board(10, 4)).error, ERR.BAD_TILE, `${p.id} deploy refused`);
|
||||
}
|
||||
const elite = give(m, ps, GLAD_E);
|
||||
equip(ps, GLADIIA_HOK_Y);
|
||||
const melee = [...map].filter(([, cls]) => cls === 'melee').map(([k]) => k);
|
||||
const highPlan = planLayout(m, ps, [elite], undefined, { occupied: new Set(melee) });
|
||||
const hk = highPlan.get(elite.uid);
|
||||
assert.equal(map.get(hk), 'ranged', `HOK-Y planned on a 高台 (${hk})`);
|
||||
const [hr, hc] = rc(hk);
|
||||
assert.deepEqual(move(m, elite.uid, board(hr, hc)), { ok: true });
|
||||
checkInvariants(m);
|
||||
assert.deepEqual(toHand(m, ps, elite), { ok: true });
|
||||
equip(ps, 'none');
|
||||
const refused = planLayout(m, ps, [elite], undefined, { occupied: new Set(melee) });
|
||||
assert.equal(refused.get(elite.uid), undefined, 'no module: a 高台 is not a fallback');
|
||||
assert.equal(move(m, elite.uid, board(10, 4)).error, ERR.BAD_TILE);
|
||||
m.dispose();
|
||||
});
|
||||
@@ -20,7 +20,7 @@ import assert from 'node:assert/strict';
|
||||
import { Match } from '../../server/match/Match.js';
|
||||
import { VirtualScheduler } from '../../server/match/scheduler.js';
|
||||
import { getData } from '../../server/data.js';
|
||||
import { FIELD, canPlace, positionClass, tileKey } from '../../server/match/board.js';
|
||||
import { FIELD, canPlace, placeClass, positionClass, tileKey } from '../../server/match/board.js';
|
||||
import { FakeBattle } from './fakeBattle.js';
|
||||
import { GEO } from '../../shared/constants.js';
|
||||
import { collectViolations } from '../../server/match/invariants.js';
|
||||
@@ -179,7 +179,8 @@ function legacyInvariants(m) {
|
||||
assert.ok(r >= FIELD.r0 && r <= FIELD.r1 && c >= FIELD.c0 && c <= FIELD.c1, `piece outside the board ${k}`);
|
||||
const rec = p.kind === 'token' ? m.gd.token(p.id) : m.gd.chess(p.id);
|
||||
assert.ok(rec, `unknown board piece ${p.id}`);
|
||||
assert.ok(canPlace(dmap, positionClass(rec), r, c), `illegal tile ${p.id} @ ${k}`);
|
||||
const cls = p.kind === 'chess' ? placeClass(ps, rec) : positionClass(rec);
|
||||
assert.ok(canPlace(dmap, cls, r, c), `illegal tile ${p.id} @ ${k}`);
|
||||
assert.notEqual(p.kind, 'item', 'items never stand on the board');
|
||||
if (p.kind === 'chess') deployed++;
|
||||
if (p.kind === 'token') assert.ok(chessUids.has(p.ownerUid), `orphan token ${p.uid}`);
|
||||
@@ -255,7 +256,7 @@ export function chessOfTier(tier, filter = () => true) {
|
||||
|
||||
/** First legal board tile for a chess (reading order). */
|
||||
export function legalTileFor(m, ps, chessId, skip = new Set()) {
|
||||
const pos = positionClass(m.gd.chess(chessId));
|
||||
const pos = placeClass(ps, m.gd.chess(chessId));
|
||||
const map = ps.deployMap();
|
||||
for (let r = FIELD.r1; r >= FIELD.r0; r--) for (let c = FIELD.c0; c <= FIELD.c1; c++) {
|
||||
if (skip.has(tileKey(r, c)) || ps.board.has(tileKey(r, c))) continue;
|
||||
|
||||
@@ -3,7 +3,7 @@ import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { ERR, PHASE } from '../../shared/constants.js';
|
||||
import { DATA, makeMatch, give, giveItem, checkInvariants, chessOfTier, legalTileFor } from './harness.js';
|
||||
import { canPlace, positionClass } from '../../server/match/board.js';
|
||||
import { canPlace, placeClass } from '../../server/match/board.js';
|
||||
|
||||
function prep(seed = 21, o = {}) {
|
||||
const h = makeMatch({ mode: 'solo', difficulty: 'NORMAL', seed, ...o }).start();
|
||||
@@ -361,7 +361,7 @@ test('a merge completed during SETTLE (突变细胞) keeps its reward offer for
|
||||
// PRTS 卫戍协议/帮助: a merge consuming a deployed copy sends the elite to that copy's tile — outside PREP too; of
|
||||
// several, the first in deploy order (col asc, then row desc — by column from the left, Battle.start)
|
||||
const map = ps.deployMap();
|
||||
const pos = positionClass(m.gd.chess(elite.id));
|
||||
const pos = placeClass(ps, m.gd.chess(elite.id));
|
||||
const legal = deployedTiles.map((k) => k.split(',').map(Number)).filter(([r, c]) => canPlace(map, pos, r, c)).sort((a, b) => a[1] - b[1] || b[0] - a[0]);
|
||||
assert.ok(legal.length, `${label}: a legal deployed tile exists`);
|
||||
assert.equal(loc.area, 'board', `${label}: the elite stands on the board`);
|
||||
|
||||
@@ -12,7 +12,7 @@ import assert from 'node:assert/strict';
|
||||
import { ERR, PHASE } from '../../shared/constants.js';
|
||||
import { checkLoadout } from '../../shared/protocol.js';
|
||||
import { DATA, makeMatch, give, giveItem, checkInvariants, chessOfTier, legalTileFor } from './harness.js';
|
||||
import { canPlace, positionClass, tileKey, mergeTile } from '../../server/match/board.js';
|
||||
import { canPlace, placeClass, tileKey, mergeTile } from '../../server/match/board.js';
|
||||
import { makeCtx } from '../../server/match/effectsMeta.js';
|
||||
import { attachAudit } from '../../server/match/audit.js';
|
||||
import { botPrep } from '../../server/match/bot.js';
|
||||
@@ -364,7 +364,7 @@ test('boss-field prep (R14, act2 m01): the elite takes a copy\'s tile that is le
|
||||
assert.deepEqual(m.handle(pid, { t: 'g.buy', slot: 0 }), { ok: true });
|
||||
const elite = ps.board.get('11,8');
|
||||
assert.ok(elite && elite.id === chess(id).goldenId && elite.dir === 'UP', `${pid} (${field}): the elite on board (11,8) of its boss half`);
|
||||
assert.ok(canPlace(ps.deployMap(), positionClass(chess(elite.id)), 11, 8));
|
||||
assert.ok(canPlace(ps.deployMap(), placeClass(ps, chess(elite.id)), 11, 8));
|
||||
}
|
||||
checkInvariants(m);
|
||||
m.dispose();
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
// GitHub #87: watching another board during prep follows its moves. A spectator uses the same prep scout.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { PHASE } from '../../shared/constants.js';
|
||||
import { makeMatch, give, legalTileFor, chessOfTier } from './harness.js';
|
||||
|
||||
const MELEE = (c) => c.position === 'MELEE' && c.profession === 'TANK';
|
||||
|
||||
test('#87 prep watch: seat B moves during prep and seat A\'s view of B changes before combat; a spectator does too', () => {
|
||||
const S = 's_spec';
|
||||
const h = makeMatch({ mode: 'coop', humans: 2, seed: 8701, fake: true, spectators: [S] }).start();
|
||||
h.toPrep(1);
|
||||
const m = h.m;
|
||||
assert.equal(m.phase, PHASE.PREP);
|
||||
const b = h.ps('p_1');
|
||||
assert.deepEqual(m.handle('p_1', { t: 'g.ready', ready: false }), { ok: true });
|
||||
for (const p of [...b.board.values(), ...b.hand.filter(Boolean), ...b.temp.filter(Boolean)]) {
|
||||
if (p.kind === 'chess') b.returnCopies(p);
|
||||
}
|
||||
b.board.clear();
|
||||
b.hand.fill(null);
|
||||
b.temp.fill(null);
|
||||
b.recompute();
|
||||
const id = chessOfTier(1, MELEE).find((x) => m.pool.has(x));
|
||||
const piece = give(m, b, id);
|
||||
assert.deepEqual(m.handle('p_0', { t: 'g.watch', fieldId: 'n:p_1' }), { ok: true });
|
||||
assert.deepEqual(m.handle(S, { t: 'g.watch', fieldId: 'n:p_1' }), { ok: true });
|
||||
assert.equal(m.watchers.get('p_0'), 'n:p_1');
|
||||
assert.equal(m.watchers.get(S), 'n:p_1');
|
||||
for (const pid of ['p_0', S]) {
|
||||
const first = h.lastTo(pid, 'm.field');
|
||||
assert.equal(first.fieldId, 'n:p_1');
|
||||
assert.equal(first.prep, true);
|
||||
assert.equal(first.units.some((u) => u.uid === piece.uid), false, `${pid}: the piece is still in the hand`);
|
||||
}
|
||||
assert.equal(h.allTo(S, 'm.private').length, 0);
|
||||
const beforeA = h.allTo('p_0', 'm.field').length;
|
||||
const beforeS = h.allTo(S, 'm.field').length;
|
||||
const [r, c] = legalTileFor(m, b, id);
|
||||
assert.deepEqual(m.handle('p_1', { t: 'g.move', uid: piece.uid, to: { area: 'board', row: r, col: c }, dir: 'LEFT' }), { ok: true });
|
||||
assert.equal(m.phase, PHASE.PREP, 'still prep — the update did not wait for combat');
|
||||
for (const [pid, before] of [['p_0', beforeA], [S, beforeS]]) {
|
||||
const fields = h.allTo(pid, 'm.field');
|
||||
assert.equal(fields.length, before + 1, `${pid}: one new board`);
|
||||
const last = fields.at(-1);
|
||||
const u = last.units.find((x) => x.uid === piece.uid);
|
||||
assert.ok(u, `${pid}: the moved piece is on the watched board`);
|
||||
assert.deepEqual([u.x, u.y, u.dir], [c, r, 'LEFT']);
|
||||
}
|
||||
const n = h.allTo('p_0', 'm.field').length;
|
||||
assert.deepEqual(m.handle('p_1', { t: 'g.freeze' }), { ok: true });
|
||||
assert.equal(h.allTo('p_0', 'm.field').length, n, 'a shop toggle is not a board change');
|
||||
assert.equal(h.allTo(S, 'm.private').length, 0);
|
||||
m.dispose();
|
||||
});
|
||||
@@ -51,6 +51,7 @@ test('unitStatsEntry: effective stats next to the base, rounded for display; the
|
||||
id: 3, uid: 44, defId: 'chess_x', hp: 813, alive: true,
|
||||
maxHp: 1250, atk: 420, def: 90, res: 12.3, interval: 1.08, blockCnt: 3, moveSpeed: 0,
|
||||
base: { maxHp: 1000, atk: 300, def: 100, res: 10, interval: 1.2, blockCnt: 2, moveSpeed: 0 },
|
||||
silenced: false,
|
||||
});
|
||||
const plain = unitStatsEntry(u);
|
||||
assert.equal(plain.atk, 300, 'no aggregated stats: the base');
|
||||
|
||||
@@ -351,6 +351,125 @@ describe('public/js/ui/emotes.js helpers', () => {
|
||||
assert.match(readFileSync(path.join(ROOT, 'public/index.html'), 'utf8'), /href="\/css\/emotes\.css"/);
|
||||
});
|
||||
|
||||
test('a hung emote manifest is missing when the clock fires (glyph), and a later retry replaces it', async () => {
|
||||
const { createDataStore } = await import('../../public/js/data.js');
|
||||
const timers = [];
|
||||
let calls = 0;
|
||||
let releaseWait;
|
||||
const store = createDataStore({
|
||||
timeoutMs: 1000,
|
||||
retryDelays: [0],
|
||||
wait: () => new Promise((r) => { releaseWait = r; }),
|
||||
setTimeout: (fn, ms) => { timers.push({ fn, ms }); return timers.length; },
|
||||
clearTimeout: () => {},
|
||||
fetch: () => {
|
||||
calls++;
|
||||
if (calls === 1) return new Promise(() => {});
|
||||
return Promise.resolve({ ok: true, status: 200, json: async () => ({ ok: true, groups: { a: { pic: { path: '/p.png' } } } }) });
|
||||
},
|
||||
});
|
||||
const states = [];
|
||||
store.subscribe(() => states.push(store.status('local')));
|
||||
const p = store.load('local');
|
||||
assert.equal(store.status('local'), 'loading');
|
||||
assert.equal(timers.length, 1);
|
||||
assert.equal(timers[0].ms, 1000);
|
||||
timers[0].fn();
|
||||
await new Promise((r) => setImmediate(r));
|
||||
assert.equal(store.status('local'), 'missing', 'glyph as soon as the attempt times out, before the retry');
|
||||
assert.equal(calls, 1, 'still on the first attempt while the retry waits');
|
||||
releaseWait();
|
||||
const value = await p;
|
||||
assert.equal(calls, 2);
|
||||
assert.equal(store.status('local'), 'ready');
|
||||
assert.equal(value.ok, true);
|
||||
assert.equal(store.get('local').groups.a.pic.path, '/p.png');
|
||||
assert.deepEqual(states, ['missing', 'ready']);
|
||||
for (const t of timers) t.fn();
|
||||
assert.equal(store.status('local'), 'ready', 'a timer that fires after success does not demote the file');
|
||||
});
|
||||
|
||||
test('a failed emote manifest (no clock) is missing before the retry, then ready; a 404 stays missing', async () => {
|
||||
const { createDataStore } = await import('../../public/js/data.js');
|
||||
let calls = 0;
|
||||
const store = createDataStore({
|
||||
timeoutMs: 1000,
|
||||
retryDelays: [0],
|
||||
wait: async () => {},
|
||||
setTimeout: () => 1,
|
||||
clearTimeout: () => {},
|
||||
fetch: async () => {
|
||||
calls++;
|
||||
if (calls === 1) throw new TypeError('Failed to fetch');
|
||||
return { ok: true, status: 200, json: async () => ({ groups: { emoticon: {} } }) };
|
||||
},
|
||||
});
|
||||
const states = [];
|
||||
store.subscribe(() => states.push(store.status('assets')));
|
||||
const value = await store.load('assets');
|
||||
assert.equal(value.groups.emoticon !== undefined, true);
|
||||
assert.deepEqual(states, ['missing', 'ready']);
|
||||
const warn = console.warn;
|
||||
console.warn = () => {};
|
||||
try {
|
||||
const timers = [];
|
||||
const gone = createDataStore({
|
||||
timeoutMs: 5,
|
||||
retryDelays: [],
|
||||
setTimeout: (fn) => { timers.push(fn); return timers.length; },
|
||||
clearTimeout: () => {},
|
||||
fetch: () => new Promise(() => {}),
|
||||
});
|
||||
const p = gone.load('local');
|
||||
assert.equal(timers.length, 1);
|
||||
timers[0]();
|
||||
assert.equal(await p, null);
|
||||
assert.equal(gone.status('local'), 'missing');
|
||||
let n = 0;
|
||||
const missing = createDataStore({
|
||||
timeoutMs: 5,
|
||||
retryDelays: [0],
|
||||
wait: async () => {},
|
||||
setTimeout: () => 1,
|
||||
clearTimeout: () => {},
|
||||
fetch: async () => { n++; return { ok: false, status: 404, json: async () => ({}) }; },
|
||||
});
|
||||
assert.equal(await missing.load('assets'), null);
|
||||
assert.equal(missing.status('assets'), 'missing');
|
||||
assert.equal(n, 1, 'a 404 is not retried');
|
||||
} finally { console.warn = warn; }
|
||||
});
|
||||
|
||||
test('the art-manifest clock is not armed for other files; the default wait is ART_MANIFEST_TIMEOUT_MS', async () => {
|
||||
const { createDataStore, ART_MANIFEST_TIMEOUT_MS } = await import('../../public/js/data.js');
|
||||
assert.equal(ART_MANIFEST_TIMEOUT_MS, 8000);
|
||||
const arms = [];
|
||||
let release;
|
||||
const store = createDataStore({
|
||||
setTimeout: (fn, ms) => { arms.push(ms); return arms.length; },
|
||||
clearTimeout: () => {},
|
||||
fetch: (url) => new Promise((resolve) => { release = () => resolve(String(url).includes('chess')
|
||||
? { ok: false, status: 404, json: async () => ({}) }
|
||||
: { ok: true, status: 200, json: async () => ({ ok: 1 }) }); }),
|
||||
});
|
||||
const local = store.load('local');
|
||||
assert.deepEqual(arms, [8000]);
|
||||
release();
|
||||
assert.equal((await local).ok, 1);
|
||||
arms.length = 0;
|
||||
const warn = console.warn;
|
||||
console.warn = () => {};
|
||||
try {
|
||||
const chess = store.load('chess');
|
||||
await new Promise((r) => setImmediate(r));
|
||||
assert.deepEqual(arms, [], 'chess is not on the emote clock');
|
||||
assert.equal(store.status('chess'), 'loading');
|
||||
release();
|
||||
await chess;
|
||||
assert.equal(store.status('chess'), 'missing');
|
||||
} finally { console.warn = warn; }
|
||||
});
|
||||
|
||||
test('emote UI never renders an emote label as text (aria-label only)', () => {
|
||||
const src = readFileSync(path.join(ROOT, 'public/js/ui/emotes.js'), 'utf8');
|
||||
assert.doesNotMatch(src, /EMOTE_TEXT|EMOTE_LABEL/, 'no text table in the UI');
|
||||
|
||||
+22
-11
@@ -39,13 +39,13 @@ let uid = 0;
|
||||
const piece = (id, extra = {}) => ({ uid: ++uid, kind: 'chess', id, golden: false, tier: chess[id]?.tier ?? 1, items: [], ...extra });
|
||||
const item = (id) => ({ uid: ++uid, kind: 'item', id, golden: false, tier: 1 });
|
||||
|
||||
function privWith({ board = [], hand = [], temp = [], deployCap = 8, ready = false } = {}) {
|
||||
function privWith({ board = [], hand = [], temp = [], deployCap = 8, ready = false, loadout = null } = {}) {
|
||||
const h = new Array(GEO.HAND_SIZE).fill(null);
|
||||
hand.forEach((p, i) => { if (p) h[p.idx ?? i] = p.piece ?? p; });
|
||||
const t = new Array(GEO.TEMP_SIZE).fill(null);
|
||||
temp.forEach((p, i) => { t[i] = p; });
|
||||
return { alive: true, ready, funds: 10, board, hand: h, temp: t, deployCap, deployCount: board.filter((p) => p.kind === 'chess').length, canReady: !t.some(Boolean),
|
||||
shop: { level: 3, maxLevel: 6, upgradePrice: 8, refreshPrice: 1, freeRefreshes: 0, frozen: false, slots: [] } };
|
||||
loadout, shop: { level: 3, maxLevel: 6, upgradePrice: 8, refreshPrice: 1, freeRefreshes: 0, frozen: false, slots: [] } };
|
||||
}
|
||||
const ctxFor = (priv, editable = true) => placementContext({ priv, stage: STAGE, editable, getChess, getItem, getToken });
|
||||
|
||||
@@ -245,17 +245,28 @@ 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
|
||||
test('only elite 歌蕾蒂娅 carrying HOK-Y lights the 高台; 崖心, 见行者, a normal record and other modules stay on the ground', () => {
|
||||
const HOK_Y = 'uniequip_003_glady';
|
||||
const gladE = piece('chess_char_4_12_b');
|
||||
const gladN = piece('chess_char_4_12_a');
|
||||
const cliff = piece('chess_char_2_03_b');
|
||||
const forcer = piece('chess_char_3_07_b');
|
||||
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`);
|
||||
const loadout = { chess_char_4_12_a: { module: HOK_Y } };
|
||||
const ctx = ctxFor(privWith({ hand: [gladE, gladN, cliff, forcer, m], loadout }));
|
||||
for (const [row, col] of [[10, 4], [11, 4], [12, 4], [9, 3]]) assert.equal(canPlace(ctx, gladE.uid, { area: 'board', row, col }).ok, true, `elite HOK-Y on ${row},${col}`);
|
||||
const lit = boardTargets(ctx, gladE.uid).legal.map(([a, b]) => tileKey(a, b));
|
||||
assert.equal(lit.length, STAGE.deployTiles.normal.melee.length + STAGE.deployTiles.normal.rangedOnly.length, 'every deploy tile lit');
|
||||
for (const p of [gladN, cliff, forcer, m]) {
|
||||
assert.deepEqual(canPlace(ctx, p.uid, { area: 'board', row: 10, col: 4 }), { ok: false, code: 'BAD_TILE', reason: '近战单位只能部署在地面' }, p.id);
|
||||
assert.equal(canPlace(ctx, p.uid, { area: 'board', row: 9, col: 3 }).ok, true, `${p.id} on the ground`);
|
||||
}
|
||||
assert.deepEqual(canPlace(ctx, m.uid, { area: 'board', row: 10, col: 4 }), { ok: false, code: 'BAD_TILE', reason: '近战单位只能部署在地面' });
|
||||
const none = ctxFor(privWith({ hand: [gladE], loadout: { chess_char_4_12_a: { module: 'none' } } }));
|
||||
assert.equal(canPlace(none, gladE.uid, { area: 'board', row: 10, col: 4 }).ok, false, 'no module');
|
||||
const xmod = ctxFor(privWith({ hand: [gladE], loadout: { chess_char_4_12_a: { module: 'uniequip_002_glady' } } }));
|
||||
assert.equal(canPlace(xmod, gladE.uid, { area: 'board', row: 10, col: 4 }).ok, false, 'HOK-X');
|
||||
const bare = ctxFor(privWith({ hand: [gladE] }));
|
||||
assert.equal(canPlace(bare, gladE.uid, { area: 'board', row: 10, col: 4 }).ok, false, 'default module is HOK-X');
|
||||
});
|
||||
test('not editable ⇒ nothing is legal', () => {
|
||||
const m = piece(MELEE);
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
// 折射 while silenced: the enemy card greys the SILENCE-format line; the status icon is mapped and not drawn
|
||||
// under silence. The sim already drops the RES bonus (enemies.js) — not asserted here.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { abilityRows } from '../../public/js/ui/abilityLines.js';
|
||||
import { statusIconKey, statusIconSuppressed } from '../../public/js/render/style.js';
|
||||
|
||||
const scout = JSON.parse(readFileSync(new URL('../../data/enemies.json', import.meta.url), 'utf8')).enemy_1166_dusbr;
|
||||
|
||||
test('折射: the card line greys only while silenced; the icon key exists and is suppressed under silence', () => {
|
||||
const on = abilityRows(scout.abilities, false);
|
||||
assert.equal(on.length, 1);
|
||||
assert.match(on[0].text, /折射/);
|
||||
assert.equal(on[0].off, false);
|
||||
const off = abilityRows(scout.abilities, true);
|
||||
assert.equal(off[0].off, true, 'grey the 折射 line');
|
||||
assert.equal(off[0].text, on[0].text, 'the words stay');
|
||||
assert.equal(abilityRows([{ text: '普通攻击', format: 'NONE' }], true)[0].off, false);
|
||||
assert.equal(abilityRows(['plain'], true)[0].off, false);
|
||||
assert.equal(statusIconKey('ab:refraction'), 'refraction');
|
||||
const both = new Set(['ab:refraction', 'silence']);
|
||||
assert.equal(statusIconSuppressed('ab:refraction', both), true, 'drop the icon while silenced');
|
||||
assert.equal(statusIconSuppressed('silence', both), false, 'the silence icon itself stays');
|
||||
assert.equal(statusIconSuppressed('ab:refraction', new Set(['ab:refraction'])), false, 'the icon shows while 折射 is on');
|
||||
});
|
||||
+1
-1
@@ -92,7 +92,7 @@ const frac = (x, rng) => Math.max(0, Math.floor(x + rng()));
|
||||
|
||||
// ---- chess helpers ----------------------------------------------------------------------------------------------
|
||||
const isHealer = (c) => !!c && (c.dmgType === 'heal' || c.attackKind === 'heal');
|
||||
// the record's own position (bot.js isBlocker): a 钩索师 / 推击手 (placement 'all', DESIGN §22.6) still blocks
|
||||
// the record's own position (bot.js isBlocker): a 钩索师 / 推击手 still blocks on a ground tile
|
||||
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';
|
||||
|
||||
+5
-20
@@ -744,22 +744,6 @@ 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模组的教官仍无法部署至高台位"). [ASSUMED] that the branch trait still applies in this prep: the 帮助 switches off
|
||||
* only 部署效果 and no source names the 钩索师 / 推击手 case for 卫戍协议 (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`).
|
||||
@@ -824,8 +808,9 @@ 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';
|
||||
// 高台 is not a data flag. The trait line 「可以放置于远程位」 is on every 钩索师 / 推击手 variant and is not
|
||||
// trusted: only elite 歌蕾蒂娅 carrying HOK-Y may stand there, decided at place time (shared/highGround.js,
|
||||
// owner's decision 2026-10-04, reversing DESIGN §22.6).
|
||||
|
||||
// Module (only active on golden chess: equipLevel > 0).
|
||||
const modId = shop.defaultUniEquipId || null;
|
||||
@@ -3158,8 +3143,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}`);
|
||||
// 高台 legality is loadout-aware (shared/highGround.js), never a field on the record
|
||||
if (c.placement !== undefined) err(`chess ${c.chessId}: placement is not a data field`);
|
||||
}
|
||||
// the deliberate trigger deviations (DESIGN §21.29, §22.10) still override an official TAKE_DAMAGE row, on the normal
|
||||
// chess and its elite alike
|
||||
|
||||
Reference in new issue
Block a user