feedback3: merge WH — ground operators are melee-position, 叙拉古 rolls on every normal hit, off bonds show as disabled, prep-end items merge next prep
This commit is contained in:
commit
1005754bf3
28 files changed
+746
-98
No files matched your search
+1
-1
@@ -1 +1 @@
|
||||
{"yanShip":{"bondId":"yanShip","name":"炎","identifier":1,"isCore":true,"bondType":"SEASON","bondOrder":2,"powerIdList":["yan","sui","lungmen","lee","lgd"],"iconId":"icon_yanShip","activeCount":3,"thresholds":[3,6,9],"maxCount":null,"thresholdTemplate":"count_threshold_upward","countMode":"BOARD","countsHand":false,"countsGoldenOnly":false,"activeType":"BATTLE","isActiveInDeck":false,"noStack":false,"weight":10,"maxInactiveBondCount":-1,"layerMilestones":[],"desc":"【炎】干员攻击力提升(受层数影响)\n<在场6名不同【炎】干员>战斗开始时召唤可自主行动的“炎佑”,其攻击力生命值分别为开战时【炎】干员攻击力生命值总和的30%,“炎佑”同时攻击3个目标,可使用“祛恶之焰”,攻击时附带攻击力一定比例的灼燃损伤且周围敌人受到一定比例的元素脆弱\n<在场9名不同【炎】干员>改为召唤2只“炎佑”,攻击力提升至1.5倍,受到的伤害-90%","descRaw":"【炎】干员<@ba.vup>攻击力</>提升<@autochess.gray>(受层数影响)</>\n<在场<@autochess.dgreen>6</>名不同【炎】干员>战斗开始时召唤可自主行动的<@ba.vup>“炎佑”</>,其攻击力生命值分别为开战时【炎】干员攻击力生命值总和的30%,“炎佑”同时攻击3个目标,可使用<@ba.vup>“祛恶之焰”</>,攻击时附带攻击力一定比例的灼燃损伤且周围敌人受到一定比例的元素脆弱\n<在场<@autochess.dgreen>9</>名不同【炎】干员>改为召唤2只<@ba.vup>“炎佑”</>,攻击力提升至<@ba.vup>1.5</>倍,受到的伤害<@ba.vup>-90%</>","effectId":"bondeffect_yan","effectName":"炎","effectDesc":"【炎】干员攻击力+{0:0%}(受层数影响)\n<在场6名不同【炎】干员>战斗开始时收集【炎】干员部分属性召唤“炎佑”(其攻击力和生命值为【炎】干员攻击力和生命值总和的30%);“炎佑”同时攻击3个目标,攻击附带20%攻击力的灼燃损伤,周围敌人受到20%的元素脆弱,周期性使用“祛恶之焰”\n<在场9名不同【炎】干员>改为召唤2只“炎佑”;“炎佑”攻击力提升至1.5倍,受到的伤害-90%","effectDescRaw":"【炎】干员攻击力<@ba.vup>+{0:0%}</><@ba.acrem>(受层数影响)</>\n<在场<@ba.vup>6</>名不同【炎】干员>战斗开始时收集【炎】干员部分属性召唤“炎佑”(其攻击力和生命值为【炎】干员攻击力和生命值总和的30%);“炎佑”同时攻击3个目标,攻击附带20%<@ba.vup>攻击力</>的<@ba.vup>灼燃损伤</>,周围敌人受到20%的<@ba.vup>元素脆弱</>,周期性使用“祛恶之焰”\n<在场<@ba.vup>9</>名不同【炎】干员>改为召唤2只“炎佑”;“炎佑”攻击力提升至1.5倍,受到的伤害-90%","effectDescParams":[{"index":0,"format":"0%","base":"base_atk","perStack":"atk_per_stack"}],"bb":{"base_atk":0.23,"atk_per_stack":0.009,"power_bond_char_cnt":6,"ex_bond_char_cnt":9,"damage_resistance":0.9,"atk":1.5},"bbStr":{"key":"act1autochess_bond_eff_yan"},"buffs":[{"key":"env_gbuff_new","bb":{"base_atk":0.23,"atk_per_stack":0.009,"power_bond_char_cnt":6,"ex_bond_char_cnt":9,"damage_resistance":0.9,"atk":1.5},"bbStr":{"key":"act1autochess_bond_eff_yan"}}],"baseParams":["base_atk"],"perStackParams":["atk_per_stack"],"members":["chess_char_1_03_a","chess_char_2_04_a","chess_char_3_03_a","chess_char_3_04_a","chess_char_4_15_a","chess_char_4_17_a","chess_char_5_03_a","chess_char_5_12_a","chess_char_5_23_a","chess_char_6_03_a","chess_char_6_15_a"],"visibleMembers":["chess_char_1_03_a","chess_char_2_04_a","chess_char_3_03_a","chess_char_3_04_a","chess_char_4_17_a","chess_char_5_03_a","chess_char_5_12_a","chess_char_5_23_a","chess_char_6_03_a","chess_char_6_15_a"],"spec":{"tiers":[{"need":"3 distinct 炎 on board","effect":"炎 members (+调和 members) ATK x(1 + 0.23 + 0.009*L)."},{"need":"6 distinct","effect":"At battle start spawn 1 \"炎佑\" (enemy_9012_acloon template, allied flying ranged unit, not blockable, moves on its own). ATK = 0.30 x sum(ATK of 炎 operators on board at battle start), maxHP = 0.30 x sum(maxHP of those operators). Template: DEF 200, RES 0, BAT 2.5 s, ASPD 100, attack range radius 2.0 tiles, magic damage, hits up to 3 targets; every hit adds 灼燃损伤 (burn elemental damage) = 20% of its ATK and applies 元素脆弱 (elemental damage taken x1.2) to enemies within 0.8 tiles of the target. Skill \"祛恶之焰\": cooldown 15 s (first cast 15 s after spawn), channels a flame on one enemy in range dealing 60% ATK magic damage per second to it and to enemies within 1.0 tile, for up to 20 s."},{"need":"9 distinct","effect":"Spawn 2 炎佑 instead; each has ATK x1.5 and takes 90% less damage (damage_resistance 0.9)."}],"formulas":{"atkMultiplier":"1 + 0.23 + 0.009*L"},"caps":"none","layerGain":"Economy-driven: 惊蛰 获得时 +shop level (no activation needed); 小满 休整期结束 +1 per operator gained this rouLine truncated
|
||||
{"yanShip":{"bondId":"yanShip","name":"炎","identifier":1,"isCore":true,"bondType":"SEASON","bondOrder":2,"powerIdList":["yan","sui","lungmen","lee","lgd"],"iconId":"icon_yanShip","activeCount":3,"thresholds":[3,6,9],"maxCount":null,"thresholdTemplate":"count_threshold_upward","countMode":"BOARD","countsHand":false,"countsGoldenOnly":false,"activeType":"BATTLE","isActiveInDeck":false,"noStack":false,"weight":10,"maxInactiveBondCount":-1,"layerMilestones":[],"desc":"【炎】干员攻击力提升(受层数影响)\n<在场6名不同【炎】干员>战斗开始时召唤可自主行动的“炎佑”,其攻击力生命值分别为开战时【炎】干员攻击力生命值总和的30%,“炎佑”同时攻击3个目标,可使用“祛恶之焰”,攻击时附带攻击力一定比例的灼燃损伤且周围敌人受到一定比例的元素脆弱\n<在场9名不同【炎】干员>改为召唤2只“炎佑”,攻击力提升至1.5倍,受到的伤害-90%","descRaw":"【炎】干员<@ba.vup>攻击力</>提升<@autochess.gray>(受层数影响)</>\n<在场<@autochess.dgreen>6</>名不同【炎】干员>战斗开始时召唤可自主行动的<@ba.vup>“炎佑”</>,其攻击力生命值分别为开战时【炎】干员攻击力生命值总和的30%,“炎佑”同时攻击3个目标,可使用<@ba.vup>“祛恶之焰”</>,攻击时附带攻击力一定比例的灼燃损伤且周围敌人受到一定比例的元素脆弱\n<在场<@autochess.dgreen>9</>名不同【炎】干员>改为召唤2只<@ba.vup>“炎佑”</>,攻击力提升至<@ba.vup>1.5</>倍,受到的伤害<@ba.vup>-90%</>","effectId":"bondeffect_yan","effectName":"炎","effectDesc":"【炎】干员攻击力+{0:0%}(受层数影响)\n<在场6名不同【炎】干员>战斗开始时收集【炎】干员部分属性召唤“炎佑”(其攻击力和生命值为【炎】干员攻击力和生命值总和的30%);“炎佑”同时攻击3个目标,攻击附带20%攻击力的灼燃损伤,周围敌人受到20%的元素脆弱,周期性使用“祛恶之焰”\n<在场9名不同【炎】干员>改为召唤2只“炎佑”;“炎佑”攻击力提升至1.5倍,受到的伤害-90%","effectDescRaw":"【炎】干员攻击力<@ba.vup>+{0:0%}</><@ba.acrem>(受层数影响)</>\n<在场<@ba.vup>6</>名不同【炎】干员>战斗开始时收集【炎】干员部分属性召唤“炎佑”(其攻击力和生命值为【炎】干员攻击力和生命值总和的30%);“炎佑”同时攻击3个目标,攻击附带20%<@ba.vup>攻击力</>的<@ba.vup>灼燃损伤</>,周围敌人受到20%的<@ba.vup>元素脆弱</>,周期性使用“祛恶之焰”\n<在场<@ba.vup>9</>名不同【炎】干员>改为召唤2只“炎佑”;“炎佑”攻击力提升至1.5倍,受到的伤害-90%","effectDescParams":[{"index":0,"format":"0%","base":"base_atk","perStack":"atk_per_stack"}],"bb":{"base_atk":0.23,"atk_per_stack":0.009,"power_bond_char_cnt":6,"ex_bond_char_cnt":9,"damage_resistance":0.9,"atk":1.5},"bbStr":{"key":"act1autochess_bond_eff_yan"},"buffs":[{"key":"env_gbuff_new","bb":{"base_atk":0.23,"atk_per_stack":0.009,"power_bond_char_cnt":6,"ex_bond_char_cnt":9,"damage_resistance":0.9,"atk":1.5},"bbStr":{"key":"act1autochess_bond_eff_yan"}}],"baseParams":["base_atk"],"perStackParams":["atk_per_stack"],"members":["chess_char_1_03_a","chess_char_2_04_a","chess_char_3_03_a","chess_char_3_04_a","chess_char_4_15_a","chess_char_4_17_a","chess_char_5_03_a","chess_char_5_12_a","chess_char_5_23_a","chess_char_6_03_a","chess_char_6_15_a"],"visibleMembers":["chess_char_1_03_a","chess_char_2_04_a","chess_char_3_03_a","chess_char_3_04_a","chess_char_4_17_a","chess_char_5_03_a","chess_char_5_12_a","chess_char_5_23_a","chess_char_6_03_a","chess_char_6_15_a"],"spec":{"tiers":[{"need":"3 distinct 炎 on board","effect":"炎 members (+调和 members) ATK x(1 + 0.23 + 0.009*L)."},{"need":"6 distinct","effect":"At battle start spawn 1 \"炎佑\" (enemy_9012_acloon template, allied flying ranged unit, not blockable, moves on its own). ATK = 0.30 x sum(ATK of 炎 operators on board at battle start), maxHP = 0.30 x sum(maxHP of those operators). Template: DEF 200, RES 0, BAT 2.5 s, ASPD 100, attack range radius 2.0 tiles, magic damage, hits up to 3 targets; every hit adds 灼燃损伤 (burn elemental damage) = 20% of its ATK and applies 元素脆弱 (elemental damage taken x1.2) to enemies within 0.8 tiles of the target. Skill \"祛恶之焰\": cooldown 15 s (first cast 15 s after spawn), channels a flame on one enemy in range dealing 60% ATK magic damage per second to it and to enemies within 1.0 tile, for up to 20 s."},{"need":"9 distinct","effect":"Spawn 2 炎佑 instead; each has ATK x1.5 and takes 90% less damage (damage_resistance 0.9)."}],"formulas":{"atkMultiplier":"1 + 0.23 + 0.009*L"},"caps":"none","layerGain":"Economy-driven: 惊蛰 获得时 +shop level (no activation needed); 小满 休整期结束 +1 per operator gained this rouLine truncated
|
||||
+4
-4
@@ -442,7 +442,7 @@ S→C:
|
||||
temp: [ Piece | null ], // length 5
|
||||
board: [ Piece & { row, col, dir } ], // dir ∈ UP|RIGHT|DOWN|LEFT (§3; bench pieces carry none)
|
||||
deployCap, deployCount,
|
||||
bonds: [ { bondId, count, active, tier, layers, harmony? /* 调和's +1 is in count, §21.26 */, thresholds, countsHand } ], // sorted: active first, then layers desc
|
||||
bonds: [ { bondId, count, active, tier, layers, harmony? /* 调和's +1 is in count, §21.26 */, thresholds, countsHand } ], // sorted: active first, then layers desc; an `off` entry (a bond the mode never activates, with members: count only, never active — the strip's grey 本局禁用 disc, bondsMeta.offBondCounts) comes last
|
||||
effects: [ { id, name, desc, iconKind: 'band'|'choice'|'team'|'item'|'garrison', iconId, counter?, counterText? /* a bounty: 还剩 N 场作战, §20.6 */ } ],
|
||||
nextEnemies: [ { enemyKey, count, tag, start? } ], // preview of the upcoming round's wave (after waves.js generation); `start` = a leader's spawn tile [row, col] on the boss field (the boss-field prep shows it standing there, community report #12)
|
||||
stats: { dmgDealt, kills, leaks, gold, refreshes, merges } }
|
||||
@@ -1111,7 +1111,7 @@ In SETTLE it is the field the last battle left on screen. In prep without watchi
|
||||
- `ps.bonds` / `ps.layers` are untouched. SETTLE clears the pending gains as it adds them (once), and the next round start clears them too. Since 0.1.3 the 联防 field fights with the same counts: the helpers' battle input takes its bonds from `bondsView()` (`battleInput({ reached: true })`, PRTS §联防阶段 "将以其阵地当前的状态" [ASSUMED] — it used to carry the round-start layers while the strip showed the reached ones); no other rule reads them.
|
||||
|
||||
**Data:**
|
||||
- `m.public players[].bonds = ps.alive ? bondList(gd, ps.bondsView()) : []`: every bond with members, layers or an active tier. It is the same list and order as the player's own `m.private bonds`, minus `thresholds` / `countsHand` (the client reads those from bonds.json).
|
||||
- `m.public players[].bonds = ps.alive ? bondList(gd, ps.bondsView(), { off: offBondCounts(gd, ps) }) : []`: every bond with members, layers or an active tier (since 0.1.3 also the mode-off bonds with members, `off: true`, last). It is the same list and order as the player's own `m.private bonds`, minus `thresholds` / `countsHand` (the client reads those from bonds.json).
|
||||
- An eliminated player's list is `[]`, for these reasons:
|
||||
- nobody can watch them: `g.watch` refuses them and they have no field;
|
||||
- the result screen reads `m.result`'s own bonds;
|
||||
@@ -1539,7 +1539,7 @@ Public GitHub issues filed after the 0.1.1 release (2026-10-03), each triaged ag
|
||||
- **Now**: build-data sets `placement: 'all'` on a chess whose branch trait without module (`branchTraitText`: the trait candidate's text, else the description) contains 可以放置于远程位 — exactly 崖心, 见行者 and 歌蕾蒂娅, normal and elite (6 records, the only data change; `validateAll` allows it on a MELEE record only; module-only text never counts). `positionClass` returns 'all' for them, so the server takes them on every deployable tile — `g.move`, swaps, a merge's elite (`mergeTile`), the terrain re-check (`_evictIllegal`), invariants and audit —, and the client's `piecePosition` 'ALL' lights the 高台 while dragging; every other melee unit keeps the refusal and its text. The battle keeps position MELEE: on a ground tile they block as before; on a 高台 (`unit.ground` false) they block nothing and attack from there — the sim needed no change (act2 m01, one 萨卡兹枯朽前锋 on the lower lane, 40 s: 歌蕾蒂娅 on (10,4) facing it 2 hits, no block, on (9,5) 12 hits, blocking; 崖心 and 见行者 alike; no errors).
|
||||
- **Bots** read the record's own position (`basePositionClass`): they still count these units as blockers (`isBlocker`; with the widened class they would not, and on act2 m01 the planner put 歌蕾蒂娅 and 崖心 on the 高台 as non-blockers, leaving 见行者 the only blocker on the road) and plan them on the ground tiles, as in 0.1.1 [ASSUMED]: offered the 高台, the planner still chose road tiles in the cases measured, but the lineup's blocker count assumes they block and a greedy 高台 pick could take a ranged dealer's tile — not benchmarked. `tools/balance.mjs` uses the same blocker test. Tokens are unchanged; the sim's automatic placements (the 突袭 landing tile, summon / device tiles) still treat them as melee, ground tiles only [ASSUMED].
|
||||
- **Players notice**: 歌蕾蒂娅 can be dragged onto a 高台 (PLAYING §4); there she attacks but blocks nothing. 崖心 and 见行者 get the same rule but are hidden chess this season (`isHidden`, never in the pool, the shop or a 突变细胞 roll), so only 歌蕾蒂娅 is reachable in play; 见行者's elite PUS-X refund (half the DP of a redeploy onto a ranged tile, kits/tier3.js `!unit.ground`) is therefore moot.
|
||||
- On a 高台 she is not a 地面干员 for the effects that read the tile (`unit.ground`: the 战栗维式重锤 proc, 休谟斯 回收利用, 不屈, the 只攻击地面单位 enemy, 污染秽蚀's 高地 rate), like a ranged operator up there; position-based effects keep her 近战 (精准 tier 2, 诗怀雅's talent) [ASSUMED: the official texts may mean the position class by 地面干员].
|
||||
- On a 高台 the effects that read the tile (`unit.ground`: the 只攻击地面单位 enemy, 污染秽蚀's 高地 rate) treat her like a ranged operator up there; position-based effects keep her 近战 (精准 tier 2, 诗怀雅's talent) — since 0.1.3 the effects worded 地面干员 too (不屈, the 战栗维式重锤 proc, 休谟斯 回收利用: 地面干员 = the melee position on any tile, see the §23 「地面干员」 subsection; the tile reading 0.1.2 [ASSUMED] here was wrong).
|
||||
- Normative lines rewritten with it: §3 (placement), §6.6 (the bot); research 03 C2; DATA §2 (`placement`).
|
||||
- Tests: `test/match/feedback1-placement.test.js` (#32-4: the data rule, cross-checked against character_table when the cache exists; `g.move` onto (10,4) / (11,4) / (12,4) of 战场#01(下半) for the six records; a 重装 refused with its text; ranged unchanged; client highlights = the server's tiles; swaps; the merge's elite on the 高台; the battle input and the sim on / off the 高台; bots), `test/match/board.test.js` (`positionClass` / `basePositionClass`), `test/ui/gameLogic.test.js` (the client mirror).
|
||||
|
||||
@@ -1569,7 +1569,7 @@ Public GitHub issues filed after the 0.1.1 release (2026-10-03), each triaged ag
|
||||
- **Seen** (GitHub #32, item 7): 「易拉三技能浮游单元无法造成伤害,只能造成三技能自带dot伤害」 — 荒芜拉普兰德's S3 终幕·浩劫 dealt only its once-per-second area damage.
|
||||
- **Official**: PRTS 荒芜拉普兰德 S3 备注, 技能流程: ① for 1.3 s after the cast or after a drone is added ("开启技能/补充浮游单元1.3s内") the drones spread evenly outward from her, one along her facing (初速度 0.1, 加速度 1.9, 最大速度 2.0); ② each then locks the target "距离自身最近>距离本体最近" anywhere on the field and flies at it (初速度 2.0, 加速度 1.0, 最大速度 4.0, 转向速度 1/6 per frame; a target that leaves or turns unselectable on the way → a new pick, the speed back to 2.0); ③ at the target it "锁定目标持续进行攻击,此状态下的攻击行为同正常浮游单元"; when that target leaves or turns unselectable it reappears at a random point of the 1.5-side square around it and picks again; with nothing selectable it circles (radius 0.9, 1.0 线速度, counter-clockwise, the circle on its left). Also: "该技能释放的浮游单元造成的伤害不属于普通攻击/技能直接伤害"; the area damage / slow has radius 0.9, does not stack and carries 头狼's 沉默. PRTS 分支特性信息 驭械术师: a drone starts at 20 % of the operator's ATK, +15 per hit on the target it hit last, up to 110 %, back to the start on a different target; drones released by a skill "不受缴械类效果制约" — the same note says they "造成技能直接伤害", which S3's own 备注 overrides for this skill. skill_table skchr_whitw2_3 at the chess levels (Lv4 / Lv7): atk +0.45 / +0.6, attack@cnt 2, attack@times 1.3 (PRTS ①'s 1.3 s), attack@move_speed −0.3 / −0.4, attack@magic_atk_scale 1, attack@fear 2, attack@range_radius 0.9, attack@projectile_turn_speed 5 (× 1/30 s = PRTS's 1/6 per frame); trait init 0.2 / 0.35 (elite: module 狼时), delta 0.15, max 1.1; 头狼 every 20 s: cap × 1.1 (PRTS 备注: 121 %), 2 s 沉默, 数量+1.
|
||||
- **Cause**: the S3 spec switched her attack off for the whole skill (`attack: { noAttack: true }`) and its tick only moved the drones (at attack@projectile_move_speed, 1 tile/s), feared a target the drone came within 0.9 of and dealt the area hit; no drone ever attacked. The kit's header said so ("she stops attacking while they roam"). The drones also preferred enemies no other drone held — an [ASSUMED] rule PRTS does not have.
|
||||
- **Now**: her own attack stays off — every drone is out (浮游单元 +attack@cnt with her own one; +1 from 头狼 stage 3, and a drone added when stage 3 comes during the skill spreads along her facing). The drones fly ① ② ③ with PRTS's numbers (spread for attack@times s; the distance covered at the mean of the old and new speed, exact under constant acceleration; the reappearance point is drawn in her facing frame, so a battle turned with her direction plays the same). A drone that reaches its target fears it (attack@fear s) and stays on it; it attacks every attack interval of hers (her live ASPD) for ATK × its own funnel ramp (trait init, +delta per hit on the same target, the cap — raised by 头狼 stage 1 —, back to init on a new target), arts. That damage is neither a normal attack nor skill damage: no `attack` hook, `isAttack` / `isSkill` false, tag `droneAttack` — the 叙拉古 6 assassin chance ("对敌人攻击时") and the on-attack items skip it, 头狼's 沉默 ("造成伤害时") applies; 缴械 does not stop it. The once-per-second area hit and the slow are unchanged (around every drone, one hit per enemy; tags `skill` + `drone`). A wolf knocked out or withdrawn in the middle of a tick (a kill hook, say) deals nothing more in that tick. fx: `droneLock` (the wolf-eye mark) when a drone picks its target, `drone` at a drone for each of its attacks and once per second for drones still roaming; the client draws them already.
|
||||
- **Now**: her own attack stays off — every drone is out (浮游单元 +attack@cnt with her own one; +1 from 头狼 stage 3, and a drone added when stage 3 comes during the skill spreads along her facing). The drones fly ① ② ③ with PRTS's numbers (spread for attack@times s; the distance covered at the mean of the old and new speed, exact under constant acceleration; the reappearance point is drawn in her facing frame, so a battle turned with her direction plays the same). A drone that reaches its target fears it (attack@fear s) and stays on it; it attacks every attack interval of hers (her live ASPD) for ATK × its own funnel ramp (trait init, +delta per hit on the same target, the cap — raised by 头狼 stage 1 —, back to init on a new target), arts. That damage is neither a normal attack nor skill damage: no `attack` hook, `isAttack` / `isSkill` false, tag `droneAttack` — the on-attack items skip it (the 叙拉古 6 chance rolls on it since 0.1.3: 普通伤害, see the §23 「叙拉古」 subsection), 头狼's 沉默 ("造成伤害时") applies; 缴械 does not stop it. The once-per-second area hit and the slow are unchanged (around every drone, one hit per enemy; tags `skill` + `drone`, + `dot` since 0.1.3: PRTS 备注 "持续法术伤害"). A wolf knocked out or withdrawn in the middle of a tick (a kill hook, say) deals nothing more in that tick. fx: `droneLock` (the wolf-eye mark) when a drone picks its target, `drone` at a drone for each of its attacks and once per second for drones still roaming; the client draws them already.
|
||||
- **Distance to a huge enemy** [ASSUMED] — the owner's decision of 2026-10-04: a drone measures every enemy's distance to its position (a huge enemy's centre, its 判定中心), so leader rounds stay close to 0.1.1. Considered and set aside: the sim's general convention for operator-side distance picks, the hit rectangle (body.js `bodyDist`: `targeting.js:135` `sortEnemyTargets` 'nearest' / 'farthest', `kits/tier3.js:86` `enemiesAround`, `kits/tier6.js:1108` 异客's storm chain, `kits/tier6.js:2525` / `:2549` 溯光星源's bounces and links, `kits/tier4.js:526` / `:995`; only the profession chain bounce, `ai.js:257`, takes the nearest by position) — with it a huge leader (胄, 管) was the nearest enemy of every drone around it and drew them all for the whole skill (胄 h07_01: her damage on the leader 106 309, on the other enemies 3 036, 2 deaths). A drone on a huge enemy sits at its centre; it reaches it on touching the hit rectangle.
|
||||
- **Players notice**: once a drone reaches a target that stays alive, its hits outweigh the area damage (3 parked 重装防御者, RES 0, the full 40 s: drone hits 38 411 of 54 640); against streams of weak enemies the area hit still does most of it (the flat stage's two lanes, 10 + 10 walkers, 40 s: drones 11 049, area 50 188 of 61 236). All drones may lock the same enemy — each takes the one nearest to itself — so against a cluster the area hit covers fewer enemies than before. Measured, old → new: parked, first 12 s normal 12 901 → 12 089 (drone hits 7 512 + area 4 578), elite 17 658 → 18 085; the whole 40 s 47 857 → 54 640 and 65 504 → 77 369; the lanes 15 481 → 61 236 (kills 1 → 14), elite 49 213 → 72 224 (9 → 18). **Leader rounds** (one elite 荒芜拉普兰德 per side, S3 at once, kept alive, 60 s; her damage on the leader / on the other enemies / enemy deaths, 0.1.1 → now): 假想敌:胄 (act1 h07_01) 32 467 / 80 186 / 9 → 84 880 / 44 391 / 6; 假想敌:管 (h07_03) 19 936 / 62 176 / 5 → 37 520 / 69 927 / 5; 假想敌:铳 (h07_02) 31 260 / 49 982 / 4 → 62 794 / 30 221 / 0; 卢西恩 (act2 h07_05) 37 957 / 58 512 / 6 → 75 094 / 40 881 / 2 (both of normal size: a drone that picks the leader because it is nearest keeps the lock, and a leader never falls); the heavy two-player 胄 scene of robustness.test.js (the review bench: 4 wolves per side, 120 extra enemies at ×20 HP, 60 s): the wolves' damage 1 315 038 → 1 291 567, on the leader 50 170 → 42 907, enemies killed 12 → 13. With the hit-rectangle distance it would have been 胄 106 309 / 3 036 / 2, 管 57 202 / 36 427 / 2 and the heavy scene 406 075 / 125 271 / 2.
|
||||
- [ASSUMED]: the centre distance above (the owner's decision); reading ① "补充浮游单元" as: after an addition only the added drone spreads, along her facing, while the others carry on; the turn rate (转向速度) is not modelled — a chasing drone heads straight at its target; a drone's attack clock runs all the time and its first hit lands on arrival when the clock is ready; the drones keep flying and attacking while she is stunned, frozen or silenced (PRTS only names 缴械); a drone reaches a huge enemy when it touches the hit rectangle and then sits at its position; the fear's source is her (they flee from her, as before); the area hit keeps `isSkill` (a skill DoT; PRTS's line speaks of 直接伤害).
|
||||
|
||||
+4
-2
@@ -431,7 +431,7 @@ otherwise locked (research 04 §2 / addendum: they leave the operator only on pr
|
||||
refuses them (`BAD_TARGET 'equipped items are locked'`). A second copy of an equipped normal item merges into the golden
|
||||
item in the hand. Every path that hands an item to the player (buy, reward, 机变, grants, equip, the equipment a sale,
|
||||
a promotion or `ctx.destroyPiece` returns) ends with the auto-merge (`acquireItem` / `checkItemMerges`: "已拥有2件同一初始
|
||||
装备时…自动合并"), so a player never holds two identical mergeable normal items (`test/match/feedback1b-items.test.js`).
|
||||
装备时…自动合并"), so a player never holds two identical mergeable normal items (`test/match/feedback1b-items.test.js`), except an item gained while 休整期结束 is dispatching (`onPrepEnd`, including a grant nested under it): it is stowed (hand, else temp) and merges at the next prep's start, and nothing already equipped is taken off for the fight. Hand and temp both full still destroys it with 「整备区已满,获得的装备已销毁」. [ASSUMED] every such grant, not only 维多利亚's hammer (owner's decision 2026-10-04). A buy, an onPrepStart grant and a grant at any other time still merge at once (`test/match/feedback3-prep-end-item.test.js`).
|
||||
|
||||
Built-ins (builtinMeta.js, overridable): 盟约之币 / 骑士储蓄罐 (random funds), 随身身份牌 (layers of the target's bonds),
|
||||
紧急调度券 (take shop chess), 精打细算玩偶 (+funds each round), 简易通讯机 / 拟态物质 (same-bond chess), 见钱眼开玩偶
|
||||
@@ -683,7 +683,9 @@ drawn set; `disabledBonds` = drawn ∪ the mode's static list), `hiddenBossId`,
|
||||
`combatMode` (`'client'` | `'server'`), `fields[].progress { killed, total, done }` (teammates' progress UI), `paused`
|
||||
(solo pause, §1.3a),
|
||||
`players[].autoplay`, `players[].uniteLeft` (UNITE, leakers only: their enemies still standing, uncapped — §4),
|
||||
`players[].bonds` = `ps.alive ? bondList(gd, ps.bondsView()) : []` — every bond with members, layers or an active tier, the same list
|
||||
`players[].bonds` = `ps.alive ? bondList(gd, ps.bondsView(), { off: offBondCounts(gd, ps) }) : []` — every bond with members, layers or an active tier
|
||||
(and, last, every bond the mode never activates that the player has members of: `{ bondId, count, active: false, tier: 0, layers, off: true }`,
|
||||
the strip's grey 本局禁用 disc — bondsMeta.offBondCounts, never in the battle input), the same list
|
||||
and order as the player's own `m.private bonds` minus `thresholds` / `countsHand` (the client reads those from
|
||||
bonds.json; an entry whose count holds 调和's +1 carries `harmony: 1` in both lists — the bond popup's 调和 row, DESIGN
|
||||
§21.26): a teammate watching the player shows it in the bond strip (DESIGN §20.15); `[]` once the player is
|
||||
|
||||
+2
-1
@@ -96,12 +96,13 @@
|
||||
|
||||
- **核心盟约**(8 个势力:炎、萨尔贡、维多利亚、谢拉格、拉特兰、阿戈尔、叙拉古、卡西米尔):3 名激活,6 名获得强力的额外效果。
|
||||
- **附加盟约**(精准、迅捷、灵巧、奥术、坚守、助力、远见、奇迹、投资人、突袭、不屈、调和、协防干员、独行、绝技):多为 2 / 3 名激活;「独行」需要场上**恰好 1 名**,「绝技」按场上的精锐数量计算,没有势力盟约的干员算「协防干员」。
|
||||
- **本局禁用**:「标准模拟」与官方相同,关闭拉特兰、阿戈尔、卡西米尔、灵巧、奥术、奇迹、投资人、突袭、独行、绝技这 10 个盟约,它们不会激活。你有这些盟约的成员时(奇迹、投资人也算整备区里的成员),盟约栏末尾显示**灰色带 ✕ 的盟约图标和「本局禁用」**,干员卡、盟约标签和盟约详情也这样标注。「远见」「奇迹」「投资人」计入整备区,盟约详情的「成员」人数也一样计入整备区里的成员(临时整备区不算)。
|
||||
- **调和**:激活时,场上已有成员的核心盟约人数各 +1(只加 1 人,不论有几名调和干员)。盟约详情会写「含调和 +1」,成员列表上方列出带来这 1 人的调和干员(如缪尔赛思)。
|
||||
- **炎佑**:场上 6 名以上「炎」干员时召唤炎佑(9 名时两只)。它追踪全场仇恨最高的敌人并悬停在其上方,技能「祛恶之焰」锁定 1 个目标**持续喷火**(最长 20 秒,每秒对目标周围的敌人造成法术伤害并附带灼燃,喷火时不普攻,目标死亡或被沉默时结束);场上暂时没有敌人时停在原地等待。它持有官方的「孤立」:医疗干员不会治疗它,友方的增益、光环和天赋也不会选中它(敌人照常可以攻击它)。
|
||||
- **层数**:盟约通过各种效果叠加层数,层数越高效果越强(例如「炎」攻击力随层数提升、「远见」每 10 层给资金)。**层数整局保留,但只有盟约激活时才生效**,多数情况下也只有激活的盟约能叠层。每个盟约最多 **999 层**(与官方相同,取自官方客户端的常量):到 999 后再获得的层数直接作废,按层数发放的奖励(如「远见」的资金)也随之停止。作战中获得的层数在盟约栏里**实时**显示,作战结束后一直保留到结算(联防期间也不会退回去)。
|
||||
- **加成怎么叠**:盟约、策略、装备、机变卡和特质给的「攻击力 / 防御力 / 生命值 +X%」与技能的「攻击力 +X%」**相加**后再乘到属性上(官方:直接乘算),例如精准 +130%、独行 +60%、一件 +40% 的装备合计 +230%,而不是 ×2.3×1.6×1.4。「伤害提升至 X%」「受到的伤害 +X%」(维多利亚、谢拉格、奥术、脆弱等)这类伤害倍率之间仍然相乘;但同名效果只算一个——同一战场两名玩家都开了「奥术」时,敌人身上只挂一层奥术易伤,取两人中较高的那一个(不再相乘);两名「灵知」的坚冰、两名「莱恩哈特」/「缄默德克萨斯」的法抗削减也一样只算最强的一个。「坚守」的反伤计入被打的那名成员(个人对领袖伤害统计里能看到)。
|
||||
- 每局会随机**禁用**若干盟约(标准模拟较少),它们的干员不会出现在商店;开局的「确认本局信息」会列出,选择策略时(「查看禁用盟约与干员」)和对局中(左上角 🔍)也能再查看。
|
||||
- 两件同名装备会合成一件更强的装备;特定的装备组合可以为干员赋予额外的盟约效果:「变形同构体」与另一件盟约装备(如维式重锤)一同携带时,携带者视为该装备对应盟约的成员。对应关系见变形同构体详情卡的「天赋」一栏(本局禁用的盟约会标出;装在干员身上时,正在生效的那一组会高亮)。
|
||||
- 两件同名装备会合成一件更强的装备。休整期结束时才到手的那一件(维多利亚每 25 层发的维式重锤是这种情况)先留在整备区,已经戴在干员身上的不会为了这场战斗被卸下,合成出的进阶装备在**下一休整期开始**时出现;其他时候拿到的同名装备仍立刻合成。整备区和临时整备区都满时,这件新装备照旧销毁(「整备区已满,获得的装备已销毁」),不会换进装备栏。特定的装备组合可以为干员赋予额外的盟约效果:「变形同构体」与另一件盟约装备(如维式重锤)一同携带时,携带者视为该装备对应盟约的成员。对应关系见变形同构体详情卡的「天赋」一栏(本局禁用的盟约会标出;装在干员身上时,正在生效的那一组会高亮)。
|
||||
|
||||
## 7. 最终攻势与隐秘核心
|
||||
|
||||
|
||||
@@ -3152,7 +3152,7 @@
|
||||
},
|
||||
{
|
||||
"need": "6 distinct",
|
||||
"effect": "After deployment also 隐匿 (camouflage) for the same duration. While camouflaged and for 10 s after it ends, each normal damage instance it deals may add (5000 + 50*L) true damage (source = the operator) and 恐惧 (fear) 3 s. Nominal 3% chance, implemented as pseudo-random: all 叙拉古 members share one counter; attempt n since last proc succeeds with p = 0.00139*n (guaranteed at n = 720); counter resets on proc."
|
||||
"effect": "After deployment also 隐匿 (camouflage) for the same duration. While camouflaged and for 10 s after it ends, each normal damage instance (普通伤害 = attack type NORMAL: attacks, skill hits, drone attacks; not 溅射 / 持续 / 附加 — `bonds/core.js siracusaRolls`) it deals may add (5000 + 50*L) true damage (source = the operator) and 恐惧 (fear) 3 s. Nominal 3% chance, implemented as pseudo-random: all 叙拉古 members share one counter; attempt n since last proc succeeds with p = 0.00139*n (guaranteed at n = 720); counter resets on proc."
|
||||
}
|
||||
],
|
||||
"formulas": {
|
||||
@@ -8796,11 +8796,11 @@
|
||||
"tiers": [
|
||||
{
|
||||
"need": "2 distinct",
|
||||
"effect": "When a ground operator is knocked out / retreats / swaps 替身<->本体: with p = min(1, 0.18 + 0.004*L) it is redeployed at once (next deployment has 0 redeploy time and 0 cost). Also consumes a \"复活\" charge if it had one."
|
||||
"effect": "When a ground operator (地面干员 = melee position, on any tile) is knocked out / retreats / swaps 替身<->本体: with p = min(1, 0.18 + 0.004*L) it is redeployed at once (next deployment has 0 redeploy time and 0 cost). Also consumes a \"复活\" charge if it had one."
|
||||
},
|
||||
{
|
||||
"need": "3 distinct",
|
||||
"effect": "When a ground operator is knocked out: every operator on the field +5 SP."
|
||||
"effect": "When a ground (melee-position) operator is knocked out: every operator on the field +5 SP."
|
||||
}
|
||||
],
|
||||
"formulas": {
|
||||
|
||||
@@ -425,7 +425,7 @@ Members (9 chess, 9 in current shop pool; by tier in shop: {'1': 1, '2': 1, '3':
|
||||
**Implementable spec**
|
||||
|
||||
- **[3 distinct]** After every deployment (incl. redeploy) a 叙拉古 member gains ASPD +(25 + 0.8*L) for (32 + 0.4*L) s.
|
||||
- **[6 distinct]** After deployment also 隐匿 (camouflage) for the same duration. While camouflaged and for 10 s after it ends, each normal damage instance it deals may add (5000 + 50*L) true damage (source = the operator) and 恐惧 (fear) 3 s. Nominal 3% chance, implemented as pseudo-random: all 叙拉古 members share one counter; attempt n since last proc succeeds with p = 0.00139*n (guaranteed at n = 720); counter resets on proc.
|
||||
- **[6 distinct]** After deployment also 隐匿 (camouflage) for the same duration. While camouflaged and for 10 s after it ends, each normal damage instance (普通伤害 = attack type NORMAL: attacks, skill hits, drone attacks; not 溅射 / 持续 / 附加 — `bonds/core.js siracusaRolls`) it deals may add (5000 + 50*L) true damage (source = the operator) and 恐惧 (fear) 3 s. Nominal 3% chance, implemented as pseudo-random: all 叙拉古 members share one counter; attempt n since last proc succeeds with p = 0.00139*n (guaranteed at n = 720); counter resets on proc.
|
||||
- Formulas: `aspd = 25 + 0.8*L`; `durationSec = 32 + 0.4*L`; `procDamage = 5000 + 50*L`; `prdStep = 0.00139`
|
||||
- Caps: none
|
||||
- How layers are gained: Shop refresh-driven: 拉普兰德 first manual refresh each round +4 (works from bench); 安洁莉娜 休整期结束 +4 per refresh this round (<=12/round); 阿罗玛 +2 叙拉古/奥术 per refresh (<=6); 伺夜 first 3 kills +2 叙拉古 +1 奇迹; 荒芜拉普兰德 each kill +2 (elite: every 叙拉古 gets this, <=100/battle); 忍冬 获得时 +6 own bonds (x2/x3 with 投资人); 机变 "德克萨斯的盟誓" +10. Strategy 贾维: every 6 manual refreshes get a free 叙拉古 (<=2/round).
|
||||
@@ -963,8 +963,8 @@ Members (9 chess, 9 in current shop pool; by tier in shop: {'1': 1, '2': 1, '3':
|
||||
|
||||
**Implementable spec**
|
||||
|
||||
- **[2 distinct]** When a ground operator is knocked out / retreats / swaps 替身<->本体: with p = min(1, 0.18 + 0.004*L) it is redeployed at once (next deployment has 0 redeploy time and 0 cost). Also consumes a "复活" charge if it had one.
|
||||
- **[3 distinct]** When a ground operator is knocked out: every operator on the field +5 SP.
|
||||
- **[2 distinct]** When a ground operator (地面干员 = melee position, on any tile) is knocked out / retreats / swaps 替身<->本体: with p = min(1, 0.18 + 0.004*L) it is redeployed at once (next deployment has 0 redeploy time and 0 cost). Also consumes a "复活" charge if it had one.
|
||||
- **[3 distinct]** When a ground (melee-position) operator is knocked out: every operator on the field +5 SP.
|
||||
- Formulas: `p = min(1, 0.18 + 0.004*L)`; `reaches100%AtL = 205`
|
||||
- How layers are gained: 雷蛇 休整期结束 +1; 砾 被击倒 +2; 归溟幽灵鲨 +5; 风笛 kills +2; 机变 "风笛的盟誓" +8.
|
||||
- Bond item (with 变形同构体 grants this bond): 不屈弹射器 `chess_item_2_01_e` (1 gold: 再部署时间-30%,生命值-30%)
|
||||
|
||||
@@ -256,6 +256,13 @@
|
||||
.bslot__count.is-max { border-color: var(--mint-700); color: var(--mint-400); }
|
||||
.bslot.is-active .bslot__count { border-color: var(--mint-700); }
|
||||
.bslot.is-open .bond__core { box-shadow: 0 0 0 2px var(--mint-400), 0 0 .2rem rgba(23, 249, 183, .6); }
|
||||
/* a bond the mode never activates, with members (the server's `off` entry): the briefing's grey ✕ disc, the chips'
|
||||
struck name, 本局禁用 under it instead of the count */
|
||||
.bslot.is-off .bond__name { color: var(--text-dim); text-decoration: line-through; text-decoration-color: rgba(255, 84, 84, .6); }
|
||||
.bslot.is-off .bslot__off {
|
||||
position: static; margin-top: .02rem; font-size: .11rem; font-weight: 500; color: var(--text-dim); white-space: nowrap;
|
||||
background: rgba(8, 11, 10, .85); border: 1px dashed rgba(255, 255, 255, .25);
|
||||
}
|
||||
|
||||
/* ---- team panel ------------------------------------------------------------------------------------------- */
|
||||
.team { position: absolute; left: .22rem; top: 2.36rem; display: flex; flex-direction: column; gap: .14rem; pointer-events: none; }
|
||||
|
||||
@@ -9,14 +9,19 @@
|
||||
// the +1 used to read as a miscount (GitHub issue #1, DESIGN §21.26). Opened from the detail panel's bond chips it docks
|
||||
// beside that panel (`beside`: the panel's side).
|
||||
// Research 06 §11.1: round mint discs, stack count over the disc, name below, sorted by stacks; grey =
|
||||
// present but inactive; in 联防 / boss rounds the strip is dimmed ("层数叠加已禁用").
|
||||
// present but inactive; in 联防 / boss rounds the strip is dimmed ("层数叠加已禁用"). A bond the mode never activates
|
||||
// that the player has members of (the server's `off` entry — server/match/bondsMeta.js offBondCounts: 标准's 投资人 奇迹
|
||||
// 突袭 独行 …) comes last as a grey disc with ✕ and 本局禁用 under it instead of its count (community reports
|
||||
// 「投资人等在休整区就能生效的盟约不生效」 / 「…不会触发斯卡蒂与异德的突袭」: the bond just vanished, 0.1.3). The popup's 成员
|
||||
// header counts the hand members too for a bond that counts the hand (投资人 远见 奇迹: gameLogic memberHeadCount), as 在场
|
||||
// already did.
|
||||
// Watching a teammate (DESIGN §20.15, ui/watchBonds.js) the strip and the popup show THAT player's bonds and layers:
|
||||
// the strip carries an amber "👁 name" tag (`owner`, the observing pill's spelling, research 09 §3.1) and amber rings,
|
||||
// the popup a "👁 name 的盟约" line; its member list reads the teammate's operators on the field (no hand: never sent).
|
||||
|
||||
import { html, BondDisc, Icon, MicroLabel, Tooltip } from './components.js';
|
||||
import { RichText, UnitThumb, BondGlyph, GIcon } from './gameComponents.js';
|
||||
import { sortBonds, bondMembers, nextThreshold, bondTier, harmonyMembers, HARMONY_BOND } from './gameLogic.js';
|
||||
import { sortBonds, bondMembers, nextThreshold, bondTier, harmonyMembers, HARMONY_BOND, memberHeadCount, briefingBondTip } from './gameLogic.js';
|
||||
import { formatBondEffect } from './richText.js';
|
||||
import { bondIconUrl } from './assetUrls.js';
|
||||
import { data } from '../data.js';
|
||||
@@ -48,6 +53,17 @@ export function BondStrip({ bonds, layersDisabled = false, onOpen, openId = null
|
||||
${shown.map((b) => {
|
||||
const rec = data.lookup('bonds', b.bondId);
|
||||
const th = Array.isArray(b.thresholds) && b.thresholds.length ? b.thresholds : rec?.thresholds || [];
|
||||
if (b.off) {
|
||||
// a bond the mode never activates, with members: the briefing's grey ✕ disc, no stack count, 本局禁用 under it
|
||||
const name = rec?.name || b.bondId;
|
||||
return html`<div key=${b.bondId} role="listitem" data-bond=${b.bondId} data-off="1"
|
||||
class=${cx('bslot', 'is-off', openId === b.bondId && 'is-open')}>
|
||||
<${BondDisc} name=${name} icon=${bondIconUrl(m, b.bondId)} tier=${0} maxTier=${Math.max(1, th.length)} active=${false}
|
||||
disabled=${true} size="sm" showName=${true} layersDisabled=${layersDisabled} onClick=${() => onOpen(b.bondId)}
|
||||
title=${`${briefingBondTip(name, 'off')} · ${b.count ?? 0} 名成员`} />
|
||||
<span class="bslot__count bslot__off">本局禁用</span>
|
||||
</div>`;
|
||||
}
|
||||
const next = nextThreshold(b.count ?? 0, th);
|
||||
return html`<div key=${b.bondId} role="listitem" data-bond=${b.bondId} data-harmony=${b.harmony > 0 ? b.harmony : null}
|
||||
class=${cx('bslot', b.active && 'is-active', openId === b.bondId && 'is-open')}>
|
||||
@@ -123,7 +139,7 @@ export function BondPopup({ bondId, entry, priv, banned = [], onClose, onMember,
|
||||
<${RichText} as="p" text=${b.descRaw || b.desc} class="bpop__desc" />
|
||||
</section>
|
||||
<section class="bpop__sec">
|
||||
<h4>成员 <small>${members.filter((x) => x.onBoard).length}/${members.length}</small></h4>
|
||||
<h4>成员 <small>${memberHeadCount(members, countsHand)}/${members.length}</small></h4>
|
||||
${harmony ? (harmonyBy.length
|
||||
? html`<button type="button" class="bpop__harmony" data-harmony=${harmony} title=${harmonyText} onClick=${() => onMember?.(harmonyBy[0].id, null)}>
|
||||
<${BondGlyph} bondId=${HARMONY_BOND} /><b>${harmonyName} +${harmony}</b><span>${harmonyText}</span></button>`
|
||||
|
||||
+44
-18
@@ -367,8 +367,10 @@ export function activeBubbles(emotes, now, ttl = 3000) {
|
||||
// ---- bonds -------------------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Sort bonds for the strip: active first, then layers desc, count desc, tier desc, core first, id.
|
||||
* @template {{bondId:string, active?:boolean, layers?:number, count?:number, tier?:number}} B
|
||||
* Sort bonds for the strip: active first, then layers desc, count desc, tier desc, core first, id; the mode-off bonds
|
||||
* (`off`: the server's entries for the bonds this mode never activates that the player has members of —
|
||||
* server/match/bondsMeta.js offBondCounts) after all the others.
|
||||
* @template {{bondId:string, active?:boolean, layers?:number, count?:number, tier?:number, off?:boolean}} B
|
||||
* @param {B[]} bonds
|
||||
* @param {(id:string)=>any} [getBond]
|
||||
* @returns {B[]}
|
||||
@@ -377,7 +379,8 @@ export function sortBonds(bonds, getBond = () => null) {
|
||||
const list = (Array.isArray(bonds) ? bonds : []).filter((b) => isObj(b) && typeof b.bondId === 'string');
|
||||
const n = (v) => (Number.isFinite(v) ? v : 0);
|
||||
return [...list].sort((a, b) => (
|
||||
(b.active ? 1 : 0) - (a.active ? 1 : 0)
|
||||
(a.off ? 1 : 0) - (b.off ? 1 : 0)
|
||||
|| (b.active ? 1 : 0) - (a.active ? 1 : 0)
|
||||
|| n(b.layers) - n(a.layers)
|
||||
|| n(b.count) - n(a.count)
|
||||
|| n(b.tier) - n(a.tier)
|
||||
@@ -509,10 +512,11 @@ export function harmonyMembers(priv, getChess = () => null) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Member rows of a bond popup: every visible member with owned / on-board / banned state, plus the player's operators
|
||||
* that are members through 变形同构体 (grantedBonds; `granted: true` and `items`: the item ids of the copy that wears the
|
||||
* pair — the card the row opens shows them; one row per operator — normal and elite copies are one member, like the
|
||||
* count's distinct members), so "成员 x/y" agrees with the count the server reports (在场).
|
||||
* Member rows of a bond popup: every visible member with owned / on-board / in-hand / banned state, plus the player's
|
||||
* operators that are members through 变形同构体 (grantedBonds; `granted: true` and `items`: the item ids of the copy that
|
||||
* wears the pair — the card the row opens shows them; one row per operator — normal and elite copies are one member,
|
||||
* like the count's distinct members), so "成员 x/y" agrees with the count the server reports (在场; memberHeadCount).
|
||||
* `inHand`: in the 整备区 (hand), not the 5 temporary slots — what BOARD_AND_DECK bonds (投资人 远见 奇迹) count.
|
||||
* @param {any} bond bonds.json record
|
||||
* @param {any} priv m.private (hand/board/temp) — or a teammate's field operators (ui/watchBonds.js ownerBoard)
|
||||
* @param {Set<string>|string[]} [banned] banned base chess ids
|
||||
@@ -525,8 +529,9 @@ export function bondMembers(bond, priv, banned = [], getChess = () => null, getI
|
||||
const baseOf = (id) => getChess(id)?.baseId || (typeof id === 'string' ? id.replace(/_b$/, '_a') : id);
|
||||
const onBoard = new Set();
|
||||
const owned = new Set();
|
||||
const inHand = new Set();
|
||||
const memberSet = new Set(members);
|
||||
/** base id → { on: on the board?, items: the wearer's item ids } — operators of the player that join this bond through 变形同构体 */
|
||||
/** base id → { on: on the board?, hand: in the hand?, items: the wearer's item ids } — operators of the player that join this bond through 变形同构体 */
|
||||
const granted = new Map();
|
||||
const grants = (p) => typeof bond?.bondId === 'string' && grantedBonds(p.items, getItem).includes(bond.bondId);
|
||||
const itemIds = (p) => p.items.map((it) => (typeof it === 'string' ? it : it?.id)).filter((x) => typeof x === 'string');
|
||||
@@ -534,25 +539,45 @@ export function bondMembers(bond, priv, banned = [], getChess = () => null, getI
|
||||
if (p?.kind !== 'chess') continue;
|
||||
const base = baseOf(p.id);
|
||||
onBoard.add(base); owned.add(base);
|
||||
if (!memberSet.has(base) && !granted.has(base) && grants(p)) granted.set(base, { on: true, items: itemIds(p) });
|
||||
if (!memberSet.has(base) && !granted.has(base) && grants(p)) granted.set(base, { on: true, hand: false, items: itemIds(p) });
|
||||
}
|
||||
for (const p of [...(Array.isArray(priv?.hand) ? priv.hand : []), ...(Array.isArray(priv?.temp) ? priv.temp : [])]) {
|
||||
if (p?.kind !== 'chess') continue;
|
||||
const base = baseOf(p.id);
|
||||
owned.add(base);
|
||||
if (!memberSet.has(base) && !granted.has(base) && grants(p)) granted.set(base, { on: false, items: itemIds(p) });
|
||||
for (const [list, hand] of [[priv?.hand, true], [priv?.temp, false]]) {
|
||||
for (const p of Array.isArray(list) ? list : []) {
|
||||
if (p?.kind !== 'chess') continue;
|
||||
const base = baseOf(p.id);
|
||||
owned.add(base);
|
||||
if (hand) inHand.add(base);
|
||||
if (memberSet.has(base) || !grants(p)) continue;
|
||||
const g = granted.get(base);
|
||||
if (!g) granted.set(base, { on: false, hand, items: itemIds(p) });
|
||||
else if (hand) g.hand = true;
|
||||
}
|
||||
}
|
||||
const rows = members.map((id) => {
|
||||
const c = getChess(id);
|
||||
return { id, tier: c?.tier ?? 0, name: c?.name ?? id, onBoard: onBoard.has(id), owned: owned.has(id), banned: bannedSet.has(id) };
|
||||
return { id, tier: c?.tier ?? 0, name: c?.name ?? id, onBoard: onBoard.has(id), owned: owned.has(id), inHand: inHand.has(id), banned: bannedSet.has(id) };
|
||||
});
|
||||
for (const [id, g] of granted) {
|
||||
const c = getChess(id);
|
||||
rows.push({ id, tier: c?.tier ?? 0, name: c?.name ?? id, onBoard: g.on, owned: true, banned: false, granted: true, items: g.items });
|
||||
rows.push({ id, tier: c?.tier ?? 0, name: c?.name ?? id, onBoard: g.on, owned: true, inHand: g.hand, banned: false, granted: true, items: g.items });
|
||||
}
|
||||
return rows.sort((a, b) => (b.onBoard - a.onBoard) || (b.owned - a.owned) || (a.tier - b.tier) || (a.id < b.id ? -1 : 1));
|
||||
}
|
||||
|
||||
/**
|
||||
* The member count of a bond popup's 成员 header: the rows on the board — and, for a bond that counts the hand
|
||||
* (`countsHand`: BOARD_AND_DECK — 投资人 远见 奇迹; the server's 在场 says (含整备区)), those in the hand too (the 5
|
||||
* temporary slots never count), so the header agrees with 在场 (community report 「投资人等在休整区就能生效的盟约不生效」:
|
||||
* the header said 0/n with the members in the hand while 在场 said 3/3).
|
||||
* @param {Array<{ onBoard?: boolean, inHand?: boolean }>} rows bondMembers rows @param {boolean} [countsHand]
|
||||
* @returns {number}
|
||||
*/
|
||||
export function memberHeadCount(rows, countsHand = false) {
|
||||
let n = 0;
|
||||
for (const r of Array.isArray(rows) ? rows : []) if (r && (r.onBoard || (countsHand && r.inHand))) n++;
|
||||
return n;
|
||||
}
|
||||
|
||||
/**
|
||||
* Banned-member count per bond (briefing / info drawer red badge).
|
||||
* @param {any[]} bonds bonds.json records
|
||||
@@ -603,9 +628,10 @@ export function briefingBondTip(name, state, bannedN = 0) {
|
||||
* The bonds a mode never activates (config.json modes[modeId].inactiveBondIds = the official modeDataDict
|
||||
* inactiveBondIdList: 标准模拟 leaves 拉特兰 阿戈尔 卡西米尔 灵巧 奥术 奇迹 投资人 突袭 独行 绝技 off). Operators that also carry an
|
||||
* enabled bond stay in the pool (server pool.js drawDisabledBonds) — 标准's 深靛 洛洛 阿罗玛 夕 圣聆初雪 still show 奥术 — and
|
||||
* the server leaves such a bond out of m.private.bonds, so without a mark a card read "奥术 0/2 未激活" with three 奥术
|
||||
* the server left such a bond out of m.private.bonds, so without a mark a card read "奥术 0/2 未激活" with three 奥术
|
||||
* operators deployed (player report after 0.1.0: "奥术盟约不生效"). The shop / reward cards, the detail card's bond chips
|
||||
* and the bond popup mark these 本局禁用 (briefingBondTip 'off').
|
||||
* and the bond popup mark these 本局禁用 (briefingBondTip 'off'); since 0.1.3 the server also lists such a bond the player
|
||||
* has members of (`off: true`, server/match/bondsMeta.js offBondCounts) and the strip shows it as a grey 本局禁用 disc.
|
||||
* @param {any} mode config.json modes[modeId] (data.js getMode), or null
|
||||
* @returns {Set<string>}
|
||||
*/
|
||||
|
||||
@@ -139,7 +139,7 @@ import { RealScheduler } from './scheduler.js';
|
||||
import { SharedPool, drawDisabledBonds } from './pool.js';
|
||||
import { PlayerState } from './PlayerState.js';
|
||||
import { buildDeployMap, boardOrder, pieceDir } from './board.js';
|
||||
import { bondList } from './bondsMeta.js';
|
||||
import { bondList, offBondCounts } from './bondsMeta.js';
|
||||
import { EffectDispatcher, getDefaultRegistry } from './effectsMeta.js';
|
||||
import { generateDraft, applyCard, cardView, bountyBattles, isMultiRoundBounty } from './choices.js';
|
||||
import { setupMatchWaves, buildNormalWave, buildBossWave, bountySpawns, withBounties, previewOf, weightedPick } from './waves.js';
|
||||
@@ -892,7 +892,8 @@ export class Match {
|
||||
// this round's in-battle gains included once the COMBAT phase ended (PlayerState.bondsView); [] once eliminated —
|
||||
// nobody can watch an eliminated player (g.watch refuses them, they have no field) and the result screen reads
|
||||
// m.result's own bonds, so their layers would only cost every m.public bytes for the rest of the match
|
||||
bonds: ps.alive ? bondList(this.gd, ps.bondsView()) : [],
|
||||
// (the mode-off bonds with members included, `off: true`, as in m.private — bondsMeta.offBondCounts)
|
||||
bonds: ps.alive ? bondList(this.gd, ps.bondsView(), { off: offBondCounts(this.gd, ps) }) : [],
|
||||
fieldId: this.fieldOf(ps),
|
||||
status: this.statusOf(ps),
|
||||
autoplay: ps.autoplay,
|
||||
@@ -1657,6 +1658,11 @@ export class Match {
|
||||
const alive = this.alivePlayers();
|
||||
for (const ps of alive) {
|
||||
ps.ready = false;
|
||||
// Items gained as the previous prep ended waited unmerged (acquireItem deferMerge). Merge them now, before
|
||||
// this prep's onPrepStart grants and before the player acts — not in endPrep, which runs in the same prep
|
||||
// that granted them and would take an equipped copy off for the fight about to start.
|
||||
ps.checkItemMerges();
|
||||
ps.recompute();
|
||||
this.dispatch(ps, 'onPrepStart', { round: this.round });
|
||||
ps.recompute();
|
||||
}
|
||||
|
||||
@@ -75,7 +75,7 @@ import { checkLoadout, resolveLoadout } from '../../shared/protocol.js';
|
||||
import { FIELD, tileKey, parseKey, inField, canPlace, positionClass, 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 } from './bondsMeta.js';
|
||||
import { computeBonds, bondList, bondSnapshot, activatedLayers, bondsWithGains, offBondCounts } from './bondsMeta.js';
|
||||
import { itemKey } from './gamedata.js';
|
||||
import { bountyText } from './choices.js';
|
||||
|
||||
@@ -712,12 +712,16 @@ export class PlayerState {
|
||||
/**
|
||||
* Acquire an item (buy, supply card, grant). Merges with an identical normal copy (hand/temp/equipped) into the
|
||||
* golden item (to the hand). Returns the owned piece or null.
|
||||
* `deferMerge` (effectsMeta, while onPrepEnd is on the stack): stow it — hand, else temp — and do not merge in this
|
||||
* call, even when an identical normal copy is already owned. The next prep's start runs checkItemMerges. Nothing
|
||||
* already equipped is taken off for the fight about to start. Hand and temp both full keeps the 「整备区已满,获得的装备已销毁」
|
||||
* outcome. [ASSUMED] every item granted at 休整期结束, not only 维多利亚's 战栗维式重锤 (owner's decision 2026-10-04).
|
||||
*/
|
||||
acquireItem(itemId, { source = 'grant', toTemp = false, silent = false } = {}) {
|
||||
acquireItem(itemId, { source = 'grant', toTemp = false, silent = false, deferMerge = false } = {}) {
|
||||
const rec = this.gd.item(itemId);
|
||||
if (!rec) return null;
|
||||
let piece = this.newPiece('item', itemId);
|
||||
if (this.completesItemMerge(itemId)) {
|
||||
if (!deferMerge && this.completesItemMerge(itemId)) {
|
||||
piece = this._mergeItem(itemId, piece);
|
||||
if (!piece) return null;
|
||||
} else if (!this.stow(piece, { allowTemp: true, toTemp })) {
|
||||
@@ -1648,7 +1652,8 @@ export class PlayerState {
|
||||
board,
|
||||
deployCap: this.deployCap,
|
||||
deployCount: this.deployCount,
|
||||
bonds: bondList(this.gd, this.bondsView(), { full: true }),
|
||||
// + the mode-off bonds it has members of (`off: true`, the strip's grey 本局禁用 discs — bondsMeta.offBondCounts)
|
||||
bonds: bondList(this.gd, this.bondsView(), { full: true, off: offBondCounts(this.gd, this) }),
|
||||
effects: this.effectsView(),
|
||||
nextEnemies: this.m.nextEnemiesFor(this),
|
||||
// DESIGN §16: the effective operator loadout ({ [baseChessId]: { skill, module } }; chess not listed use defaults)
|
||||
|
||||
+84
-33
@@ -16,7 +16,10 @@
|
||||
// 助力 (deputShip): tier 1 needs `thresholds[0]` distinct operators; the upper tiers count operators that differ
|
||||
// in name OR elite state (PRTS 修正).
|
||||
// 独行 (soloShip, count_threshold_downward): active iff 1 ≤ count ≤ maxCount (distinct 独行 operators).
|
||||
// Bonds in the mode's static inactive list (FUNNY) never activate and are omitted.
|
||||
// Bonds in the mode's static inactive list (FUNNY) never activate and are omitted from computeBonds (the battle
|
||||
// input never sees them); the views list the ones the player has members of as `off: true`
|
||||
// (offBondCounts → bondList: count only, never active, after the others) so the strip can say
|
||||
// 本局禁用 (community reports 「投资人…不生效」 / 「…不会触发斯卡蒂与异德的突袭」, 0.1.3).
|
||||
// `tier` = number of thresholds reached (downward: 1 when active); `active = tier ≥ 1`.
|
||||
// Layers (`ps.layers[bondId]`) persist the whole match; they are reported for every bond but only matter while active.
|
||||
|
||||
@@ -48,17 +51,13 @@ export function pieceBonds(gd, piece) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute every bond's state for a player. `harmony` (only present when it applies) = the +1 that 调和 added to `count`.
|
||||
* @param {import('./gamedata.js').GameData} gd
|
||||
* @param {{ board: Map<string, any>, hand: Array<any>, layers: Record<string, number>, bondCountBonus?: Record<string, number> }} ps
|
||||
* @returns {Record<string, { count: number, active: boolean, tier: number, layers: number, harmony?: number }>}
|
||||
* Who carries which bond (computeBonds' and offBondCounts' shared first step): bondId → Set(baseId) on the board / in
|
||||
* the hand (the 5 temporary slots never count), bondId → Set(baseId|golden) on the board, and the elite chess on the board.
|
||||
*/
|
||||
export function computeBonds(gd, ps) {
|
||||
function membership(gd, ps) {
|
||||
const boardChess = [];
|
||||
for (const p of ps.board.values()) if (p && p.kind === 'chess') boardChess.push(p);
|
||||
const handChess = ps.hand.filter((p) => p && p.kind === 'chess');
|
||||
|
||||
/** bondId → Set(baseId) on board / hand; bondId → Set(baseId|golden) on board */
|
||||
const onBoard = new Map();
|
||||
const onBoardVariant = new Map();
|
||||
const inHand = new Map();
|
||||
@@ -72,14 +71,43 @@ export function computeBonds(gd, ps) {
|
||||
const base = gd.baseIdOf(p.id);
|
||||
for (const b of pieceBonds(gd, p)) add(inHand, b, base);
|
||||
}
|
||||
const goldenOnBoard = boardChess.filter((p) => gd.isGolden(p.id)).length;
|
||||
return { onBoard, onBoardVariant, inHand, goldenOnBoard: boardChess.filter((p) => gd.isGolden(p.id)).length };
|
||||
}
|
||||
|
||||
/** A bond's member count by its counting mode (before 调和's +1), with the effects' per-bond count bonus. */
|
||||
function rawCount(gd, id, mem, ps) {
|
||||
const bond = gd.bond(id);
|
||||
let count;
|
||||
if (bond.countMode === 'BOARD_ALL_CHESS' || bond.thresholdTemplate === 'count_threshold_upward_golden') {
|
||||
count = mem.goldenOnBoard;
|
||||
} else if (bond.countMode === 'BOARD_AND_DECK') {
|
||||
const s = new Set([...(mem.onBoard.get(id) || []), ...(mem.inHand.get(id) || [])]);
|
||||
count = s.size;
|
||||
} else {
|
||||
count = (mem.onBoard.get(id) || new Set()).size;
|
||||
}
|
||||
const bonus = ps.bondCountBonus || {};
|
||||
return Math.max(0, count + (Number.isInteger(bonus[id]) ? bonus[id] : 0));
|
||||
}
|
||||
|
||||
const layersIn = (ps, id) => {
|
||||
const v = ps.layers && ps.layers[id];
|
||||
return Number.isFinite(v) && v > 0 ? v : 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* Compute every bond's state for a player. `harmony` (only present when it applies) = the +1 that 调和 added to `count`.
|
||||
* @param {import('./gamedata.js').GameData} gd
|
||||
* @param {{ board: Map<string, any>, hand: Array<any>, layers: Record<string, number>, bondCountBonus?: Record<string, number> }} ps
|
||||
* @returns {Record<string, { count: number, active: boolean, tier: number, layers: number, harmony?: number }>}
|
||||
*/
|
||||
export function computeBonds(gd, ps) {
|
||||
const mem = membership(gd, ps);
|
||||
const { onBoard, onBoardVariant } = mem;
|
||||
|
||||
/** @type {Record<string, { count: number, active: boolean, tier: number, layers: number, harmony?: number }>} */
|
||||
const out = {};
|
||||
const layersOf = (id) => {
|
||||
const v = ps.layers && ps.layers[id];
|
||||
return Number.isFinite(v) && v > 0 ? v : 0;
|
||||
};
|
||||
const layersOf = (id) => layersIn(ps, id);
|
||||
const bonus = ps.bondCountBonus || {};
|
||||
const bonusOf = (id) => (Number.isInteger(bonus[id]) ? bonus[id] : 0);
|
||||
|
||||
@@ -87,17 +115,7 @@ export function computeBonds(gd, ps) {
|
||||
const raw = {};
|
||||
for (const id of gd.bondIds) {
|
||||
if (gd.modeInactiveBonds.has(id)) continue;
|
||||
const bond = gd.bond(id);
|
||||
let count;
|
||||
if (bond.countMode === 'BOARD_ALL_CHESS' || bond.thresholdTemplate === 'count_threshold_upward_golden') {
|
||||
count = goldenOnBoard;
|
||||
} else if (bond.countMode === 'BOARD_AND_DECK') {
|
||||
const s = new Set([...(onBoard.get(id) || []), ...(inHand.get(id) || [])]);
|
||||
count = s.size;
|
||||
} else {
|
||||
count = (onBoard.get(id) || new Set()).size;
|
||||
}
|
||||
raw[id] = Math.max(0, count + bonusOf(id));
|
||||
raw[id] = rawCount(gd, id, mem, ps);
|
||||
}
|
||||
// 调和: +1 to core bonds with ≥ 1 real board member while it is active
|
||||
const harmony = gd.bond(HARMONY_BOND);
|
||||
@@ -121,6 +139,28 @@ export function computeBonds(gd, ps) {
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The bonds this mode never activates (the static inactive list — 标准模拟's 拉特兰 阿戈尔 卡西米尔 灵巧 奥术 奇迹 投资人 突袭
|
||||
* 独行 绝技) that the player has members of, counted like computeBonds would (投资人 / 奇迹 with the hand): { [bondId]:
|
||||
* { count, layers } }, null when the mode switches nothing off. Only the views use it (bondList `off`) — the strip shows
|
||||
* such a bond as a grey 本局禁用 disc instead of leaving the player to wonder why it does nothing (0.1.3).
|
||||
* @param {import('./gamedata.js').GameData} gd
|
||||
* @param {{ board: Map<string, any>, hand: Array<any>, layers: Record<string, number>, bondCountBonus?: Record<string, number> }} ps
|
||||
* @returns {Record<string, { count: number, layers: number }>|null}
|
||||
*/
|
||||
export function offBondCounts(gd, ps) {
|
||||
if (!gd.modeInactiveBonds || !gd.modeInactiveBonds.size) return null;
|
||||
const mem = membership(gd, ps);
|
||||
let out = null;
|
||||
for (const id of gd.bondIds) {
|
||||
if (!gd.modeInactiveBonds.has(id)) continue;
|
||||
const count = rawCount(gd, id, mem, ps);
|
||||
if (!(count > 0)) continue;
|
||||
(out ??= {})[id] = { count, layers: layersIn(ps, id) };
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Ascending member-count thresholds of a bond (fallback: activeCount). */
|
||||
export function thresholdsOf(bond) {
|
||||
if (Array.isArray(bond.thresholds) && bond.thresholds.length) return bond.thresholds.filter((n) => Number.isFinite(n)).sort((a, b) => a - b);
|
||||
@@ -149,26 +189,37 @@ export function activatedLayers(bonds) {
|
||||
|
||||
/**
|
||||
* View list for m.private / m.public: bonds with members or layers, active first, then layers desc, then data order.
|
||||
* An entry whose count includes 调和's +1 carries `harmony: 1` (both views; absent otherwise).
|
||||
* An entry whose count includes 调和's +1 carries `harmony: 1` (both views; absent otherwise). `off` (offBondCounts): the
|
||||
* mode-off bonds the player has members of follow, in data order, as `{ bondId, count, active: false, tier: 0, layers,
|
||||
* off: true }` — the strip's grey 本局禁用 discs; never in computeBonds, so never in the battle input.
|
||||
* @param {import('./gamedata.js').GameData} gd
|
||||
* @param {ReturnType<typeof computeBonds>} bonds
|
||||
* @param {{ full?: boolean }} [opts] full → include thresholds/countsHand (m.private)
|
||||
* @param {{ full?: boolean, off?: ReturnType<typeof offBondCounts> }} [opts] full → include thresholds/countsHand (m.private)
|
||||
*/
|
||||
export function bondList(gd, bonds, { full = false } = {}) {
|
||||
export function bondList(gd, bonds, { full = false, off = null } = {}) {
|
||||
const order = new Map(gd.bondIds.map((id, i) => [id, i]));
|
||||
const list = [];
|
||||
const extras = (e) => {
|
||||
if (!full) return e;
|
||||
const bond = gd.bond(e.bondId);
|
||||
e.thresholds = bond ? thresholdsOf(bond) : [];
|
||||
e.countsHand = !!(bond && bond.countMode === 'BOARD_AND_DECK');
|
||||
return e;
|
||||
};
|
||||
for (const [bondId, b] of Object.entries(bonds || {})) {
|
||||
if (!(b.count > 0 || b.layers > 0 || b.active)) continue;
|
||||
const e = { bondId, count: b.count, active: b.active, tier: b.tier, layers: b.layers };
|
||||
if (b.harmony > 0) e.harmony = b.harmony;
|
||||
if (full) {
|
||||
const bond = gd.bond(bondId);
|
||||
e.thresholds = bond ? thresholdsOf(bond) : [];
|
||||
e.countsHand = !!(bond && bond.countMode === 'BOARD_AND_DECK');
|
||||
}
|
||||
list.push(e);
|
||||
list.push(extras(e));
|
||||
}
|
||||
list.sort((a, b) => (b.active - a.active) || (b.layers - a.layers) || ((order.get(a.bondId) ?? 99) - (order.get(b.bondId) ?? 99)));
|
||||
if (off) {
|
||||
for (const bondId of gd.bondIds) {
|
||||
const o = off[bondId];
|
||||
if (!o || !(o.count > 0) || (bonds && bonds[bondId])) continue;
|
||||
list.push(extras({ bondId, count: o.count, active: false, tier: 0, layers: Number.isFinite(o.layers) && o.layers > 0 ? o.layers : 0, off: true }));
|
||||
}
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
|
||||
@@ -196,6 +196,11 @@ export class EffectDispatcher {
|
||||
return ev;
|
||||
}
|
||||
this.depth++;
|
||||
// An item granted while 休整期结束 is on the stack (this hook, or onGain re-entering it — 维多利亚 pays the next
|
||||
// milestone from the grant's onGain) is stowed and not merged until the next prep start. [ASSUMED] every such
|
||||
// item, not only the 战栗维式重锤 (owner's decision 2026-10-04: "an item that arrives at 休整期结束").
|
||||
const deferItems = hook === 'onPrepEnd';
|
||||
if (deferItems) ps._deferItemMerge = (ps._deferItemMerge || 0) + 1;
|
||||
try {
|
||||
const reg = this.registry;
|
||||
// 0. onPrice: the priced chess's own 特质 first — 购买价格为N defines the price every other modifier acts on
|
||||
@@ -236,6 +241,7 @@ export class EffectDispatcher {
|
||||
if (h) this._call(ps, ref.key, h, hook, { kind: 'effect', key: ref.key, ref }, ev);
|
||||
}
|
||||
} finally {
|
||||
if (deferItems) ps._deferItemMerge--;
|
||||
this.depth--;
|
||||
}
|
||||
return ev;
|
||||
@@ -511,7 +517,11 @@ export function makeCtx(m, ps, source, hook, ev = null) {
|
||||
},
|
||||
grantItem: (itemId, opts = {}) => {
|
||||
if (!ps.alive || !gd.item(itemId)) return null;
|
||||
const p = ps.acquireItem(itemId, { source: opts.source || source.key || 'effect', toTemp: !!opts.toTemp });
|
||||
const p = ps.acquireItem(itemId, {
|
||||
source: opts.source || source.key || 'effect',
|
||||
toTemp: !!opts.toTemp,
|
||||
deferMerge: ps._deferItemMerge > 0,
|
||||
});
|
||||
return p ? view(p) : null;
|
||||
},
|
||||
/** Random chess id from the shared pool (copy-weighted). opts: { maxTier, tier, bond, filter(id) } */
|
||||
|
||||
@@ -129,7 +129,7 @@ const BY_KEY = {
|
||||
});
|
||||
},
|
||||
|
||||
// 休谟斯 回收利用
|
||||
// 休谟斯 回收利用 ("地面干员技能结束时…": a melee-position operator on any tile — support isGroundOp)
|
||||
act1autochess_band18_buff(battle, ps, p, bandId) {
|
||||
const sp = num(p.sp);
|
||||
if (!(sp > 0)) return;
|
||||
|
||||
@@ -441,6 +441,8 @@ export function install(battle) {
|
||||
// next time she leaves the field (knocked out, withdrawn) she is back at once and free (`u.mem.indomFreeDeploy`)
|
||||
// [ASSUMED: one such deployment at a time, spent by her next deployment whatever brings it]. Tier 2 (+sp SP to every
|
||||
// operator on the field) stays a knock-out effect: its own line ("地面干员被击倒时使场上所有干员技力+5") was not corrected.
|
||||
// Both lines take a 地面干员 = a melee-position operator on any tile (support isGroundOp: 歌蕾蒂娅 on a 高台 counts, a
|
||||
// ranged operator on a melee tile does not — community report 「不屈盟约效果高台干员也错误的吃到了」, 0.1.3).
|
||||
if (has(ID.indom)) {
|
||||
const INDOM_EXITS = new Set(['killed', 'retreat', 'merchant']);
|
||||
const stOf = (u) => { const st = isGroundOp(u) ? byPid[u.ownerId] : null; return st && st.tiers[ID.indom] ? st : null; };
|
||||
|
||||
@@ -19,8 +19,8 @@
|
||||
// 5: the first max_free_respawn_cnt members by position (the devour's order, fixed at battle
|
||||
// start) each redeploy at once (free) on their first knock-out
|
||||
// 叙拉古 siracusaShip every member deployment: ASPD +(base + per·L) for (base_duration + per·L) s; 6: 隐匿 for the same
|
||||
// time, and while hidden / end_duration s after, attacks proc (PRD, nominal `prob`) base_damage +
|
||||
// damage_per_stack·L true damage + fear `fear` s
|
||||
// time, and while hidden / end_duration s after, every 普通伤害 hit (siracusaRolls) procs (PRD,
|
||||
// nominal `prob`) base_damage + damage_per_stack·L true damage + fear `fear` s
|
||||
// 卡西米尔 kazimierzShip every deployment of one of the player's operators (initial ones included): members ATK
|
||||
// +atk_when_born, total ≤ base_max_atk_when_born + max_atk_when_born_per_stack·L; 6: blocking
|
||||
// members pulse damage_atk_scale×ATK true damage + stun around them every damage_interval s,
|
||||
@@ -476,6 +476,29 @@ function installEgir(battle, pid, bb, members) {
|
||||
// =====================================================================================================================
|
||||
// 叙拉古
|
||||
|
||||
/**
|
||||
* Does a member's damage instance try the tier-6 proc? PRTS 盟约记录 叙拉古: "※仅在造成普通伤害时尝试造成来源为干员自身的
|
||||
* 真实附加伤害和恐惧", "每次造成普通伤害时尝试触发" — 普通伤害 is the attack type NORMAL (PRTS 伤害分类: the default type,
|
||||
* group damage included unless it is 溅射), not "a normal attack" (community report 「叙拉古盟约真伤概率数值没有正确递增
|
||||
* 成长」, GitHub #79: it rolled on normal attacks only). So skill hits roll — 德克萨斯 剑雨, 缄默德克萨斯's bursts and
|
||||
* sword rain, 阿罗玛 S1 / S2, 忍冬 S1 / S2, 伺夜 S3 — and so do 荒芜拉普兰德's S3 drone attacks (PRTS 备注 "不属于普通攻击/
|
||||
* 技能直接伤害": neither an attack nor skill damage, still 普通伤害). Not: 溅射 (`isSplash`); 持续伤害 (tags dot / periodic:
|
||||
* 荒芜拉普兰德's S3 pulse, PRTS 备注 "持续法术伤害"; 缄默德克萨斯 S1, "伤害分类为法术持续伤害"); 附加伤害 (tag `addition`:
|
||||
* 拉普兰德's module, 忍冬's 追凶 — PRTS "法术附加伤害"; item procs and the bonds' riders, own proc included [ASSUMED: 附加,
|
||||
* PRTS gives no class]); element damage; 无来源 damage (坚守's thorns); 流失.
|
||||
*/
|
||||
function siracusaRolls(dmg) {
|
||||
if (!dmg || dmg.isSplash || dmg.sourceless || dmg.type === 'element' || dmg.type === 'elemental') return false;
|
||||
const tags = dmg.tags;
|
||||
if (!Array.isArray(tags)) return true;
|
||||
for (let i = 0; i < tags.length; i++) {
|
||||
const t = tags[i];
|
||||
if (t === 'dot' || t === 'periodic' || t === 'addition' || t === 'item' || t === 'hpLoss') return false;
|
||||
if (typeof t === 'string' && t.startsWith('bond:')) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function installSiracusa(battle, pid, bb, members) {
|
||||
const memberSet = new Set(members);
|
||||
const six = reached(battle, pid, 'siracusaShip', bb.power_bond_char_cnt);
|
||||
@@ -497,7 +520,7 @@ function installSiracusa(battle, pid, bb, members) {
|
||||
const st = { n: 0 };
|
||||
const onDmg = (ctx) => {
|
||||
const u = ctx.source, t = ctx.target, dmg = ctx.dmg;
|
||||
if (!dmg || !dmg.isAttack || dmg.isSplash || !isEnemyTarget(t) || ownTag(dmg, 'bond:siracusa')) return;
|
||||
if (!isEnemyTarget(t) || !siracusaRolls(dmg)) return;
|
||||
const hidden = u.s.flags.stealth || battle.time <= num(u.mem.siraStealthEnd, -Infinity) + end + 1e-9;
|
||||
if (!hidden) return;
|
||||
st.n++;
|
||||
@@ -609,6 +632,8 @@ export function payHammers(ctx) {
|
||||
ctx.setCounter(VICTORIA_COUNTER, paid + 1);
|
||||
for (let i = 0; i < count; i++) {
|
||||
const id = ctx.rollItem({ pool: p.pool });
|
||||
// onPrepEnd: grantItem stows this and does not merge until the next prep start, so a second copy does not
|
||||
// take an equipped hammer off for the fight (effectsMeta _deferItemMerge). [ASSUMED] every item that hook grants.
|
||||
if (id && ctx.grantItem(id, { source: 'bond:victoriaShip' })) granted++;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -299,7 +299,7 @@ function hammerAcquire(battle, rt, u) {
|
||||
const m = hammerMul(battle, rt, u, hs, 'burn');
|
||||
if (m > 0) battle.dealDamage(u, c.target, { type: 'element', element: 'burn', amount: c.amount * num(p.damage_scale) * m, tags: procTags('hammer_burn') });
|
||||
});
|
||||
// 战栗: ground carrier, on attack prob × m ⇒ 战栗 disarmed_duration s
|
||||
// 战栗: 地面干员 carrier (melee position, any tile — support isGroundOp), on attack prob × m ⇒ 战栗 disarmed_duration s
|
||||
S.on('attack', (c) => {
|
||||
if (c.attacker !== u || !isGroundOp(u)) return;
|
||||
const p = hammerParams(hs, 'tremble');
|
||||
|
||||
@@ -40,7 +40,7 @@ function onDefaultSkill(chess) {
|
||||
* `liftoff` (no ground enemy blocked — Battle._blockerFor; 对地规避 — targeting.js evadesGround: no ground enemy selects
|
||||
* her, while what selects nobody still lands — 无视无法选择 abilities, direct picks, flying units' blasts, 无来源 damage, the
|
||||
* ticks of a debuff already on her) and `blockFly` (blocks flyers at the air radius). She stays a ground unit on her tile (PRTS 行动方式 "起飞的
|
||||
* 干员仍然是地面单位": `unit.ground` unchanged — 隐德来希's 血镰, 地面干员 bonds / items still count her).
|
||||
* 干员仍然是地面单位": `unit.ground` unchanged — 隐德来希's 血镰 still counts her; 地面干员 bonds / items read her position).
|
||||
*/
|
||||
const LIFTOFF_FLAGS = Object.freeze({ blockFly: true, liftoff: true });
|
||||
/** 蒂比 take-off: the ground enemies she blocked walk on. */
|
||||
@@ -721,7 +721,8 @@ export default {
|
||||
if (num(t.duration) > 0) trait.onHitStatus = { key: 'silence', duration: num(t.duration) };
|
||||
if (num(tb.atk_scale_m) > 0) {
|
||||
trait.afterHit = (battle, u, target) => {
|
||||
if (target && target.alive && target.side === 'enemy') battle.dealDamage(u, target, { amount: u.s.atk * num(tb.atk_scale_m), type: 'arts', tags: ['module'] });
|
||||
// PRTS 特性备注 "造成预计算的法术附加伤害": 附加伤害 (tag addition — no 叙拉古 6 roll)
|
||||
if (target && target.alive && target.side === 'enemy') battle.dealDamage(u, target, { amount: u.s.atk * num(tb.atk_scale_m), type: 'arts', tags: ['module', 'addition'] });
|
||||
};
|
||||
}
|
||||
return {
|
||||
|
||||
@@ -1389,7 +1389,8 @@ const KITS = {
|
||||
if (t == null) { marks.set(e.id, battle.time); return; }
|
||||
if (battle.time - t <= num(t0.interval, 10) + 1e-9 && e.alive) {
|
||||
busy = true;
|
||||
try { battle.dealDamage(unit, e, { amount: unit.s.atk * num(t0.atk_scale), type: 'arts', canDodge: false, tags: ['talent', 'vulpisHunt'] }); } finally { busy = false; }
|
||||
// PRTS 备注 "伤害类型为法术附加伤害": tag addition (no 叙拉古 6 roll)
|
||||
try { battle.dealDamage(unit, e, { amount: unit.s.atk * num(t0.atk_scale), type: 'arts', canDodge: false, tags: ['talent', 'vulpisHunt', 'addition'] }); } finally { busy = false; }
|
||||
}
|
||||
}, { owner: unit });
|
||||
battle.on('kill', (ctx) => { if (ctx.killer === unit && unit.skill?.active) unit.mem.vulpisKill = true; }, { owner: unit });
|
||||
|
||||
@@ -2972,11 +2972,13 @@ function whitw2(bb, chess, def) {
|
||||
// 信息 驭械术师 "浮游单元攻击不同目标…时,上述的伤害立刻恢复至初始值"). That damage is arts and neither a normal attack
|
||||
// nor skill damage (PRTS S3 备注 "该技能释放的浮游单元造成的伤害不属于普通攻击/技能直接伤害", which for this skill
|
||||
// overrides the branch note "通过技能释放的浮游单元造成技能直接伤害": no 'attack' hook, isAttack / isSkill false — the
|
||||
// 叙拉古 6 assassin proc and the on-attack items skip it), and 缴械 does not stop it (PRTS 驭械术师 "…不受缴械类效果
|
||||
// on-attack items skip it; it is still 普通伤害, so the 叙拉古 6 proc rolls on it, bonds/core.js siracusaRolls), and
|
||||
// 缴械 does not stop it (PRTS 驭械术师 "…不受缴械类效果
|
||||
// 制约"); [ASSUMED] nor do her stun, freeze or silence (PRTS names only 缴械) — the skill ticks on and so do the drones.
|
||||
// Around every drone (attack@range_radius): move speed attack@move_speed and, once per second, attack@magic_atk_scale
|
||||
// × ATK arts (不叠加: one hit per enemy whatever the number of drones); [ASSUMED] that area hit keeps `isSkill` (a skill
|
||||
// DoT — the 备注 speaks of 直接伤害). A knocked-out / withdrawn wolf (onEnd cleared the drones mid-tick, e.g. from a
|
||||
// DoT — the 备注 speaks of 直接伤害) and is 持续伤害 (tag dot: PRTS 备注 "持续法术伤害" — no 叙拉古 6 roll, 锡人's
|
||||
// 凋敝魂灵 raises it). A knocked-out / withdrawn wolf (onEnd cleared the drones mid-tick, e.g. from a
|
||||
// kill hook) deals nothing more in that tick.
|
||||
skill: {
|
||||
kind: 'duration',
|
||||
@@ -3009,7 +3011,7 @@ function whitw2(bb, chess, def) {
|
||||
unit.mem.droneAcc -= 1;
|
||||
for (const e of near) {
|
||||
if (gone()) return;
|
||||
if (e.alive) battle.dealDamage(unit, e, { amount: unit.s.atk * dmgScale, type: 'arts', isSkill: true, tags: ['skill', 'drone'] });
|
||||
if (e.alive) battle.dealDamage(unit, e, { amount: unit.s.atk * dmgScale, type: 'arts', isSkill: true, tags: ['skill', 'drone', 'dot'] });
|
||||
}
|
||||
// (a drone on its target already pulses with each of its attacks)
|
||||
for (const d of D) if (d.phase !== 'lock') battle.fx('drone', { x: d.x, y: d.y, id: unit.id });
|
||||
|
||||
@@ -151,8 +151,14 @@ export function tierOf(u) {
|
||||
if (u.ownerUnit) return tierOf(u.ownerUnit);
|
||||
return 1;
|
||||
}
|
||||
/** Operator standing on a ground (LOW, not elevated) tile — "地面干员" (engine flag set at deployment). */
|
||||
export const isGroundOp = (u) => isOp(u) && u.ground === true;
|
||||
/**
|
||||
* "地面干员": a melee-position (地面位, deploy class MELEE) operator, whatever tile it stands on — the official vocabulary
|
||||
* names the deploy class, not the tile (game data: this mode's map card 「可以部署高台干员和地面干员的地面」, 「可将高台干员
|
||||
* 部署于其上」, 「所有地面干员阻挡数+2」; PRTS corrected 琴柳's 地面干员 to 地面位干员). Read by 不屈, the 战栗维式重锤 proc and
|
||||
* 休谟斯 回收利用 (community report 「不屈盟约效果高台干员也错误的吃到了」, 0.1.3). The tile (`unit.ground`) stays what the
|
||||
* enemies' 地面单位 targeting reads.
|
||||
*/
|
||||
export const isGroundOp = (u) => isOp(u) && u.def?.position === 'MELEE';
|
||||
/** Base (normal) chess id of an operator (normal and elite share it). */
|
||||
export const baseChessId = (u) => String(u?.def?.baseId ?? u?.defId ?? '').replace(/_b$/, '_a');
|
||||
|
||||
|
||||
@@ -412,7 +412,7 @@ test('突袭 #51: the most advanced enemy out of reach → the jump goes to the
|
||||
checkInvariants(h.b);
|
||||
});
|
||||
|
||||
test('不屈: knocked-out ground operator redeploys (p=1 at high L); tier 2 every operator +5 SP; inactive / elevated → no', () => {
|
||||
test('不屈: knocked-out 地面干员 (melee position) redeploys (p=1 at high L); tier 2 every operator +5 SP; inactive / ranged → no', () => {
|
||||
const bb = bondBb('indomShip');
|
||||
const sk = { spCost: 50, initSp: 0 };
|
||||
const defs = { chess: { i_g: op('i_g', ['indomShip']), i_o: chessRec({ id: 'i_o', bonds: [], skill: sk }) } };
|
||||
@@ -441,12 +441,21 @@ test('不屈: knocked-out ground operator redeploys (p=1 at high L); tier 2 ever
|
||||
close(o1.skill.sp, s1, 'tier 1: no SP');
|
||||
checkInvariants(t1.b);
|
||||
|
||||
// 地面干员 = the melee position, whatever the tile: up on a 高台 it still counts; a ranged operator on a melee tile never
|
||||
const hi = makeBattle({ defs, units: [{ chessId: 'i_g', row: 10, col: 2 }], bonds: { indomShip: bond(1, 300) } });
|
||||
hi.step(2);
|
||||
const hg = hi.unit('i_g');
|
||||
assert.equal(hg.ground, false, 'elevated tile');
|
||||
hi.b.dealDamage(null, hg, { amount: 1e9, type: 'true' });
|
||||
assert.equal(hg.alive, false, 'not a ground operator');
|
||||
assert.ok(hg.alive && hg.deployed, 'a melee operator on a 高台 is a 地面干员');
|
||||
const rd = { chess: { i_r: ranged('i_r', ['indomShip']), i_o: chessRec({ id: 'i_o', bonds: [], skill: sk }) } };
|
||||
const lo = makeBattle({ defs: rd, units: [{ chessId: 'i_r', row: 10, col: 4 }, { chessId: 'i_o', row: 12, col: 6 }], bonds: { indomShip: bond(2, 300, 3) } });
|
||||
lo.step(2);
|
||||
const lr = lo.unit('i_r'), lsp = lo.unit('i_o').skill.sp;
|
||||
assert.equal(lr.ground, true, 'a melee (ground) tile');
|
||||
lo.b.dealDamage(null, lr, { amount: 1e9, type: 'true' });
|
||||
assert.equal(lr.alive, false, 'a ranged operator on a melee tile is not a 地面干员');
|
||||
close(lo.unit('i_o').skill.sp, lsp, 'no tier 2 SP either');
|
||||
});
|
||||
|
||||
test('协防干员: all operators take ×0.8 phys/arts; members deal ×1.2 (elite ×1.4)', () => {
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
// test/content/feedback3-ground-ops.test.js — 「地面干员」 is the melee position (地面位), not the tile (0.1.3; community
|
||||
// report 「不屈盟约效果高台干员也错误的吃到了」). Game data words the deploy class this way (this mode's map card
|
||||
// 「可以部署高台干员和地面干员的地面」, 「可将高台干员部署于其上」, 「所有地面干员阻挡数+2」); PRTS corrected 琴柳's 地面干员 to
|
||||
// 地面位干员. The three effects worded 地面干员 — 不屈 (both lines), the 战栗维式重锤 proc and 休谟斯 回收利用 — read
|
||||
// support isGroundOp = def.position MELEE: 歌蕾蒂娅 on a 高台 counts, a ranged operator on a melee tile does not.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { makeBattle, chessRec, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
|
||||
import { isGroundOp } from '../../server/sim/content/support/index.js';
|
||||
|
||||
const close = (a, b, msg, eps = 1e-6) => assert.ok(Math.abs(a - b) <= eps, `${msg}: ${a} ≠ ${b}`);
|
||||
const RANGE5 = [[0, 0], [0, 1], [0, 2], [0, 3], [0, 4]];
|
||||
const op = (id, o = {}) => chessRec({
|
||||
id, profession: o.profession ?? 'WARRIOR', bonds: o.bonds ?? [], rangeGrid: RANGE5,
|
||||
stats: { maxHp: 2000, atk: 500, def: 200, res: 10, aspd: 100, bat: 1, respawnTime: 20, spRecovery: 1, blockCnt: 2 },
|
||||
skill: { spCost: 60, duration: 5, initSp: 0 },
|
||||
});
|
||||
const DUMMY = enemyRec({ key: 'e_d', hp: 1e7, speed: 0 });
|
||||
function forceRng(h, v) { const orig = h.b.rng; h.b.rng = Object.assign(() => v, orig); }
|
||||
|
||||
// act2autochess_m01 (战场#01(下半)): (9,4) a melee tile on the road, (10,4) a 高台
|
||||
const PROVENCE = 'chess_char_1_07_a'; // 普罗旺斯 狙击 RANGED, 不屈
|
||||
const GLADIIA = 'chess_char_4_12_a'; // 歌蕾蒂娅 钩索师 MELEE (placement 'all': may stand on a 高台)
|
||||
const CUORA = 'chess_char_3_16_a'; // 蛇屠箱: takes the tier-2 SP
|
||||
const INDOM = { indomShip: { count: 3, active: true, tier: 2, layers: 210 } }; // p = min(1, 0.18 + 0.004 × 210) = 1
|
||||
|
||||
test('不屈 (real chess): 歌蕾蒂娅 knocked out on a 高台 is back at once with the tier-2 SP; 普罗旺斯 on a melee tile stays down', () => {
|
||||
const run = (id, row, col) => {
|
||||
const h = makeBattle({
|
||||
stageId: 'act2autochess_m01', seed: 4, timeLimit: 60, autoFinish: false, bonds: INDOM,
|
||||
units: [{ chessId: id, row, col, dir: 'RIGHT' }, { chessId: CUORA, row: 12, col: 6, dir: 'RIGHT' }],
|
||||
});
|
||||
h.step(2);
|
||||
const u = h.unit(id), other = h.unit(CUORA);
|
||||
const sp0 = other.skill.sp;
|
||||
h.b.kill(u, null);
|
||||
const back = u.alive && u.deployed;
|
||||
const sp = other.skill.sp - sp0;
|
||||
checkInvariants(h.b);
|
||||
return { u, back, sp };
|
||||
};
|
||||
const g = run(GLADIIA, 10, 4);
|
||||
assert.equal(g.u.ground, false, '歌蕾蒂娅 stands on the 高台');
|
||||
assert.equal(g.u.def.position, 'MELEE');
|
||||
assert.ok(isGroundOp(g.u), 'a 地面干员 up there');
|
||||
assert.ok(g.back, '不屈 brings her back at once');
|
||||
close(g.sp, 5, '不屈 tier 2: every operator on the field +5 SP');
|
||||
const p = run(PROVENCE, 9, 4);
|
||||
assert.equal(p.u.ground, true, '普罗旺斯 stands on a melee tile');
|
||||
assert.ok(!isGroundOp(p.u), 'not a 地面干员');
|
||||
assert.ok(!p.back, '不屈 does not bring a ranged operator back');
|
||||
close(p.sp, 0, 'no tier-2 SP for her knock-out');
|
||||
const g2 = run(GLADIIA, 9, 4);
|
||||
assert.ok(g2.back && g2.sp === 5, '歌蕾蒂娅 on the ground: as before');
|
||||
});
|
||||
|
||||
test('战栗维式重锤: the 战栗 proc takes a melee carrier on a 高台, never a ranged carrier on a melee tile', () => {
|
||||
const ops = { t_m: op('t_m', { bonds: ['victoriaShip'] }), t_r: op('t_r', { bonds: ['victoriaShip'], profession: 'SNIPER' }) };
|
||||
const tremble = (id, row, col) => {
|
||||
const h = makeBattle({
|
||||
defs: { chess: ops, enemies: { e_d: DUMMY } }, timeLimit: 999, autoFinish: false,
|
||||
units: [{ chessId: id, row, col, items: ['chess_item_2_03_e_a'] }], enemies: [{ key: 'e_d', pos: [10, 5] }],
|
||||
});
|
||||
forceRng(h, 0.05); // under the 10 % chance
|
||||
h.runUntil(() => h.hooksOf('attack').length >= 1, 5);
|
||||
assert.ok(h.hooksOf('attack').length >= 1, `${id} attacked`);
|
||||
const u = h.unit(id);
|
||||
return { ground: u.ground, hit: !!h.b.enemies[0].findBuff('tremble') };
|
||||
};
|
||||
const m = tremble('t_m', 10, 2);
|
||||
assert.equal(m.ground, false, 'the melee carrier on the 高台');
|
||||
assert.ok(m.hit, '战栗 from a melee carrier on a 高台');
|
||||
const r = tremble('t_r', 10, 4);
|
||||
assert.equal(r.ground, true, 'the ranged carrier on a melee tile');
|
||||
assert.ok(!r.hit, 'no 战栗 from a ranged carrier');
|
||||
});
|
||||
|
||||
test('休谟斯 回收利用: a melee operator\'s skill end on a 高台 feeds a neighbour 3 SP; a ranged one\'s on a melee tile does not', () => {
|
||||
const ops = { t_m: op('t_m'), t_r: op('t_r', { profession: 'SNIPER' }), t_n: op('t_n') };
|
||||
const recycle = (id, row, col, nRow, nCol) => {
|
||||
const h = makeBattle({
|
||||
defs: { chess: ops, enemies: { e_d: DUMMY } }, bandId: 'band_humus', timeLimit: 999, autoFinish: false,
|
||||
units: [{ chessId: id, row, col }, { chessId: 't_n', row: nRow, col: nCol }],
|
||||
});
|
||||
h.step(1);
|
||||
const u = h.unit(id), n = h.unit('t_n');
|
||||
const n0 = n.skill.sp;
|
||||
u.skill.activate('test', { free: true });
|
||||
u.skill.end('test');
|
||||
return { ground: u.ground, gain: n.skill.sp - n0 };
|
||||
};
|
||||
const m = recycle('t_m', 10, 2, 10, 3);
|
||||
assert.equal(m.ground, false);
|
||||
close(m.gain, 3, 'melee on the 高台: neighbour +3');
|
||||
const r = recycle('t_r', 10, 4, 11, 4);
|
||||
assert.equal(r.ground, true);
|
||||
close(r.gain, 0, 'ranged on a melee tile: nothing');
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
// test/content/feedback3-siracusa-rolls.test.js — 叙拉古 6's true-damage proc rolls on every 普通伤害 hit of a member
|
||||
// (0.1.3; community report 「叙拉古盟约真伤概率数值没有正确递增成长」, GitHub #79 「咬了一整个技能也没出」). PRTS 盟约记录
|
||||
// 叙拉古: "※仅在造成普通伤害时尝试…", "每次造成普通伤害时尝试触发"; PRTS 伤害分类: 普通伤害 = attack type NORMAL, the
|
||||
// default (group damage too unless it is 溅射); 溅射 / 持续 (BUFF) / 附加 (ADDITION) are other types. With the battle rng
|
||||
// forced to 0 every roll hits, so the procs count the hits that rolled (the PRD rule and the amounts are unchanged).
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { makeBattle, chessRec, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
|
||||
|
||||
const L = 20;
|
||||
const SIRA6 = { siracusaShip: { count: 6, active: true, tier: 2, layers: L } };
|
||||
const dummy = (key) => enemyRec({ key, hp: 1e9, speed: 0, def: 0, res: 0 });
|
||||
const ENEMIES = { e_dummy: dummy('e_dummy'), e_dummy2: dummy('e_dummy2') };
|
||||
function forceRng(h, v) { const orig = h.b.rng; h.b.rng = Object.assign(() => v, orig); }
|
||||
const isProc = (c) => !!(c.dmg && c.dmg.tags && c.dmg.tags.includes('bond:siracusa'));
|
||||
const hasTag = (c, t) => !!(c.dmg && c.dmg.tags && c.dmg.tags.includes(t));
|
||||
|
||||
test('叙拉古 6: which damage rolls — attacks, skill hits, drone attacks yes; 溅射, 持续, 附加, item procs, element, 无来源 no', () => {
|
||||
const h = makeBattle({
|
||||
defs: { chess: { r0_a: chessRec({ id: 'r0_a', bonds: ['siracusaShip'], skill: null }) }, enemies: ENEMIES },
|
||||
units: [{ chessId: 'r0_a', row: 10, col: 3 }], enemies: [{ key: 'e_dummy', pos: [12, 9] }], // out of her range
|
||||
bonds: SIRA6, autoFinish: false, timeLimit: 300,
|
||||
});
|
||||
h.step(1);
|
||||
const a = h.unit('r0_a'), e = h.enemies()[0];
|
||||
assert.ok(a.s.flags.stealth, '隐匿 after the deployment');
|
||||
forceRng(h, 0);
|
||||
let procs = 0;
|
||||
h.b.on('damaged', (c) => { if (isProc(c)) procs++; });
|
||||
const rolls = (d) => { const n0 = procs; h.b.dealDamage(a, e, { amount: 1, ...d }); return procs - n0; };
|
||||
const cases = [
|
||||
['a normal attack', { type: 'phys', isAttack: true }, 1],
|
||||
['a skill hit (剑雨-like)', { type: 'arts', isSkill: true, tags: ['skill'] }, 1],
|
||||
['a drone attack (neither attack nor skill)', { type: 'arts', tags: ['droneAttack'] }, 1],
|
||||
['a module / talent hit with no class', { type: 'arts', tags: ['talent'] }, 1],
|
||||
['溅射', { type: 'phys', isAttack: true, isSplash: true }, 0],
|
||||
['持续 (the S3 pulse, 缄默德克萨斯 S1)', { type: 'arts', isSkill: true, tags: ['skill', 'drone', 'dot'] }, 0],
|
||||
['持续 (periodic)', { type: 'true', canDodge: false, tags: ['dot', 'periodic'] }, 0],
|
||||
['附加 (拉普兰德 module, 忍冬 追凶)', { type: 'arts', tags: ['module', 'addition'] }, 0],
|
||||
['an item proc', { type: 'true', canDodge: false, tags: ['item', 'item:pegasus'] }, 0],
|
||||
['another bond\'s rider', { type: 'true', canDodge: false, tags: ['bond:kazimierz'] }, 0],
|
||||
['element damage', { type: 'element', element: 'burn' }, 0],
|
||||
['无来源 damage', { type: 'arts', canDodge: false, sourceless: true }, 0],
|
||||
];
|
||||
for (const [label, d, want] of cases) assert.equal(rolls(d), want, label);
|
||||
checkInvariants(h.b);
|
||||
});
|
||||
|
||||
test('叙拉古 6: 荒芜拉普兰德 S3 — every drone attack rolls, the per-second pulse (持续法术伤害) does not', () => {
|
||||
const log = [];
|
||||
const h = makeBattle({
|
||||
defs: { enemies: ENEMIES }, seed: 7, timeLimit: 120, autoFinish: false, bonds: SIRA6,
|
||||
units: [{ chessId: 'chess_char_6_18_a', row: 10, col: 3, carryState: { sp: 69 } }],
|
||||
enemies: [{ key: 'e_dummy', pos: [12, 9] }],
|
||||
setup(b) { b.on('damaged', (c) => log.push(c), { priority: -999 }); },
|
||||
});
|
||||
const u = h.unit('chess_char_6_18_a');
|
||||
assert.ok(h.runUntil(() => u.skill.active, 10), 'S3 cast');
|
||||
forceRng(h, 0);
|
||||
const t0 = h.b.time, from = log.length;
|
||||
h.run(10);
|
||||
assert.ok(u.skill.active && u.s.flags.stealth, 'still in S3 and 隐匿');
|
||||
const mine = log.slice(from).filter((c) => c.source === u);
|
||||
const drones = mine.filter((c) => hasTag(c, 'droneAttack')).length;
|
||||
const pulses = mine.filter((c) => hasTag(c, 'drone'));
|
||||
const procs = mine.filter(isProc).length;
|
||||
assert.ok(drones >= 5, `drone attacks ${drones}`);
|
||||
assert.ok(pulses.length >= 5 && pulses.every((c) => hasTag(c, 'dot')), `pulses ${pulses.length}, 持续伤害`);
|
||||
assert.equal(mine.filter((c) => c.dmg?.isAttack).length, 0, 'no attack of her own during S3');
|
||||
assert.equal(procs, drones, `one roll per drone attack, none per pulse (t ${t0.toFixed(1)}–${h.b.time.toFixed(1)})`);
|
||||
checkInvariants(h.b);
|
||||
});
|
||||
|
||||
test('叙拉古 6: 德克萨斯 S2 剑雨 — both hits on every enemy around roll', () => {
|
||||
const log = [];
|
||||
const h = makeBattle({
|
||||
defs: { enemies: ENEMIES }, seed: 3, timeLimit: 60, autoFinish: false, bonds: SIRA6,
|
||||
units: [{ chessId: 'chess_char_1_08_a', row: 10, col: 3, carryState: { sp: 40 } }],
|
||||
// inside the skill's diamond, outside her attack range (her tile and the one in front)
|
||||
enemies: [{ key: 'e_dummy', pos: [10, 5] }, { key: 'e_dummy2', pos: [11, 4] }],
|
||||
setup(b) { b.on('damaged', (c) => log.push({ c, t: b.time }), { priority: -999 }); },
|
||||
});
|
||||
forceRng(h, 0);
|
||||
const u = h.unit('chess_char_1_08_a');
|
||||
const rainHit = (x) => x.c.source === u && x.c.dmg?.isSkill && !isProc(x.c);
|
||||
assert.ok(h.runUntil(() => log.some(rainHit), 5), '剑雨 cast');
|
||||
const T = log.find(rainHit).t;
|
||||
const mine = log.filter((x) => x.c.source === u && x.t === T).map((x) => x.c);
|
||||
assert.equal(mine.filter((c) => c.dmg?.isSkill && !isProc(c)).length, 4, 'two hits on each of the two enemies');
|
||||
assert.equal(mine.filter((c) => c.dmg?.isAttack).length, 0, 'no attack hit in that tick');
|
||||
assert.equal(mine.filter(isProc).length, 4, 'every 剑雨 hit rolled (rng 0: each roll hits)');
|
||||
checkInvariants(h.b);
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
// Mode-off bonds stay out of the battle (computeBonds) and show in the views when the player has members
|
||||
// (offBondCounts → bondList `off: true`, the strip's grey 本局禁用 discs). 标准模拟's inactiveBondIdList
|
||||
// (activity_table mode_single_funny): 投资人 and 奇迹 count the hand (BOARD_AND_DECK); 突袭 counts the board.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { GameData } from '../../server/match/gamedata.js';
|
||||
import { computeBonds, bondList, bondSnapshot, offBondCounts } from '../../server/match/bondsMeta.js';
|
||||
import { DATA } from './harness.js';
|
||||
|
||||
const funny = new GameData(DATA, 'mode_single_funny');
|
||||
const open = new GameData(DATA, 'mode_multi_normal');
|
||||
let uid = 1;
|
||||
const piece = (id) => ({ uid: uid++, kind: 'chess', id, items: [] });
|
||||
const state = ({ board = [], hand = [], layers = {}, bondCountBonus } = {}) => {
|
||||
const b = new Map();
|
||||
board.forEach((p, i) => b.set(`${9 + (i % 4)},${2 + Math.floor(i / 4)}`, p));
|
||||
const h = new Array(10).fill(null);
|
||||
hand.forEach((p, i) => { h[9 - i] = p; });
|
||||
return { board: b, hand: h, layers, bondCountBonus };
|
||||
};
|
||||
const members = (bond, n) => Object.values(DATA.chess)
|
||||
.filter((c) => c.visible && !c.isGolden && c.bonds.includes(bond))
|
||||
.slice(0, n)
|
||||
.map((c) => c.chessId);
|
||||
|
||||
const INVESTOR = members('investShip', 2);
|
||||
const RAIDER = members('raidShip', 1);
|
||||
const YAN = members('yanShip', 1);
|
||||
|
||||
test('标准模拟: an 投资人 member in hand and a 突袭 member on the board are off entries, never in computeBonds', () => {
|
||||
assert.equal(INVESTOR.length, 2);
|
||||
assert.equal(RAIDER.length, 1);
|
||||
const ps = state({
|
||||
board: [piece(RAIDER[0]), piece(YAN[0])],
|
||||
hand: [piece(INVESTOR[0])],
|
||||
layers: { investShip: 12, raidShip: 4, lateranoShip: 9 },
|
||||
});
|
||||
const bonds = computeBonds(funny, ps);
|
||||
assert.ok(!('investShip' in bonds), '投资人 stays out of the battle input');
|
||||
assert.ok(!('raidShip' in bonds), '突袭 stays out of the battle input');
|
||||
assert.ok(!('lateranoShip' in bonds));
|
||||
assert.equal(bondSnapshot(bonds).investShip, undefined);
|
||||
assert.ok(bonds.yanShip, 'an enabled bond is unchanged');
|
||||
|
||||
const off = offBondCounts(funny, ps);
|
||||
assert.equal(off.investShip.count, 1, 'a hand member counts for 投资人');
|
||||
assert.equal(off.investShip.layers, 12);
|
||||
assert.equal(off.raidShip.count, 1);
|
||||
assert.equal(off.raidShip.layers, 4);
|
||||
assert.ok(!('lateranoShip' in off), 'an inactive bond with zero members is omitted, layers or not');
|
||||
|
||||
const list = bondList(funny, bonds, { full: true, off });
|
||||
const invest = list.find((x) => x.bondId === 'investShip');
|
||||
const raid = list.find((x) => x.bondId === 'raidShip');
|
||||
assert.deepEqual(
|
||||
{ count: invest.count, active: invest.active, tier: invest.tier, layers: invest.layers, off: invest.off, countsHand: invest.countsHand },
|
||||
{ count: 1, active: false, tier: 0, layers: 12, off: true, countsHand: true },
|
||||
);
|
||||
assert.equal(raid.off, true);
|
||||
assert.equal(raid.active, false);
|
||||
assert.equal(raid.tier, 0);
|
||||
assert.equal(raid.countsHand, false);
|
||||
assert.ok(list.findIndex((x) => x.off) > list.findIndex((x) => x.bondId === 'yanShip'), 'off entries follow the live bonds');
|
||||
assert.ok(list.slice(list.findIndex((x) => x.off)).every((x) => x.off), 'the off entries are a suffix');
|
||||
const pub = bondList(funny, bonds, { off });
|
||||
assert.equal(pub.find((x) => x.bondId === 'investShip').off, true);
|
||||
assert.equal('countsHand' in pub.find((x) => x.bondId === 'investShip'), false, 'm.public omits countsHand');
|
||||
});
|
||||
|
||||
test('a BOARD_AND_DECK off bond counts the hand, not the temporary slots, and not twice', () => {
|
||||
const [a, b] = INVESTOR;
|
||||
const handOnly = offBondCounts(funny, state({ hand: [piece(a), piece(b)] }));
|
||||
assert.equal(handOnly.investShip.count, 2);
|
||||
assert.ok(!('raidShip' in (handOnly || {})), '突袭 in nobody\'s hand: a board bond with no board member is omitted');
|
||||
const raidHand = offBondCounts(funny, state({ hand: [piece(RAIDER[0])] }));
|
||||
assert.equal(raidHand, null, '突袭 does not count a hand member');
|
||||
const both = offBondCounts(funny, state({ board: [piece(a)], hand: [piece(a), piece(DATA.chess[a].goldenId)] }));
|
||||
assert.equal(both.investShip.count, 1, 'normal and elite of one operator, board and hand, count once');
|
||||
const tempOnly = { board: new Map(), hand: new Array(10).fill(null), layers: {}, temp: [piece(a)] };
|
||||
assert.equal(offBondCounts(funny, tempOnly), null, 'the 5 temporary slots never count');
|
||||
});
|
||||
|
||||
test('a mode with an empty inactive set returns null, and those bonds stay in computeBonds', () => {
|
||||
assert.equal(open.modeInactiveBonds.size, 0);
|
||||
const ps = state({ board: [piece(RAIDER[0])], hand: [piece(INVESTOR[0])] });
|
||||
assert.equal(offBondCounts(open, ps), null);
|
||||
const bonds = computeBonds(open, ps);
|
||||
assert.equal(bonds.investShip.count, 1);
|
||||
assert.equal(bonds.raidShip.count, 1);
|
||||
assert.equal(bondList(open, bonds).some((x) => x.off), false);
|
||||
});
|
||||
@@ -0,0 +1,156 @@
|
||||
// An item that arrives at 休整期结束 waits in the hand and merges at the next prep start (owner's decision 2026-10-04).
|
||||
// 维多利亚 pays 战栗维式重锤 on onPrepEnd; merging in that call used to detach the equipped copy before the fight.
|
||||
// [ASSUMED] every grant while that dispatch is on the stack, not only this hammer.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createRegistry } from '../../server/match/effectsMeta.js';
|
||||
import { registerMeta as coreRegisterMeta } from '../../server/sim/content/bonds/core.js';
|
||||
import { makeMatch, give, giveItem, legalTileFor } from './harness.js';
|
||||
import { FakeBattle } from './fakeBattle.js';
|
||||
import { tileKey } from '../../server/match/board.js';
|
||||
|
||||
const HAMMER = 'chess_item_2_03_e_a'; // 战栗维式重锤
|
||||
const GOLDEN = 'chess_item_2_03_e_b';
|
||||
const FILLER = 'chess_item_1_01_e_a';
|
||||
const OPS = ['chess_char_1_06_a', 'chess_char_2_05_a', 'chess_char_3_08_a'];
|
||||
|
||||
function copies(ps, id) {
|
||||
let n = 0;
|
||||
const see = (p) => { if (p && p.kind === 'item' && p.id === id) n++; };
|
||||
for (const p of ps.hand) see(p);
|
||||
for (const p of ps.temp) see(p);
|
||||
for (const c of ps.board.values()) if (c && c.kind === 'chess') for (const it of c.items || []) see(it);
|
||||
return n;
|
||||
}
|
||||
|
||||
function fresh() {
|
||||
const reg = createRegistry({ content: false });
|
||||
coreRegisterMeta(reg);
|
||||
const h = makeMatch({ mode: 'solo', seed: 5, registry: reg, fake: true }).start();
|
||||
h.toPrep(1);
|
||||
const orig = h.m.rollItemId.bind(h.m);
|
||||
h.m.rollItemId = (opts = {}) => (opts.pool === 'pool_equip_vict' ? HAMMER : orig(opts));
|
||||
return h;
|
||||
}
|
||||
|
||||
/** Bond inactive while the layers land, then three 维多利亚 operators. One wears the hammer when `equip`. */
|
||||
function arm(h, { layers, equip = true, fill = false } = {}) {
|
||||
const ps = h.ps('p_0');
|
||||
assert.equal(ps.setReady(false).ok, true);
|
||||
for (const p of [...ps.board.values(), ...ps.hand.filter(Boolean), ...ps.temp.filter(Boolean)]) {
|
||||
if (p && p.kind === 'chess') ps.returnCopies(p);
|
||||
}
|
||||
ps.board.clear();
|
||||
ps.hand.fill(null);
|
||||
ps.temp.fill(null);
|
||||
ps._tempDue.clear();
|
||||
ps.recompute();
|
||||
assert.equal(ps.bonds.victoriaShip?.active ?? false, false);
|
||||
if (layers > 0) assert.equal(ps.addLayers('victoriaShip', layers), layers);
|
||||
assert.equal(ps.counters['bond:victoria:hammers'] || 0, 0, 'inactive: the milestones are owed, not paid');
|
||||
const used = new Set();
|
||||
const ops = OPS.map((id) => {
|
||||
const t = legalTileFor(h.m, ps, id, used);
|
||||
used.add(tileKey(t[0], t[1]));
|
||||
return give(h.m, ps, id, 'board', t);
|
||||
});
|
||||
assert.equal(ps.bonds.victoriaShip.active, true);
|
||||
let equipped = null;
|
||||
if (equip) {
|
||||
equipped = giveItem(h.m, ps, HAMMER);
|
||||
const eq = ps.equip(equipped.uid, ops[0].uid);
|
||||
assert.equal(eq.ok, true, eq.error || eq.detail);
|
||||
assert.equal(ops[0].items[0].uid, equipped.uid);
|
||||
}
|
||||
if (fill) {
|
||||
for (let i = 0; i < ps.hand.length; i++) if (!ps.hand[i]) giveItem(h.m, ps, FILLER, 'hand', i);
|
||||
for (let i = 0; i < ps.temp.length; i++) if (!ps.temp[i]) giveItem(h.m, ps, FILLER, 'temp', i);
|
||||
assert.ok(ps.hand.every(Boolean) && ps.temp.every(Boolean));
|
||||
}
|
||||
return { ps, carrier: ops[0], equipped };
|
||||
}
|
||||
|
||||
test('维多利亚 pays a 战栗维式重锤 at prep end: the equipped copy stays for the fight, and they merge at the next prep', () => {
|
||||
const h = fresh();
|
||||
const { ps, carrier, equipped } = arm(h, { layers: 25 });
|
||||
assert.equal(ps.setReady(true).ok, true);
|
||||
h.m.endPrep();
|
||||
assert.equal(h.m.phase, 'COMBAT');
|
||||
assert.equal(carrier.items.length, 1);
|
||||
assert.equal(carrier.items[0].uid, equipped.uid, 'the equipped hammer was not taken off');
|
||||
assert.equal(carrier.items[0].id, HAMMER);
|
||||
assert.equal(copies(ps, HAMMER), 2, 'the new copy is in hand or temp');
|
||||
assert.equal(copies(ps, GOLDEN), 0, 'no golden yet');
|
||||
const input = FakeBattle.instances.at(-1).opts.players[0];
|
||||
const unit = input.units.find((u) => u.uid === carrier.uid);
|
||||
assert.ok(unit && unit.items.includes(HAMMER), 'the fight is built with the hammer still equipped');
|
||||
h.toPrep(2);
|
||||
assert.equal(h.m.phase, 'PREP');
|
||||
assert.equal(copies(ps, GOLDEN), 1, 'the pair merged when the next prep started');
|
||||
assert.equal(copies(ps, HAMMER), 0);
|
||||
h.invariants();
|
||||
h.m.dispose();
|
||||
});
|
||||
|
||||
test('two milestones owed at prep end both wait: no golden before the fight; the next prep merges the hand copies and leaves the equipped one', () => {
|
||||
const h = fresh();
|
||||
const { ps, carrier, equipped } = arm(h, { layers: 50 });
|
||||
assert.equal(ps.setReady(true).ok, true);
|
||||
h.m.endPrep();
|
||||
assert.equal(carrier.items[0].uid, equipped.uid);
|
||||
assert.equal(copies(ps, HAMMER), 3, 'equipped + two stowed, the nested onGain grant included');
|
||||
assert.equal(copies(ps, GOLDEN), 0);
|
||||
h.toPrep(2);
|
||||
assert.equal(copies(ps, GOLDEN), 1);
|
||||
assert.equal(copies(ps, HAMMER), 1);
|
||||
assert.equal(carrier.items[0] && carrier.items[0].uid, equipped.uid, 'the hand copies merged with each other; the equipped one stayed');
|
||||
h.m.dispose();
|
||||
});
|
||||
|
||||
test('hand and temp full at prep end: the new hammer is destroyed, the equipped one stays, and the next prep does not put a golden in the slot', () => {
|
||||
const h = fresh();
|
||||
const { ps, carrier, equipped } = arm(h, { layers: 50, fill: true });
|
||||
h.m.endPrep();
|
||||
assert.equal(carrier.items.length, 1);
|
||||
assert.equal(carrier.items[0].uid, equipped.uid);
|
||||
assert.equal(carrier.items[0].id, HAMMER);
|
||||
assert.equal(copies(ps, GOLDEN), 0);
|
||||
assert.equal(copies(ps, HAMMER), 1);
|
||||
const toasts = h.sent.filter(([, msg]) => msg.t === 'm.toast').map(([, msg]) => msg.text);
|
||||
assert.equal(toasts.filter((t) => t === '整备区已满,获得的装备已销毁').length, 2);
|
||||
const input = FakeBattle.instances.at(-1).opts.players[0];
|
||||
assert.ok(input.units.find((u) => u.uid === carrier.uid).items.includes(HAMMER));
|
||||
h.toPrep(2);
|
||||
assert.equal(carrier.items[0].uid, equipped.uid, 'nothing was merged into the slot');
|
||||
assert.equal(copies(ps, GOLDEN), 0);
|
||||
assert.equal(copies(ps, HAMMER), 1);
|
||||
h.m.dispose();
|
||||
});
|
||||
|
||||
test('a buy during prep still merges at once', () => {
|
||||
const h = fresh();
|
||||
const ps = h.ps('p_0');
|
||||
assert.equal(ps.setReady(false).ok, true);
|
||||
giveItem(h.m, ps, HAMMER);
|
||||
ps.funds = 50;
|
||||
ps.shop.slots[0] = { kind: 'item', id: HAMMER, price: 1, basePrice: 1, sold: false };
|
||||
assert.equal(ps.buy(0).ok, true);
|
||||
assert.equal(h.m.phase, 'PREP');
|
||||
assert.equal(copies(ps, HAMMER), 0);
|
||||
assert.equal(copies(ps, GOLDEN), 1);
|
||||
h.m.dispose();
|
||||
});
|
||||
|
||||
test('a grant after the prep has ended (onLayers during combat) still merges at once', () => {
|
||||
const h = fresh();
|
||||
const { ps, carrier, equipped } = arm(h, { layers: 0 });
|
||||
assert.equal(ps.setReady(true).ok, true);
|
||||
h.m.endPrep();
|
||||
assert.equal(h.m.phase, 'COMBAT');
|
||||
assert.equal(carrier.items[0].uid, equipped.uid);
|
||||
assert.equal(ps.addLayers('victoriaShip', 25), 25);
|
||||
assert.equal(copies(ps, HAMMER), 0, 'the equipped copy was merged immediately');
|
||||
assert.equal(copies(ps, GOLDEN), 1);
|
||||
assert.equal(h.m.phase, 'COMBAT');
|
||||
h.m.dispose();
|
||||
});
|
||||
@@ -3,8 +3,9 @@
|
||||
// of the modes whose official modeDataDict inactiveBondIdList switches 奥术 off (with 拉特兰 阿戈尔 卡西米尔 灵巧 奇迹 投资人
|
||||
// 突袭 独行 绝技), yet 深靛 洛洛 阿罗玛 夕 圣聆初雪 stay in the pool through their other bond (server pool.js) and their cards
|
||||
// showed 奥术 like any bond: the detail card's chip read "奥术 0/2 未激活" with three 奥术 operators deployed (the server
|
||||
// leaves an inactive bond out of m.private.bonds) and the bond popup "在场 0/2 未激活". The cards, chips and popup now say
|
||||
// 本局禁用 for a bond the mode never activates (gameLogic modeOffBonds over config.modes[modeId].inactiveBondIds).
|
||||
// used to leave an inactive bond out of m.private.bonds) and the bond popup "在场 0/2 未激活". The cards, chips and popup
|
||||
// say 本局禁用 for a bond the mode never activates (gameLogic modeOffBonds over config.modes[modeId].inactiveBondIds);
|
||||
// since 0.1.3 the view also lists it (`off: true`) so the strip can draw the grey disc — still never in ps.bonds.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
@@ -63,13 +64,15 @@ test('a real 标准 match: 奥术 operators are buyable, three on the board neve
|
||||
const used = new Set();
|
||||
for (const id of members.slice(0, 3)) { const t = legalTileFor(m, ps, id, used); used.add(tileKey(t[0], t[1])); give(m, ps, id, 'board', t); }
|
||||
ps.recompute();
|
||||
assert.equal(ps.bonds.arcaneShip, undefined, 'official: the mode never activates 奥术');
|
||||
assert.equal(ps.bonds.arcaneShip, undefined, 'official: the mode never activates 奥术 — not in the battle state');
|
||||
const priv = ps.privateView();
|
||||
assert.equal(priv.bonds.find((b) => b.bondId === 'arcaneShip'), undefined, 'no 奥术 entry in m.private.bonds');
|
||||
const arc = priv.bonds.find((b) => b.bondId === 'arcaneShip');
|
||||
assert.ok(arc && arc.off === true && arc.active === false && arc.tier === 0 && arc.count === 3, 'the view lists the members as a grey 本局禁用 disc');
|
||||
assert.ok(priv.bonds.slice(priv.bonds.findIndex((b) => b.off)).every((b) => b.off), 'off entries come last');
|
||||
const off = modeOffBonds(getMode(m.modeId));
|
||||
// before: the chip computed 0 members from the missing entry — "奥术:在场 0/2,未激活" with 3 deployed
|
||||
const before = chipOf(BondChips({ bondIds: DATA.chess[members[0]].bonds, bonds: priv.bonds }), 'arcaneShip');
|
||||
assert.match(before.props.title, /在场 0\/2,未激活/);
|
||||
// without the mode's off set the chip would read the new entry as an ordinary inactive bond (在场 3/3,未激活)
|
||||
const bare = chipOf(BondChips({ bondIds: DATA.chess[members[0]].bonds, bonds: priv.bonds }), 'arcaneShip');
|
||||
assert.match(bare.props.title, /在场 3\/3,未激活/);
|
||||
const chip = chipOf(BondChips({ bondIds: DATA.chess[members[0]].bonds, bonds: priv.bonds, off }), 'arcaneShip');
|
||||
assert.ok(hasClass(chip, 'is-off') && !hasClass(chip, 'is-active'));
|
||||
assert.equal(chip.props.title, '奥术:本局禁用(该盟约不会激活)');
|
||||
|
||||
@@ -9,7 +9,7 @@ import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
phaseMode, phaseBanner, isCombatPhase, isBossPhase, countdownState, phaseTotalSeconds, sortBonds, bondTier, nextThreshold,
|
||||
bondMembers, bannedPerBond, priceTone, mergeProgress, shopBlockReason, deploySets, indexPieces, placementContext, canPlace,
|
||||
bondMembers, memberHeadCount, bannedPerBond, priceTone, mergeProgress, shopBlockReason, deploySets, indexPieces, placementContext, canPlace,
|
||||
boardTargets, dropIntent, normalizeDraft, normalizeSp, groupEnemies, factionTypes, snapHud, bossFrac, attackInterval, fmtNum,
|
||||
rangeGridBox, shortcutFor, sanitizeSettings, DEFAULT_SETTINGS, normalizeResult, cycleField, fieldLabel, homeFieldId,
|
||||
activeBubbles, sortedPlayers, tileKey, prepCapsuleLabel, prepCamera, dropFailureReason,
|
||||
@@ -138,9 +138,46 @@ describe('bonds', () => {
|
||||
assert.equal(byId.get(m2).owned, true, 'golden in hand counts as owned base');
|
||||
assert.equal(byId.get(m2).onBoard, false);
|
||||
assert.equal(byId.get(m3).banned, true);
|
||||
assert.equal(byId.get(m2).inHand, true, 'a golden copy in the hand marks the base in hand');
|
||||
assert.equal(rows[0].id, m1, 'on-board first');
|
||||
assert.equal(rows.length, b.visibleMembers.length);
|
||||
});
|
||||
test('memberHeadCount: the hand counts only for a bond that counts the hand, once per row', () => {
|
||||
const rows = [
|
||||
{ id: 'a', onBoard: true, inHand: true },
|
||||
{ id: 'b', onBoard: false, inHand: true },
|
||||
{ id: 'c', onBoard: false, inHand: false },
|
||||
null,
|
||||
];
|
||||
assert.equal(memberHeadCount(rows, false), 1, 'board only');
|
||||
assert.equal(memberHeadCount(rows, true), 2, 'board or hand, one row once');
|
||||
assert.equal(memberHeadCount(null, true), 0);
|
||||
assert.equal(memberHeadCount(undefined), 0);
|
||||
const invest = bonds.investShip;
|
||||
assert.equal(invest.countsHand, true);
|
||||
const [m1, m2] = invest.visibleMembers;
|
||||
const priv = privWith({
|
||||
board: [{ ...piece(m1), row: 9, col: 3 }],
|
||||
hand: [piece(m2)],
|
||||
temp: [piece(invest.visibleMembers[2] || m2)],
|
||||
});
|
||||
const got = bondMembers(invest, priv, [], getChess);
|
||||
const byId = new Map(got.map((r) => [r.id, r]));
|
||||
assert.equal(byId.get(m1).onBoard, true);
|
||||
assert.equal(byId.get(m2).inHand, true);
|
||||
assert.equal(byId.get(m2).onBoard, false);
|
||||
if (invest.visibleMembers[2]) assert.equal(byId.get(invest.visibleMembers[2]).inHand, false, 'a temporary-slot copy is not in hand');
|
||||
assert.equal(memberHeadCount(got, true), 2, 'the board member and the hand member, not the temporary slot');
|
||||
assert.equal(memberHeadCount(got, false), 1, 'without countsHand the hand member is left out');
|
||||
});
|
||||
test('sortBonds puts a mode-off bond after every live bond', () => {
|
||||
const sorted = sortBonds([
|
||||
{ bondId: 'investShip', off: true, active: false, layers: 99, count: 3, tier: 0 },
|
||||
{ bondId: 'yanShip', active: true, layers: 1, count: 3, tier: 1 },
|
||||
{ bondId: 'raidShip', off: true, layers: 0, count: 1 },
|
||||
], (id) => bonds[id]).map((b) => b.bondId);
|
||||
assert.deepEqual(sorted, ['yanShip', 'investShip', 'raidShip']);
|
||||
});
|
||||
test('bannedPerBond counts banned visible members', () => {
|
||||
const b = bonds.deputShip;
|
||||
const per = bannedPerBond(Object.values(bonds), [b.visibleMembers[0], b.visibleMembers[1], 'nope']);
|
||||
|
||||
Reference in new issue
Block a user