feedback1 integrate: WE 鸭爵 strategy (圆仔 unblockable, hurt speed +300 %, swaps in the Final Assault and Hidden Core)
This commit is contained in:
commit
8ffd075c9e
12 files changed
+311
-25
No files matched your search
+1
-1
@@ -1 +1 @@
|
||||
{"enemy_1000_gopro_2":{"key":"enemy_1000_gopro_2","name":"猎狗pro","level":0,"rank":"NORMAL","handbookIndex":"O2","desc":"比一般猎狗更具有作战能力,行动速度很快。","descRaw":"比一般猎狗更具有作战能力,<@eb.key>行动速度很快</>。","applyWay":"MELEE","stats":{"maxHp":1700,"atk":260,"def":0,"res":20,"moveSpeed":1.9,"bat":1.4,"aspd":100,"rangeRadius":0,"rawRangeRadius":0,"blockCnt":1,"massLevel":0,"lpr":1,"hpRecoveryPerSec":0,"elementRes":0,"elementDmgRes":0,"hitRatePhys":0,"hitRateArts":0,"dmgType":"phys","dmgTypes":["phys"],"motion":"WALK","immunities":{"stun":false,"silence":false,"sleep":false,"frozen":false,"levitate":false},"otherImmunities":[],"tauntLevel":0},"abilities":[],"talents":{"bb":{},"bbStr":{}},"skills":[],"notCountInTotal":false,"tags":["infection"],"be":3060,"beFactor":1,"attrPower":3060,"isFlyEnemy":false,"tokenOnly":false,"acTypes":["SPECIAL"],"acType":"SPECIAL","templateSlot":"T","summons":[],"inactiveIn":[],"seasonOverride":null,"iconId":"enemy_1000_gopro_2","spine":"enemy_1000_gopro_2","modelScale":0.963},"enemy_1001_bigbo":{"key":"enemy_1001_bigbo","name":"大鲍勃","level":0,"rank":"BOSS","handbookIndex":"11","desc":"整合运动的特殊近战人员,使用电锯进行攻击,兼具高耐久、攻击力和防御力。","descRaw":"整合运动的特殊近战人员,使用电锯进行攻击,兼具<@eb.danger>高耐久、攻击力和防御力</>。","applyWay":"MELEE","stats":{"maxHp":22000,"atk":1900,"def":800,"res":30,"moveSpeed":0.5,"bat":6,"aspd":100,"rangeRadius":0,"rawRangeRadius":0,"blockCnt":1,"massLevel":4,"lpr":1,"hpRecoveryPerSec":0,"elementRes":0,"elementDmgRes":0,"hitRatePhys":0,"hitRateArts":0,"dmgType":"phys","dmgTypes":["phys"],"motion":"WALK","immunities":{"stun":false,"silence":false,"sleep":false,"frozen":false,"levitate":false},"otherImmunities":[],"tauntLevel":0},"abilities":[],"talents":{"bb":{},"bbStr":{}},"skills":[],"notCountInTotal":false,"tags":[],"be":33990,"beFactor":1,"attrPower":33990,"isFlyEnemy":false,"tokenOnly":false,"acTypes":[],"acType":null,"templateSlot":null,"summons":[],"inactiveIn":[],"seasonOverride":null,"iconId":"enemy_1001_bigbo","spine":"enemy_1001_bigbo","modelScale":1.1111},"enemy_1005_yokai":{"key":"enemy_1005_yokai","name":"妖怪","level":0,"rank":"NORMAL","handbookIndex":"D1","desc":"【飞行单位】敌方人员操纵的无人机,不会进行攻击。","descRaw":"<@eb.key>【飞行单位】</>敌方人员操纵的无人机,不会进行攻击。","applyWay":"NONE","stats":{"maxHp":800,"atk":0,"def":50,"res":0,"moveSpeed":0.9,"bat":2.3,"aspd":100,"rangeRadius":0,"rawRangeRadius":0,"blockCnt":1,"massLevel":0,"lpr":1,"hpRecoveryPerSec":0,"elementRes":0,"elementDmgRes":0,"hitRatePhys":0,"hitRateArts":0,"dmgType":"none","dmgTypes":["none"],"motion":"FLY","immunities":{"stun":false,"silence":false,"sleep":false,"frozen":false,"levitate":false},"otherImmunities":[],"tauntLevel":0},"abilities":[{"text":"飞行单位","textRaw":"飞行单位","format":"NORMAL"}],"talents":{"bb":{},"bbStr":{}},"skills":[],"notCountInTotal":false,"tags":["drone"],"be":950,"beFactor":1,"attrPower":950,"isFlyEnemy":true,"tokenOnly":false,"acTypes":["FLY"],"acType":"FLY","templateSlot":"NF","summons":[],"inactiveIn":[],"seasonOverride":null,"iconId":"enemy_1005_yokai","spine":"enemy_1005_yokai","modelScale":0.7407},"enemy_1005_yokai_2":{"key":"enemy_1005_yokai_2","name":"妖怪MKII","level":0,"rank":"NORMAL","handbookIndex":"D2","desc":"【飞行单位】妖怪的改进版,可进行远程物理射击。","descRaw":"<@eb.key>【飞行单位】</>妖怪的改进版,可进行<@eb.key>远程物理射击</>。","applyWay":"RANGED","stats":{"maxHp":1550,"atk":220,"def":50,"res":0,"moveSpeed":0.9,"bat":3,"aspd":100,"rangeRadius":2,"rawRangeRadius":2,"blockCnt":1,"massLevel":0,"lpr":1,"hpRecoveryPerSec":0,"elementRes":0,"elementDmgRes":0,"hitRatePhys":0,"hitRateArts":0,"dmgType":"phys","dmgTypes":["phys"],"motion":"FLY","immunities":{"stun":false,"silence":false,"sleep":false,"frozen":false,"levitate":false},"otherImmunities":[],"tauntLevel":0},"abilities":[{"text":"飞行单位","textRaw":"飞行单位","format":"NORMAL"}],"talents":{"bb":{},"bbStr":{}},"skills":[],"notCountInTotal":false,"tags":["drone"],"be":4000,"beFactor":0.7,"attrPower":2800,"isFlyEnemy":true,"tokenOnly":false,"acTypes":["FLY"],"acType":"FLY","templateSlot":null,"summons":[],"inactiveIn":[],"seasonOverride":null,"iconId":"enemy_1005_yokai_2","spine":"enemy_1005_yokai_2","modelScale":0.8148},"enemy_1005_yokai_3":{"key":"enemy_1005_yokai_3","name":"威龙","level":0,"rank":"ELITE","handbookIndex":"D3","desc":"【飞行单位】大型无人机,拥有更好的耐久性能,可进行远程物理射击。","descRaw":"<@eb.key>【飞行单位】</>大型无人机,拥有更好的耐久性能,可进行<@eb.key>远程物理射击</>。","applyWay":"RANGED","stats":{"maxHp":30000,"atk":660,"def":170,"res":0,"moveSpeed":0.5,"bat":3.8,"aspd":100,"rangLine truncated
|
||||
{"enemy_1000_gopro_2":{"key":"enemy_1000_gopro_2","name":"猎狗pro","level":0,"rank":"NORMAL","handbookIndex":"O2","desc":"比一般猎狗更具有作战能力,行动速度很快。","descRaw":"比一般猎狗更具有作战能力,<@eb.key>行动速度很快</>。","applyWay":"MELEE","stats":{"maxHp":1700,"atk":260,"def":0,"res":20,"moveSpeed":1.9,"bat":1.4,"aspd":100,"rangeRadius":0,"rawRangeRadius":0,"blockCnt":1,"massLevel":0,"lpr":1,"hpRecoveryPerSec":0,"elementRes":0,"elementDmgRes":0,"hitRatePhys":0,"hitRateArts":0,"dmgType":"phys","dmgTypes":["phys"],"motion":"WALK","immunities":{"stun":false,"silence":false,"sleep":false,"frozen":false,"levitate":false},"otherImmunities":[],"tauntLevel":0},"abilities":[],"talents":{"bb":{},"bbStr":{}},"skills":[],"notCountInTotal":false,"tags":["infection"],"be":3060,"beFactor":1,"attrPower":3060,"isFlyEnemy":false,"tokenOnly":false,"acTypes":["SPECIAL"],"acType":"SPECIAL","templateSlot":"T","summons":[],"inactiveIn":[],"seasonOverride":null,"iconId":"enemy_1000_gopro_2","spine":"enemy_1000_gopro_2","modelScale":0.963},"enemy_1001_bigbo":{"key":"enemy_1001_bigbo","name":"大鲍勃","level":0,"rank":"BOSS","handbookIndex":"11","desc":"整合运动的特殊近战人员,使用电锯进行攻击,兼具高耐久、攻击力和防御力。","descRaw":"整合运动的特殊近战人员,使用电锯进行攻击,兼具<@eb.danger>高耐久、攻击力和防御力</>。","applyWay":"MELEE","stats":{"maxHp":22000,"atk":1900,"def":800,"res":30,"moveSpeed":0.5,"bat":6,"aspd":100,"rangeRadius":0,"rawRangeRadius":0,"blockCnt":1,"massLevel":4,"lpr":1,"hpRecoveryPerSec":0,"elementRes":0,"elementDmgRes":0,"hitRatePhys":0,"hitRateArts":0,"dmgType":"phys","dmgTypes":["phys"],"motion":"WALK","immunities":{"stun":false,"silence":false,"sleep":false,"frozen":false,"levitate":false},"otherImmunities":[],"tauntLevel":0},"abilities":[],"talents":{"bb":{},"bbStr":{}},"skills":[],"notCountInTotal":false,"tags":[],"be":33990,"beFactor":1,"attrPower":33990,"isFlyEnemy":false,"tokenOnly":false,"acTypes":[],"acType":null,"templateSlot":null,"summons":[],"inactiveIn":[],"seasonOverride":null,"iconId":"enemy_1001_bigbo","spine":"enemy_1001_bigbo","modelScale":1.1111},"enemy_1005_yokai":{"key":"enemy_1005_yokai","name":"妖怪","level":0,"rank":"NORMAL","handbookIndex":"D1","desc":"【飞行单位】敌方人员操纵的无人机,不会进行攻击。","descRaw":"<@eb.key>【飞行单位】</>敌方人员操纵的无人机,不会进行攻击。","applyWay":"NONE","stats":{"maxHp":800,"atk":0,"def":50,"res":0,"moveSpeed":0.9,"bat":2.3,"aspd":100,"rangeRadius":0,"rawRangeRadius":0,"blockCnt":1,"massLevel":0,"lpr":1,"hpRecoveryPerSec":0,"elementRes":0,"elementDmgRes":0,"hitRatePhys":0,"hitRateArts":0,"dmgType":"none","dmgTypes":["none"],"motion":"FLY","immunities":{"stun":false,"silence":false,"sleep":false,"frozen":false,"levitate":false},"otherImmunities":[],"tauntLevel":0},"abilities":[{"text":"飞行单位","textRaw":"飞行单位","format":"NORMAL"}],"talents":{"bb":{},"bbStr":{}},"skills":[],"notCountInTotal":false,"tags":["drone"],"be":950,"beFactor":1,"attrPower":950,"isFlyEnemy":true,"tokenOnly":false,"acTypes":["FLY"],"acType":"FLY","templateSlot":"NF","summons":[],"inactiveIn":[],"seasonOverride":null,"iconId":"enemy_1005_yokai","spine":"enemy_1005_yokai","modelScale":0.7407},"enemy_1005_yokai_2":{"key":"enemy_1005_yokai_2","name":"妖怪MKII","level":0,"rank":"NORMAL","handbookIndex":"D2","desc":"【飞行单位】妖怪的改进版,可进行远程物理射击。","descRaw":"<@eb.key>【飞行单位】</>妖怪的改进版,可进行<@eb.key>远程物理射击</>。","applyWay":"RANGED","stats":{"maxHp":1550,"atk":220,"def":50,"res":0,"moveSpeed":0.9,"bat":3,"aspd":100,"rangeRadius":2,"rawRangeRadius":2,"blockCnt":1,"massLevel":0,"lpr":1,"hpRecoveryPerSec":0,"elementRes":0,"elementDmgRes":0,"hitRatePhys":0,"hitRateArts":0,"dmgType":"phys","dmgTypes":["phys"],"motion":"FLY","immunities":{"stun":false,"silence":false,"sleep":false,"frozen":false,"levitate":false},"otherImmunities":[],"tauntLevel":0},"abilities":[{"text":"飞行单位","textRaw":"飞行单位","format":"NORMAL"}],"talents":{"bb":{},"bbStr":{}},"skills":[],"notCountInTotal":false,"tags":["drone"],"be":4000,"beFactor":0.7,"attrPower":2800,"isFlyEnemy":true,"tokenOnly":false,"acTypes":["FLY"],"acType":"FLY","templateSlot":null,"summons":[],"inactiveIn":[],"seasonOverride":null,"iconId":"enemy_1005_yokai_2","spine":"enemy_1005_yokai_2","modelScale":0.8148},"enemy_1005_yokai_3":{"key":"enemy_1005_yokai_3","name":"威龙","level":0,"rank":"ELITE","handbookIndex":"D3","desc":"【飞行单位】大型无人机,拥有更好的耐久性能,可进行远程物理射击。","descRaw":"<@eb.key>【飞行单位】</>大型无人机,拥有更好的耐久性能,可进行<@eb.key>远程物理射击</>。","applyWay":"RANGED","stats":{"maxHp":30000,"atk":660,"def":170,"res":0,"moveSpeed":0.5,"bat":3.8,"aspd":100,"rangLine truncated
|
||||
+1
-1
@@ -337,7 +337,7 @@ blackboards (transitively). Level = `randomEnemyAttributeDict[key].level` (0 for
|
||||
|---|---|---|
|
||||
| `key`, `name`, `level`, `rank`, `handbookIndex` | …, `"萨卡兹枯朽前锋"`, `0`, `"NORMAL"`, `"NAC18"` | `rank` NORMAL / ELITE / BOSS |
|
||||
| `desc`, `descRaw`, `applyWay` | …, `"MELEE"` | `applyWay` MELEE / RANGED / ALL / NONE |
|
||||
| `stats` | `{"maxHp":4700,"atk":300,"def":100,"res":40,"moveSpeed":0.8,"bat":3,"aspd":100,"rangeRadius":0,"rawRangeRadius":-1,"blockCnt":1,"massLevel":1,"lpr":1,"hpRecoveryPerSec":0,"elementRes":0,"elementDmgRes":0,"hitRatePhys":0,"hitRateArts":0,"dmgType":"phys","dmgTypes":["phys","arts"],"motion":"WALK","immunities":{…},"otherImmunities":[],"tauntLevel":0}` | level-0 (unscaled). **`rangeRadius` ≥ 0 is the normal-attack radius under the DESIGN §5.5 contract: `> 0` ⇔ the enemy attacks units it is not blocked by; always 0 for `applyWay` MELEE** (melee enemies only hit their blocker; 粉碎攻坚手/宿主士兵/深池方阵步兵 carry an official 1.2–2.5 used by abilities) and negative sentinels become 0. `rawRangeRadius` = the official value. `blockCnt` = block slots it consumes; `lpr` = lifePointReduce; `dmgType` = first handbook damage type (`phys`/`arts`/`heal`/`none`/…), full list in `dmgTypes`. `elementRes` = 损伤抵抗 (official `epResistance` = EP_RESISTANCE, % off every element gauge fill; 10 on 转译基底·α, 0 on every other enemy here) and `elementDmgRes` = 元素抗性 (`epDamageResistance`, % off 元素伤害 — the bursts' HP damage; 0 on every enemy here — newer event enemies of the full database reach 10–15 on both) — PRTS 元素 / 游戏数据基础 and the enemy pages' 损伤抵抗 / 元素抗性 rows. Undefined database fields (`m_defined:false`) holding the zero value fall back to the defaults `lpr` 1, `bat` 1, `aspd` 100, `moveSpeed` 1 (see §15) |
|
||||
| `stats` | `{"maxHp":4700,"atk":300,"def":100,"res":40,"moveSpeed":0.8,"bat":3,"aspd":100,"rangeRadius":0,"rawRangeRadius":-1,"blockCnt":1,"massLevel":1,"lpr":1,"hpRecoveryPerSec":0,"elementRes":0,"elementDmgRes":0,"hitRatePhys":0,"hitRateArts":0,"dmgType":"phys","dmgTypes":["phys","arts"],"motion":"WALK","immunities":{…},"otherImmunities":[],"tauntLevel":0}` | level-0 (unscaled). **`rangeRadius` ≥ 0 is the normal-attack radius under the DESIGN §5.5 contract: `> 0` ⇔ the enemy attacks units it is not blocked by; always 0 for `applyWay` MELEE** (melee enemies only hit their blocker; 粉碎攻坚手/宿主士兵/深池方阵步兵 carry an official 1.2–2.5 used by abilities) and negative sentinels become 0. `rawRangeRadius` = the official value. `blockCnt` = block slots it consumes; `lpr` = lifePointReduce (LP cost of a leak; except the 鸭爵 strategy's four swapped-in `_2` enemies — the `round_start_all_player_change_enemy_2` enemylist — which cost 1, not the database's roguelike 0: PRTS 下半/PRTS盟约记录 鸭爵 备注 "进入保护目标点将减少1点目标生命值"; build-data `bandSwapEnemyKeys`); `dmgType` = first handbook damage type (`phys`/`arts`/`heal`/`none`/…), full list in `dmgTypes`. `elementRes` = 损伤抵抗 (official `epResistance` = EP_RESISTANCE, % off every element gauge fill; 10 on 转译基底·α, 0 on every other enemy here) and `elementDmgRes` = 元素抗性 (`epDamageResistance`, % off 元素伤害 — the bursts' HP damage; 0 on every enemy here — newer event enemies of the full database reach 10–15 on both) — PRTS 元素 / 游戏数据基础 and the enemy pages' 损伤抵抗 / 元素抗性 rows. Undefined database fields (`m_defined:false`) holding the zero value fall back to the defaults `lpr` 1, `bat` 1, `aspd` 100, `moveSpeed` 1 (see §15) |
|
||||
| `abilities[]` | `{"text":"位于源石污染区内时,攻击造成法术伤害","textRaw":"…","format":"NORMAL"}` | handbook ability list |
|
||||
| `talents` | `{"bb":{"1.hp_ratio":0.2,"1.damage_scale":0.5},"bbStr":{}}` | talentBlackboard |
|
||||
| `skills[]` | `{"prefabKey":"2","priority":0,"cooldown":50,"initCooldown":0,"spCost":0,"bb":{"hp_ratio":0.02},"bbStr":{"branch_id":"boss_summon_enemy","enemy_key":"enemy_1005_yokai"}}` | `branch_id` → `waves[t].branches` |
|
||||
|
||||
+1
-1
@@ -263,7 +263,7 @@ Every handler method is `(ctx, ev)`; `ev` is shared by all handlers of one dispa
|
||||
| `onMerge` | chess or item merge | `{ kind, piece, baseId|itemId, consumed:[uid], area? }` — a chess merge's `area` is where its elite went: 'board' (a consumed copy's tile) \| 'hand' \| 'temp' |
|
||||
| `onLevelUp` | shop level up | `{ level, price }` |
|
||||
| `onSpend` | a payment's action is complete (buy / refresh / levelUp / reward / effect) | `{ amount, reason, total }` (`total` = funds spent this match) |
|
||||
| `onBattleStart` | a battle input is built (normal / unite / boss / hidden); also for the stats preview (`g.unitStats`, DESIGN §18.5) | `{ input: PlayerBattleInput, kind, round, spawns?, preview? }` — mutate/replace `ev.input`. With `ev.preview` true (no `ev.spawns`) a handler must NOT change match state or draw match rng: the preview only reads the input |
|
||||
| `onBattleStart` | a battle input is built (normal / unite / boss / hidden); also for the stats preview (`g.unitStats`, DESIGN §18.5) | `{ input: PlayerBattleInput, kind, round, spawns?, routes?, side?, preview? }` — mutate/replace `ev.input`; `ev.spawns` (the field's spawn list, shared by a boss field's players) may be edited in place. Boss / hidden fields also carry the wave's `routes` and the player's `side` ('L' / 'R' on a pair field — its half is the routes ending left / right of the middle column — null on a one-player field), so a handler can edit only the player's own enemies (鸭爵's swap). With `ev.preview` true (no `ev.spawns`) a handler must NOT change match state or draw match rng: the preview only reads the input |
|
||||
| `onBattleResult` | SETTLE (normal rounds) and after boss fields | `{ result, lpLoss, perfect, unite?|boss? }` |
|
||||
| `onChoicePick` | a 机变 card was applied (the card's own `choice:` handler runs first, then every source observes) | `{ card, family, picker, forTeammate }` |
|
||||
| `onEquip` | `g.equip` (item handler only) | `{ item, target, golden, consumed, keep, error }` — see §2.5 |
|
||||
|
||||
@@ -75,6 +75,7 @@
|
||||
- **推开 / 拉拽**按官方「力度 − 重量」决定距离:例如野鬃二技能(中等力度)把重量 0 / 1 / 2 / 3 的敌人推开约 2.1 / 1.7 / 0.4 / 0.1 格,重量 4 以上推不动——轻型敌人反而推得更远。攻击速度最低为 20(沼泽、寒霜等减速叠满时也不会更慢)。
|
||||
- **元素损伤条**在单位自己的血条正下方:左边是元素图标,白条是剩余的元素值,只有真正爆发时才会耗尽;爆发后条变成元素的颜色、图标闪烁,在冷却期间慢慢回满(这段时间不会再积累元素损伤)。
|
||||
- **敌人**:远程敌人的射程按官方从干员的碰撞体积(半径 0.25 格)算起,比数据上的攻击范围半径远 0.25 格——例如萨卡兹枯朽战车(射程 2.2)能打到中心 2.45 格以内、站在低地的干员。它的第一下攻击就是「秽蚀轰击」(在目标处留下半径 1.7、持续 10 秒的污染区:低地每秒 50、高台 25 点真实伤害,同名不叠加),被阻挡时普通攻击伤害翻倍。“萨科塔之翼”等血量第一次低于一半时会在原地小范围乱飞 5 秒(官方叫「恐惧」);叙拉古、妮芙等干员造成的恐惧会让敌人朝远离干员的方向逃跑。
|
||||
- **策略「鸭爵」(“神秘顾客”)**:从第 5 回合起,你和队友的每场作战(**最终攻势和隐秘核心也算**,联防不另外替换)有 0–2 名地面敌人(不含领袖及其部件、悬赏敌人)被替换为特殊版本的鸭爵、高普尼克、流泪小子或圆仔,出现在被替换敌人的时间和路线上(两人同场的领袖战里,各自只替换走向自己保护目标点的敌人);击倒者获得 1 资金(联防中的队友也算),漏进保护目标点照常扣 1 点目标生命值。鸭爵和圆仔**不攻击、无法被阻挡**:鸭爵受到伤害后移动速度 +300%;圆仔始终面向我方干员较多的一侧,来自正面的物理 / 法术伤害 −80%。流泪小子隐匿(被阻挡时才能被选中),攻击使目标晕眩 3.5 秒;高普尼克免疫晕眩、冻结和浮空。
|
||||
- 右上角 🔍▶▶ 可以查看本回合即将来袭的敌人(预览区),左上角 🔍 查看本局信息(禁用盟约、策略)。
|
||||
|
||||
## 5. 联防(同盟)
|
||||
|
||||
@@ -2825,7 +2825,8 @@ export class Match {
|
||||
const inputs = g.map((ps, j) => {
|
||||
const input = ps.battleInput({ side: j === 0 ? 'L' : 'R', colOffset: j === 0 ? 0 : 8 });
|
||||
input.lpForBoss = this.teamLp;
|
||||
const ev = { input, kind: hidden ? 'hidden' : 'boss', round: this.round, spawns };
|
||||
// `side` + `routes`: the player's half of a pair field (spawn-list edits for one player, e.g. 鸭爵's swap)
|
||||
const ev = { input, kind: hidden ? 'hidden' : 'boss', round: this.round, spawns, routes: wave.routes, side: g.length > 1 ? (j === 0 ? 'L' : 'R') : null };
|
||||
this.dispatch(ps, 'onBattleStart', ev);
|
||||
return ev.input && typeof ev.input === 'object' ? ev.input : input;
|
||||
});
|
||||
|
||||
@@ -19,7 +19,9 @@
|
||||
// round_start_bond_check_gain_layer 余 round start of `round`: exactly factioncount active bonds → +count1, else each active +count2
|
||||
// up_shop_add_special_goods {count,choice,pool} 凯瑟琳 every level-up: pick 1 of `count` items of pool (free)
|
||||
// coin_carry_over {capital,interest,max} 坎诺特 leftover ≥ capital at round start → +min(max, ⌊left/capital⌋×interest) income
|
||||
// round_start_all_player_change_enemy_2 鸭爵 global: every alive player's normal battle from `round` (own + teammates)
|
||||
// round_start_all_player_change_enemy_2 鸭爵 global: every alive player's battle from `round` (own + teammates;
|
||||
// normal, Final Assault, Hidden Core — on the player's half of a
|
||||
// pair field): 0–2 ground enemies → enemylist
|
||||
// round_start_activate_char_chess_effect_in_board 铃兰 round start: 获得时 traits of the right-most (then bottom-most) board op with one
|
||||
// first_sell_char_chess_exchange_char_chess_in_shop 巫恋 first sale of a normal op per round: swapped with a random shop chess (no funds)
|
||||
// round_start_gain_char_chess_in_shop_every_n_round 松桐 round % n = 0: a random shop chess for free (its slot empties)
|
||||
@@ -28,6 +30,7 @@
|
||||
// preparation_start_add_special_goods_every_n_round 娜仁图亚 round % n = 0: pick choice_cnt of refresh_cnt items of pool (free)
|
||||
// "进入休整期时" / "回合开始时" = onRoundStart (research 01 §5 step 6).
|
||||
|
||||
import { GEO } from '../../../../shared/constants.js';
|
||||
import { buffsOf, num, bandRecord, gameData } from '../support/index.js';
|
||||
import { metaBonds } from '../support/meta.js';
|
||||
import * as garrisons from '../garrisons.js';
|
||||
@@ -348,31 +351,61 @@ K.preparation_start_add_special_goods_every_n_round = (ps) => ({
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------
|
||||
// 鸭爵: "你和队友遭遇的部分敌人" — a global handler rewrites the normal-battle spawn list of every alive player while
|
||||
// the band is held by that player or a teammate. From the `minweight`–`maxweight` share of the wave (time order),
|
||||
// min..max NORMAL ground enemies are replaced by a random enemylist enemy worth 1 fund to its killer (bounty, also in
|
||||
// 联防). The originals never exist, so none of their death / kill / leak effects happen.
|
||||
// 鸭爵 “神秘顾客”: "从第5回合起,你和队友遭遇的部分敌人可能会替换为<鸭爵><高普尼克><流泪小子><圆仔>,击倒这些敌人者获得1资金奖励
|
||||
// (包括联防阶段)". A global handler rewrites the battle spawn list of every alive player while the band is held by that
|
||||
// player or a teammate: from round `round`, in the normal battle, the Final Assault and the Hidden Core (PRTS 卫戍协议:盟约
|
||||
// 下半/PRTS盟约记录 鸭爵 备注 "每回合将有0~2名敌人被替换为上述敌人之一…且在最终回合和隐秘核心回合中仍然生效"; the 联防 phase
|
||||
// replaces nothing: its enemies are leaks, which keep their bounty). min..max (0–2, uniform [ASSUMED]) of the player's
|
||||
// ground enemies from the `minweight`–`maxweight` share of the player's enemies (time order) [ASSUMED reading of the two
|
||||
// weights] — any wave enemy but the leader, its parts, bounties, earlier swaps and uncounted units. Elites are eligible
|
||||
// too [ASSUMED: the text names no rank; one rule for every battle; about two thirds of the leader rounds' escorts in that
|
||||
// share are elites, so a NORMAL-only rule would leave most Final Assault halves without a candidate — ≈ 80 % instead of
|
||||
// ≈ 36 % of the R14 pair-field halves over 40 seeds]. Flyers are never swapped [ASSUMED: the four are ground units and a
|
||||
// FLY action's route is a FLY route]. A swapped unit becomes a random enemylist enemy (the act2 `_2` versions) at the
|
||||
// replaced unit's time and route, worth DUCK_COINS to its killer (bounty, also in 联防). A pair boss field has one spawn
|
||||
// list that its two players' handlers edit in turn; each player's 0–2 come from the enemies whose route ends on that
|
||||
// player's half (`ev.side` + `ev.routes`, the half rule of the field's bounties, server/match/waves.js routeByMotion), the
|
||||
// share taken over that half [ASSUMED: "你和队友遭遇的敌人" — the enemies heading for the player's own protection point;
|
||||
// each official client simulates its own battle, docs/research/08 appendix A]. The originals never exist, so none of
|
||||
// their death / kill / leak effects happen. Reaching the protection point costs 1 LP ("但进入保护目标点将减少1点目标生命值":
|
||||
// the enemies' data lpr, tools/build-data.mjs).
|
||||
|
||||
export const DUCK_BAND = 'band_ducklord';
|
||||
/** "击倒这些敌人者获得1资金奖励" (the blackboard's `count` is 1 too). */
|
||||
export const DUCK_COINS = 1;
|
||||
/** Battle kinds the swap applies to (PRTS: the Final Assault and the Hidden Core too). */
|
||||
const DUCK_KINDS = new Set(['normal', 'boss', 'hidden']);
|
||||
/** Boss-field column between the left ('L') and the right ('R') player's half (the goals are at cols 2–3 / 17). */
|
||||
const BOSS_MID_COL = (GEO.BOSS_RECT.c0 + GEO.BOSS_RECT.c1) / 2;
|
||||
|
||||
function duckParams() {
|
||||
for (const b of buffsOf(bandRecord(DUCK_BAND))) if (b.key === 'round_start_all_player_change_enemy_2') return b.p;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Rewrite `spawns` in place (exported for tests). Returns the replaced spawn specs. */
|
||||
export function duckReplace(ctx, spawns, p, ownerPlayerId) {
|
||||
/**
|
||||
* Rewrite `spawns` in place (exported for tests). Returns the replaced spawn specs. `side` ('L' | 'R', a pair boss
|
||||
* field) limits the swap to the spawns whose route (`routes[routeIndex]`) ends on that half of the field.
|
||||
*/
|
||||
export function duckReplace(ctx, spawns, p, ownerPlayerId, { routes = null, side = null } = {}) {
|
||||
const gd = ctx.gd;
|
||||
const ducks = list(p.enemylist).filter((k) => gd.enemy(k));
|
||||
if (!ducks.length || !Array.isArray(spawns)) return [];
|
||||
const lo = Math.max(0, int(p.min, 0)), hi = Math.max(lo, int(p.max, lo));
|
||||
const want = lo + ctx.rng.int(hi - lo + 1);
|
||||
if (want <= 0) return [];
|
||||
// one entry per enemy (split multi-count specs), in spawn-time order
|
||||
const half = side === 'L' || side === 'R';
|
||||
const onHalf = (s) => {
|
||||
if (!half) return true;
|
||||
const r = Array.isArray(routes) ? routes[s.routeIndex] : null;
|
||||
const c = r && Array.isArray(r.end) ? Number(r.end[1]) : NaN;
|
||||
return side === 'R' ? c > BOSS_MID_COL : c < BOSS_MID_COL;
|
||||
};
|
||||
// one entry per enemy of the player (split multi-count specs), in spawn-time order
|
||||
const flat = [];
|
||||
for (let i = 0; i < spawns.length; i++) {
|
||||
const s = spawns[i];
|
||||
if (!s || !onHalf(s)) continue;
|
||||
const cnt = Math.max(1, int(s.count, 1));
|
||||
for (let k = 0; k < cnt; k++) flat.push({ i, k, t: num(s.time, 0) + k * num(s.interval, 0) });
|
||||
}
|
||||
@@ -383,7 +416,8 @@ export function duckReplace(ctx, spawns, p, ownerPlayerId) {
|
||||
const s = spawns[f.i];
|
||||
const e = gd.enemy(s.enemyKey);
|
||||
const frac = n > 1 ? j / (n - 1) : 0;
|
||||
return !s.tag && e && e.rank === 'NORMAL' && e.stats?.motion !== 'FLY' && frac >= w0 - 1e-9 && frac <= w1 + 1e-9;
|
||||
return !s.tag && s.countInTotal !== false && e && e.rank !== 'BOSS' && !e.notCountInTotal && e.stats?.motion !== 'FLY'
|
||||
&& frac >= w0 - 1e-9 && frac <= w1 + 1e-9;
|
||||
});
|
||||
const picked = ctx.rng.shuffle(ok).slice(0, want);
|
||||
if (!picked.length) return [];
|
||||
@@ -421,12 +455,12 @@ export function duckReplace(ctx, spawns, p, ownerPlayerId) {
|
||||
|
||||
const duckGlobal = {
|
||||
onBattleStart(ctx, ev) {
|
||||
if (!ev || ev.kind !== 'normal' || !Array.isArray(ev.spawns)) return;
|
||||
if (!ev || !DUCK_KINDS.has(ev.kind) || !Array.isArray(ev.spawns)) return;
|
||||
const p = duckParams();
|
||||
if (!p || ctx.round < int(p.round, 1)) return;
|
||||
const held = ctx.bandId() === DUCK_BAND || ctx.teammates().some((t) => t.bandId() === DUCK_BAND);
|
||||
if (!held) return;
|
||||
duckReplace(ctx, ev.spawns, p, ctx.playerId);
|
||||
duckReplace(ctx, ev.spawns, p, ctx.playerId, { routes: ev.routes, side: ev.side });
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -516,6 +516,11 @@ const onTerrain = (b, u, terrain) => { const [r, c] = tileOf(u); return b.grid.t
|
||||
const stealth = () => ({ spawn(b, e) { b.addBuff(e, { key: 'ab:stealth', flags: { stealth: true }, persist: true }); } });
|
||||
/** 无法被阻挡. */
|
||||
const unblockable = () => ({ spawn(b, e) { b.addBuff(e, { key: 'ab:unblockable', flags: { unblockable: true }, persist: true }); } });
|
||||
/**
|
||||
* "受到伤害时开始奔跑,移动速度+N%" (鸭爵 `run.attack@move_speed`): the first damage it takes ⇒ move speed ×(1 + v) for good —
|
||||
* PRTS 鸭爵 天赋 "移动速度+400%" for v 4, PRTS 卫戍协议:盟约 下半/PRTS盟约记录 鸭爵 备注 "受伤后移动速度+300%" for the act2 v 3.
|
||||
*/
|
||||
const runWhenHit = (v) => ({ taken(c, b, e, a) { if (a.on || !(v > 0)) return; a.on = true; b.addBuff(e, { key: 'ab:duckRun', persist: true, mods: { moveMul: 1 + v } }); } });
|
||||
/** 频次: maxHp = hits. */
|
||||
const times = (artsOnly = false) => ({ spawn(b, e) { setHits(e, e.def.maxHp); hitCount(b, e, true, artsOnly); } });
|
||||
/** "只能被阻挡数大于等于N的单位阻挡". */
|
||||
@@ -2327,8 +2332,8 @@ export const KITS = Object.freeze({
|
||||
}],
|
||||
enemy_1501_demonk: () => [maxTargets(2)], // 萨卡兹百夫长 · attacks 2 targets
|
||||
enemy_10018_sgrobh: () => [maxTargets(2)], // “独轮车玩具” · attacks 2 targets
|
||||
enemy_2001_duckmi: (ab) => [unblockable(), { taken(c, b, e, a) { if (a.on) return; a.on = true; b.addBuff(e, { key: 'ab:duckRun', persist: true, mods: { moveMul: T(ab, 'run.attack@move_speed') ?? 1 } }); } }], // 鸭爵 · unblockable; runs once hit
|
||||
enemy_2001_duckmi_2: (ab) => [unblockable(), { taken(c, b, e, a) { if (a.on) return; a.on = true; b.addBuff(e, { key: 'ab:duckRun', persist: true, mods: { moveMul: T(ab, 'run.attack@move_speed') ?? 1 } }); } }], // 鸭爵 · same
|
||||
enemy_2001_duckmi: (ab) => [unblockable(), runWhenHit(T(ab, 'run.attack@move_speed') ?? 0)], // 鸭爵 · unblockable, no attack; runs +400 % once hurt
|
||||
enemy_2001_duckmi_2: (ab) => [unblockable(), runWhenHit(T(ab, 'run.attack@move_speed') ?? 0)], // 鸭爵 (鸭爵 strategy) · same, +300 %
|
||||
enemy_10159_mntrjn: () => [unblockable()], // 伊利昂的木驮兽 · unblockable (passengers n/a)
|
||||
enemy_2009_csaudc: kitEp('neural', 'combat.attack@ep_damage_ratio'), // 骇笑看客 · neural on hit
|
||||
enemy_2010_csdcr: (ab) => [ep('neural', T(ab, 'attack.attack@ep_damage_ratio') ?? 0), { // 绯红歌伶 · neural on hit; every 20 hits taken: global enemy ASPD up
|
||||
@@ -2468,8 +2473,10 @@ export const KITS = Object.freeze({
|
||||
enemy_1535_wlfmster: kitWolfLord, // 扎罗 · −30 % damage, 溶血骇惧 ⇒ 远古威慑 rebirth ⇒ ranged double hits
|
||||
enemy_10081_mpplai: kitTranslator, // 转译基底·α · 4 phys / 4 arts hits or being blocked ⇒ one of three forms
|
||||
enemy_10144_xdelk_2: kitElk, // 乌顶巨角卢鲁 · 角力对决 charge on its blocker (push fails ⇒ damage + stun)
|
||||
enemy_2085_skzjxd: (ab) => [frontGuard(T(ab, 'Weakness.damage_resistance') ?? 0, faceCrowd)], // 圆仔 · faces the bigger crowd, front damage −80 %
|
||||
enemy_2085_skzjxd_2: (ab) => [frontGuard(T(ab, 'Weakness.damage_resistance') ?? 0, faceCrowd)], // 圆仔 · same
|
||||
// 圆仔 · PRTS 天赋 "无法攻击/被阻挡;受到来源于正面的物理和法术伤害-80%;自身始终朝向我方干员数量最多的方向" (no attack: applyWay
|
||||
// NONE; its 倒走 / 【炫耀】 is "仅用于演出,无额外效果"): unblockable, faces the bigger crowd, front damage −80 %
|
||||
enemy_2085_skzjxd: (ab) => [unblockable(), frontGuard(T(ab, 'Weakness.damage_resistance') ?? 0, faceCrowd)],
|
||||
enemy_2085_skzjxd_2: (ab) => [unblockable(), frontGuard(T(ab, 'Weakness.damage_resistance') ?? 0, faceCrowd)], // (鸭爵 strategy) same
|
||||
// 失衡 (pushed / pulled by operators)
|
||||
enemy_1328_cbjedi: (ab) => [unbalanced((b, e, a, d) => { // 弧光锋卫 · loses HP in proportion to the distance moved while unbalanced
|
||||
const v = T(ab, 'unbalanced_bleed.damage') ?? 0, iv = T(ab, 'unbalanced_bleed.interval') ?? 1;
|
||||
|
||||
@@ -337,7 +337,7 @@ test('坎诺特 利滚利: leftover funds are kept; ≥ 5 left ⇒ +1 at round s
|
||||
cover('band_cannot');
|
||||
});
|
||||
|
||||
test('鸭爵 “神秘顾客”: from R5, 0–2 normal ground enemies in the last 40 % of every teammate\'s wave become 鸭爵 & co. worth 1 fund to the killer', () => {
|
||||
test('鸭爵 “神秘顾客”: from R5, 0–2 ground enemies in the last 40 % of every teammate\'s wave become 鸭爵 & co. worth 1 fund to the killer', () => {
|
||||
const p = DATA.bands.band_ducklord.buffs[0];
|
||||
const ducks = p.bbStr.enemylist.split(',');
|
||||
const normalKey = Object.values(DATA.enemies).find((e) => e.rank === 'NORMAL' && e.stats.motion === 'WALK').key;
|
||||
@@ -360,8 +360,9 @@ test('鸭爵 “神秘顾客”: from R5, 0–2 normal ground enemies in the las
|
||||
assert.ok(ev.spawns.some((sp) => sp.enemyKey === flyKey), 'flyers are never replaced');
|
||||
}
|
||||
assert.deepEqual(Object.keys(counts).map(Number).sort(), [0, 1, 2]);
|
||||
// before R5, in boss rounds, or when nobody holds the band: nothing
|
||||
for (const [r, kind, band] of [[4, 'normal', 'band_ducklord'], [5, 'boss', 'band_ducklord'], [5, 'normal', null]]) {
|
||||
// before R5, in the 联防 phase (its enemies are leaks), or when nobody holds the band: nothing (the Final Assault and
|
||||
// the Hidden Core swap too: test/match/feedback1-ducklord.test.js)
|
||||
for (const [r, kind, band] of [[4, 'normal', 'band_ducklord'], [5, 'unite', 'band_ducklord'], [5, 'normal', null]]) {
|
||||
h.ps('p_1').bandId = band;
|
||||
m.round = r;
|
||||
const ev = { input: p0.battleInput({ side: 'L', colOffset: 0 }), kind, round: r, spawns: wave() };
|
||||
@@ -375,6 +376,16 @@ test('鸭爵 “神秘顾客”: from R5, 0–2 normal ground enemies in the las
|
||||
const out = duckReplace(ctx, spawns, { ...p.bb, ...p.bbStr }, 'p_0');
|
||||
assert.equal(out.length, 2);
|
||||
assert.equal(spawns.reduce((n, sp) => n + (sp.count || 1), 0), 10);
|
||||
// a pair boss field (`side` + `routes`): only the enemies heading for the player's half (route end left / right of
|
||||
// the middle column), the 60–99 % share taken over that half — here the left half's enemies all come first
|
||||
const routes = [{ motion: 'WALK', end: [1, 3] }, { motion: 'WALK', end: [1, 17] }];
|
||||
for (const [side, route, times] of [['L', 0, [6, 7]], ['R', 1, [16, 17]], [null, 1, [12, 13]]]) {
|
||||
const field = Array.from({ length: 20 }, (_, i) => ({ time: i, enemyKey: normalKey, routeIndex: i < 10 ? 0 : 1, count: 1 }));
|
||||
const got = duckReplace(ctx, field, { ...p.bb, ...p.bbStr }, 'p_0', { routes, side });
|
||||
assert.deepEqual(got.map((d) => d.time), times, `side ${side}`);
|
||||
for (const d of got) assert.equal(d.routeIndex, route);
|
||||
assert.equal(field.length, 20);
|
||||
}
|
||||
cover('band_ducklord');
|
||||
});
|
||||
|
||||
|
||||
@@ -1088,14 +1088,18 @@ for (const key of ['enemy_1501_demonk', 'enemy_10018_sgrobh']) {
|
||||
}
|
||||
|
||||
for (const key of ['enemy_2001_duckmi', 'enemy_2001_duckmi_2']) {
|
||||
test(`${nm(key)}: unblockable; runs ×${tb(key, 'run.attack@move_speed')} once hit`, () => {
|
||||
// PRTS 鸭爵 "移动速度+400%" (run 4); the 鸭爵 strategy's version "受伤后移动速度+300%" (run 3): ×(1 + run)
|
||||
test(`${nm(key)}: unblockable, no attack; runs ×${1 + tb(key, 'run.attack@move_speed')} once hit`, () => {
|
||||
const h = arena();
|
||||
h.step();
|
||||
const e = put(h, key, [10, 7], { move: true });
|
||||
const v = e.s.moveSpeed;
|
||||
assert.ok(e.s.flags.unblockable);
|
||||
assert.ok(e.profile.noAttack);
|
||||
h.b.dealDamage(null, e, { amount: 1, type: 'true' });
|
||||
approx(e.s.moveSpeed, v * tb(key, 'run.attack@move_speed'));
|
||||
approx(e.s.moveSpeed, v * (1 + tb(key, 'run.attack@move_speed')));
|
||||
h.b.dealDamage(null, e, { amount: 1, type: 'true' });
|
||||
approx(e.s.moveSpeed, v * (1 + tb(key, 'run.attack@move_speed')), 1e-6, 'once');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1622,6 +1626,20 @@ test(`${nm('enemy_10097_crshd')} / ${nm('enemy_2085_skzjxd')}: physical / arts d
|
||||
approx(h.b.dealDamage(back, y, { amount: 1000, type: 'true' }), 1000, 1e-6, 'true damage is never reduced');
|
||||
});
|
||||
|
||||
for (const key of ['enemy_2085_skzjxd', 'enemy_2085_skzjxd_2']) {
|
||||
test(`${nm(key)}: 无法攻击/被阻挡 (PRTS 天赋) — walks through a blocker, never attacks`, () => {
|
||||
const h = arena({ units: [{ chessId: 't_wall', row: 10, col: 5 }], hooks: ['blocked'] });
|
||||
h.step();
|
||||
const e = put(h, key, [10, 7], { move: true, route: { motion: 'WALK', start: [10, 7], end: [10, 2], checkpoints: [] } });
|
||||
assert.ok(e.s.flags.unblockable);
|
||||
assert.ok(e.profile.noAttack);
|
||||
h.runUntil(() => e.x < 4, 60);
|
||||
assert.ok(e.x < 4, `passed the wall (x ${e.x})`);
|
||||
assert.equal(h.hooksOf('blocked').length, 0);
|
||||
assert.equal(e.stats.attacks, 0);
|
||||
});
|
||||
}
|
||||
|
||||
test(`${nm('enemy_10098_crhro')}: 重生 once after ${tb('enemy_10098_crhro', 'reborn.duration')} s at full HP`, () => {
|
||||
const h = arena();
|
||||
h.step();
|
||||
|
||||
+6
-1
@@ -686,6 +686,11 @@ test('independent re-derivation of every chess and enemy stat from the raw offic
|
||||
const db = new Map(raw('levels/enemydata/enemy_database.json').enemies.map((e) => [e.Key, e.Value]));
|
||||
const ov = new Map((raw('levels/activities/act1autochess/level_autochess_enemy_data.json').enemyDbRefs || [])
|
||||
.filter((r) => r.overwrittenData).map((r) => [r.id, r.overwrittenData]));
|
||||
// the 鸭爵 strategy's swapped-in enemies cost 1 LP at the protection point (PRTS 卫戍协议:盟约 下半/PRTS盟约记录 鸭爵 备注
|
||||
// "但进入保护目标点将减少1点目标生命值"; the database's lifePointReduce 0 is the roguelike rule)
|
||||
const swapped = new Set(Object.values(act.effectBuffInfoDataDict).flat().filter((b) => b.key === 'round_start_all_player_change_enemy_2')
|
||||
.flatMap((b) => b.blackboard.filter((kv) => kv.key === 'enemylist').flatMap((kv) => kv.valueStr.split(','))));
|
||||
assert.equal(swapped.size, 4);
|
||||
for (const e of Object.values(enemies)) {
|
||||
const base = db.get(e.key).find((l) => l.level === 0).enemyData;
|
||||
const o = ov.get(e.key);
|
||||
@@ -700,7 +705,7 @@ test('independent re-derivation of every chess and enemy stat from the raw offic
|
||||
maxHp: pick((x) => x.attributes?.maxHp, 0), atk: pick((x) => x.attributes?.atk, 0), def: pick((x) => x.attributes?.def, 0),
|
||||
res: pick((x) => x.attributes?.magicResistance, 0), moveSpeed: pick((x) => x.attributes?.moveSpeed, 1),
|
||||
bat: pick((x) => x.attributes?.baseAttackTime, 1), aspd: pick((x) => x.attributes?.attackSpeed, 100),
|
||||
lpr: pick((x) => x.lifePointReduce, 1), massLevel: pick((x) => x.attributes?.massLevel, 0),
|
||||
lpr: swapped.has(e.key) ? 1 : pick((x) => x.lifePointReduce, 1), massLevel: pick((x) => x.attributes?.massLevel, 0),
|
||||
motion: pick((x) => x.motion, 'WALK'),
|
||||
};
|
||||
for (const [key, v] of Object.entries(exp)) {
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
// Player feedback after 0.1.0, workstream WE (report #7): the 鸭爵 strategy “神秘顾客” and its four enemies —
|
||||
// "鸭爵策略带来的怪物圆仔错误的能被阻挡,鸭爵带来的其它几个敌人你也都复核下规则,数据对不对".
|
||||
// Official: the band's enemylist names the act2 versions (enemy_*_2; enemy_database level 0) and PRTS 卫戍协议:盟约 下半/
|
||||
// PRTS盟约记录 §策略 鸭爵 备注: "每回合将有0~2名敌人被替换为上述敌人之一,且上述敌人为特殊版本,相较于原版更弱(鸭爵 6000 生命,
|
||||
// 受伤后移动速度+300%;高普尼克 11000 / 攻击 1600;流泪小子 4500,攻击附加3.5秒晕眩;圆仔 13000),但进入保护目标点将减少1点
|
||||
// 目标生命值,且在最终回合和隐秘核心回合中仍然生效"; PRTS 圆仔 天赋 "无法攻击/被阻挡".
|
||||
// Real match paths (the match harness in virtual time, real data, the real meta registry) and the real Battle.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { PHASE } from '../../shared/constants.js';
|
||||
import { DATA, makeMatch, give, legalTileFor } from './harness.js';
|
||||
import { FakeBattle } from './fakeBattle.js';
|
||||
import { Battle } from '../../server/sim/Battle.js';
|
||||
|
||||
const BAND = 'band_ducklord';
|
||||
const PARAMS = DATA.bands[BAND].buffs.find((b) => b.key === 'round_start_all_player_change_enemy_2');
|
||||
const DUCKS = PARAMS.bbStr.enemylist.split(',');
|
||||
const YUANZAI = 'enemy_2085_skzjxd_2';
|
||||
const DUCK = 'enemy_2001_duckmi_2';
|
||||
const QUIET = { info() {}, debug() {}, warn() {}, error() {} };
|
||||
const TANKS = Object.values(DATA.chess).filter((c) => c.visible && !c.isGolden && c.profession === 'TANK' && c.tier <= 2).map((c) => c.chessId).sort();
|
||||
|
||||
/** A real Battle of a match's battle options (the match itself runs FakeBattles in these tests). */
|
||||
const realBattle = (m, opts) => new Battle({ data: m.ds, content: m.battleContent, logger: QUIET, ...opts });
|
||||
|
||||
/** Solo match at PREP of round `r` holding the 鸭爵 band (FakeBattle rounds before). */
|
||||
function soloAt(r, seed) {
|
||||
const h = makeMatch({ mode: 'solo', difficulty: 'NORMAL', seed, fake: true }).start();
|
||||
h.toPrep(r, { band: BAND });
|
||||
const ps = h.ps('p_0');
|
||||
assert.equal(ps.bandId, BAND);
|
||||
return { h, m: h.m, ps };
|
||||
}
|
||||
|
||||
/** The player's normal-battle options until the band swapped a `key` into the real wave (the real onBattleStart path). */
|
||||
function optsWith(m, ps, key, tries = 400) {
|
||||
for (let i = 0; i < tries; i++) {
|
||||
const opts = m._normalOpts(ps);
|
||||
const spec = opts.spawns.find((s) => s.enemyKey === key);
|
||||
if (spec) return { opts, spec };
|
||||
}
|
||||
assert.fail(`no ${key} in ${tries} battles`);
|
||||
}
|
||||
|
||||
test('#7 圆仔 brought by 鸭爵 is never blocked: it walks past a board full of defenders (real R6 wave, real Battle)', () => {
|
||||
const { h, m, ps } = soloAt(6, 7071);
|
||||
h.setStage('act1autochess_m01');
|
||||
for (const p of [...ps.board.values(), ...ps.hand.filter(Boolean)]) if (p.kind === 'chess') ps.returnCopies(p);
|
||||
ps.board.clear();
|
||||
ps.hand.fill(null);
|
||||
// a defender (3 blocks) on every melee tile of the board: any blockable ground enemy meets one
|
||||
let n = 0;
|
||||
for (let k = 0; k < 40; k++) {
|
||||
const id = TANKS.find((t) => m.pool.has(t) && m.pool.left(t) > 0);
|
||||
const at = id && legalTileFor(m, ps, id);
|
||||
if (!at) break;
|
||||
give(m, ps, id, 'board', at);
|
||||
n++;
|
||||
}
|
||||
assert.ok(n >= 6, `${n} defenders`);
|
||||
const { opts, spec } = optsWith(m, ps, YUANZAI);
|
||||
assert.deepEqual(spec.bounty, { coins: 1, ownerPlayerId: 'p_0' }, '1 fund to whoever knocks it out');
|
||||
// the swapped-in 圆仔 alone, at once (its real route and round multipliers)
|
||||
const b = realBattle(m, { ...opts, spawns: [{ ...spec, time: 0 }], timeLimit: 600 });
|
||||
let yz = null;
|
||||
let blocked = 0;
|
||||
for (let t = 0; t < 600 * 30 && !b.finished; t++) {
|
||||
b.step();
|
||||
yz = yz || b.enemies.find((e) => e.defId === YUANZAI) || null;
|
||||
if (yz && yz.alive && yz.blockedBy) blocked++;
|
||||
}
|
||||
assert.ok(yz, 'spawned');
|
||||
assert.equal(blocked, 0, 'never blocked (ticks blocked)');
|
||||
assert.ok(yz.s.flags.unblockable, 'PRTS 圆仔 天赋 "无法攻击/被阻挡"');
|
||||
assert.equal(yz.stats.attacks, 0, 'never attacks');
|
||||
const r = b.result();
|
||||
const leaked = (r.perPlayer.p_0.leaked || []).filter((l) => l.enemyKey === YUANZAI);
|
||||
assert.ok(leaked.length === 1 || !yz.alive, 'it either leaks or is knocked out on the way');
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
test('#7 the four 鸭爵 enemies are the act2 versions: PRTS stats, 1 LP when they reach the protection point', () => {
|
||||
const s = (k) => DATA.enemies[k].stats;
|
||||
assert.deepEqual(DUCKS.slice().sort(), ['enemy_2001_duckmi_2', 'enemy_2002_bearmi_2', 'enemy_2034_sythef_2', 'enemy_2085_skzjxd_2']);
|
||||
assert.equal(s('enemy_2001_duckmi_2').maxHp, 6000);
|
||||
assert.equal(s('enemy_2002_bearmi_2').maxHp, 11000);
|
||||
assert.equal(s('enemy_2002_bearmi_2').atk, 1600);
|
||||
assert.equal(s('enemy_2034_sythef_2').maxHp, 4500);
|
||||
assert.equal(DATA.enemies.enemy_2034_sythef_2.talents.bb['Combat.attack@stun'], 3.5);
|
||||
assert.equal(s('enemy_2085_skzjxd_2').maxHp, 13000);
|
||||
// "但进入保护目标点将减少1点目标生命值" (the database's lifePointReduce 0 is the roguelike 宝藏 rule)
|
||||
for (const k of DUCKS) assert.equal(s(k).lpr, 1, k);
|
||||
// the original (roguelike) versions keep the database value
|
||||
for (const k of ['enemy_2001_duckmi', 'enemy_2002_bearmi', 'enemy_2034_sythef', 'enemy_2085_skzjxd']) assert.equal(s(k).lpr, 0, k);
|
||||
});
|
||||
|
||||
test('#7 鸭爵 runs at +300 % move speed once hurt (PRTS: 受伤后移动速度+300%), never attacks, is never blocked', () => {
|
||||
const { m, ps } = soloAt(5, 7072);
|
||||
const { opts, spec } = optsWith(m, ps, DUCK);
|
||||
const b = realBattle(m, { ...opts, spawns: [{ ...spec, time: 0 }], timeLimit: 600 });
|
||||
let d = null;
|
||||
for (let t = 0; t < 30 && !d; t++) { b.step(); d = b.enemies.find((e) => e.defId === DUCK) || null; }
|
||||
assert.ok(d && d.s.flags.unblockable);
|
||||
const v = d.s.moveSpeed;
|
||||
b.dealDamage(null, d, { amount: 1, type: 'true' });
|
||||
const run = DATA.enemies[DUCK].talents.bb['run.attack@move_speed'];
|
||||
assert.equal(run, 3);
|
||||
assert.ok(Math.abs(d.s.moveSpeed - v * (1 + run)) < 1e-9, `${d.s.moveSpeed} = ${v} × ${1 + run}`);
|
||||
assert.ok(d.profile.noAttack);
|
||||
m.dispose();
|
||||
});
|
||||
|
||||
test('#7 the swap still happens in the Final Assault (escorts, never the leader or its parts), and a leaked swap costs the merged LP 1', () => {
|
||||
let swapped = 0;
|
||||
let lpChecked = false;
|
||||
for (let seed = 1; seed <= 8 && (!swapped || !lpChecked); seed++) {
|
||||
const h = makeMatch({ mode: 'solo', difficulty: 'FUNNY', seed: 7100 + seed, fake: true, script: (b) => (b.kind === 'boss' ? { bossDps: 1e9 } : {}) }).start();
|
||||
const m = h.m;
|
||||
h.drive(() => m.phase === PHASE.PREP && m.round === m.gd.bossRound, { band: BAND });
|
||||
h.drive(() => m.phase === PHASE.FINAL_ASSAULT, { band: BAND });
|
||||
const f = FakeBattle.instances.find((b) => b.kind === 'boss');
|
||||
assert.ok(f, 'boss field');
|
||||
const ducks = f.opts.spawns.filter((s) => DUCKS.includes(s.enemyKey));
|
||||
assert.ok(ducks.length <= PARAMS.bb.max, `${ducks.length} swaps`);
|
||||
assert.ok(f.opts.spawns.some((s) => s.tag === 'boss'), 'the leader stays');
|
||||
for (const d of ducks) {
|
||||
assert.equal(d.tag, 'duck');
|
||||
assert.deepEqual(d.bounty, { coins: 1, ownerPlayerId: 'p_0' });
|
||||
}
|
||||
swapped += ducks.length;
|
||||
if (ducks.length && !lpChecked) {
|
||||
// the real boss battle (no operators): the swapped enemy walks into the goal and the team pool loses its lpr 1
|
||||
const pool = { hp: 1e12, maxHp: 1e12, damage() { return 0; } };
|
||||
const b = realBattle(m, { ...f.opts, sharedBoss: pool, spawns: [{ ...ducks[0], time: 0 }] });
|
||||
const leaks = [];
|
||||
b.on('enemyLeak', (ctx) => leaks.push(ctx.enemy));
|
||||
for (let t = 0; t < 400 * 30 && !leaks.length; t++) b.step();
|
||||
assert.equal(leaks.length, 1, 'leaked');
|
||||
assert.equal(leaks[0].lpr, 1);
|
||||
const lp = m.teamLp;
|
||||
m._bossLeak(leaks[0]);
|
||||
assert.equal(m.teamLp, lp - 1);
|
||||
lpChecked = true;
|
||||
}
|
||||
m.dispose();
|
||||
}
|
||||
assert.ok(swapped > 0, 'some Final Assault had a swap');
|
||||
assert.ok(lpChecked);
|
||||
});
|
||||
|
||||
test('#7 pair fields of the Final Assault and the Hidden Core (real match path): each player\'s 0–2 swaps land on their own half', () => {
|
||||
const seen = { boss: { L: 0, R: 0 }, hidden: { L: 0, R: 0 } };
|
||||
const done = () => Object.values(seen).every((k) => k.L > 0 && k.R > 0);
|
||||
for (let seed = 1; seed <= 8 && !done(); seed++) {
|
||||
// 2 humans + 2 bots → two pair fields; the bosses fall at once; Σ layers > 1200 opens the Hidden Core
|
||||
const h = makeMatch({ mode: 'coop', difficulty: 'NORMAL', humans: 2, bots: 2, seed: 7200 + seed, fake: true, script: (b) => (b.kind === 'boss' || b.kind === 'hidden' ? { bossDps: 1e9 } : {}) }).start();
|
||||
const m = h.m;
|
||||
h.drive(() => m.phase === PHASE.PREP && m.round === m.gd.bossRound, { band: BAND });
|
||||
assert.equal(m.round, m.gd.bossRound);
|
||||
assert.ok([...m.players.values()].some((p) => p.bandId === BAND), 'someone holds 鸭爵');
|
||||
for (const p of m.players.values()) { p.bondCountBonus.yanShip = 3; p.layers.yanShip = 601; p.recompute(); }
|
||||
for (const [kind, phase] of [['boss', PHASE.FINAL_ASSAULT], ['hidden', PHASE.HIDDEN_CORE]]) {
|
||||
h.drive(() => m.phase === phase, { band: BAND });
|
||||
assert.equal(m.phase, phase, `${kind} reached`);
|
||||
const fields = FakeBattle.instances.filter((b) => b.kind === kind);
|
||||
assert.equal(fields.length, 2, `${kind}: two pair fields`);
|
||||
for (const f of fields) {
|
||||
assert.equal(f.players.length, 2);
|
||||
const wave = m.bossWaves.find((w) => w.players.join() === f.players.join()).wave;
|
||||
const sum = (list) => list.reduce((n, sp) => n + (sp.count || 1), 0);
|
||||
assert.equal(sum(f.opts.spawns.filter((sp) => sp.tag !== 'bounty')), sum(wave.spawns), `${kind}: same enemy count`);
|
||||
assert.ok(f.opts.spawns.some((sp) => sp.tag === 'boss'), 'the leader stays');
|
||||
const ducks = f.opts.spawns.filter((sp) => DUCKS.includes(sp.enemyKey));
|
||||
for (const d of ducks) {
|
||||
assert.equal(d.tag, 'duck');
|
||||
const j = f.players.indexOf(d.bounty.ownerPlayerId);
|
||||
assert.ok(j >= 0, 'owned by a player of the field');
|
||||
assert.equal(d.bounty.coins, 1);
|
||||
// its route heads for the owner's protection point (goals at cols 2–3 left / 17 right)
|
||||
const end = f.opts.routes[d.routeIndex].end[1];
|
||||
assert.ok(j === 0 ? end < 10 : end > 10, `${kind}: ${d.bounty.ownerPlayerId}'s swap on route ${d.routeIndex} ends at col ${end}`);
|
||||
seen[kind][j === 0 ? 'L' : 'R']++;
|
||||
}
|
||||
for (const pid of f.players) assert.ok(ducks.filter((d) => d.bounty.ownerPlayerId === pid).length <= PARAMS.bb.max, `${kind}: ≤ 2 for ${pid}`);
|
||||
}
|
||||
}
|
||||
m.dispose();
|
||||
}
|
||||
assert.ok(done(), `swaps on both halves of both leader rounds: ${JSON.stringify(seen)}`);
|
||||
});
|
||||
@@ -1693,12 +1693,30 @@ const MODEL_SCALES = new Map([
|
||||
const MODEL_SCALE_BY_PREFAB = new Map();
|
||||
for (const [v, list] of MODEL_SCALES) for (const k of list) MODEL_SCALE_BY_PREFAB.set(`enemy_${k}`, Math.round((v / MODEL_SCALE_STANDARD) * 1e4) / 1e4);
|
||||
|
||||
/**
|
||||
* The 鸭爵 strategy's swapped-in enemies (`round_start_all_player_change_enemy_2` enemylist — the act2 versions, *_2)
|
||||
* cost BAND_SWAP_LPR at the protection point, not the database's lifePointReduce 0 (the roguelike 宝藏 rule): PRTS
|
||||
* 卫戍协议:盟约 下半/PRTS盟约记录 §策略 鸭爵 备注 "…但进入保护目标点将减少1点目标生命值,且在最终回合和隐秘核心回合中仍然生效"
|
||||
* (player feedback after 0.1.0, #7). Normal rounds count leaks whatever their `lpr`; the Final Assault / Hidden Core
|
||||
* LP and the detail card's 目标价值 read it.
|
||||
*/
|
||||
const BAND_SWAP_LPR = 1;
|
||||
function bandSwapEnemyKeys(act) {
|
||||
const out = new Set();
|
||||
for (const buffs of Object.values(act.effectBuffInfoDataDict || {})) for (const b of buffs || []) {
|
||||
if (b?.key !== 'round_start_all_player_change_enemy_2') continue;
|
||||
for (const kv of b.blackboard || []) if (kv.key === 'enemylist') for (const k of String(kv.valueStr || '').split(',')) if (k.trim()) out.add(k.trim());
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build data/enemies.json: base stats at the season level (randomEnemyAttributeDict.level, 0 for
|
||||
* all), with the season-wide override level (level_autochess_enemy_data) applied.
|
||||
*/
|
||||
function buildEnemies(ctx) {
|
||||
const { ac, act, handbook } = ctx;
|
||||
const swapKeys = bandSwapEnemyKeys(act);
|
||||
const c = ac.constData;
|
||||
const hpF = c.enemyMaxHpFactor ?? 1, atkF = c.enemyAtkFactor ?? 5, defF = c.enemyDefFactor ?? 3, resF = c.enemyMagicResistanceFactor ?? 3;
|
||||
const globalOverrides = new Map();
|
||||
@@ -1756,6 +1774,7 @@ function buildEnemies(ctx) {
|
||||
otherImmunities: ['disarmedCombat', 'feared', 'palsy', 'attract', 'teleport', 'groundBound'].filter((k) => !!mv(at[`${k}Immune`], false)),
|
||||
tauntLevel: mv(at.tauntLevel, 0),
|
||||
};
|
||||
if (swapKeys.has(key)) stats.lpr = BAND_SWAP_LPR;
|
||||
const beFactor = rand?.enemyBattleEffectivenessFactor ?? 1;
|
||||
const be = beFactor > 0 ? Math.round((stats.maxHp * hpF + stats.atk * atkF + stats.def * defF + stats.res * resF) / beFactor) : null;
|
||||
const talents = flattenBB(data.talentBlackboard, `enemy ${key} talents`);
|
||||
|
||||
Reference in new issue
Block a user