diff --git a/docs/SIM.md b/docs/SIM.md index 505d92c8..0eb25e7d 100644 --- a/docs/SIM.md +++ b/docs/SIM.md @@ -774,7 +774,7 @@ registration order. `battle.off(handle)` / `battle.off(name, fn)` / `battle.offO | `damaged` | `{ source, target, amount, type, dmg, credit }` | after application (`amount` may be 0 when shielded); element fills too (with their source); 无来源: `source` null, `credit` set | | `hpDamage` | `{ source, target, amount, dmg, credit }` | a damage instance after shields, before the HP loss (§4; not a 流失): lower `amount` only (a raise is ignored) — HP floors / reductions ordered after a barrier (kits/ops/op-huang.js, op-zuole.js) | | `heal` | `{ source, target, amount, opts }` | mutable `amount` | -| `fatal` | `{ unit, source, credit, dmg, amount, prevented }` | HP would reach 0 — set `prevented` (substitutes, kit savers, 不死 / 复活 items, 埃芒加德; 不屈 is a `death` hook). Fired by every HP loss of a unit without a boss pool — hits of any type, element bursts, 无来源 damage, `loseHp` 流失. Order: kits' own savers (10 … −60) → items' 不死 (坚固维式重锤 — once per deployment: `items/battle.js deploymentOf`, a key every deploy changes and an in-place 复活 changes too; one battle-level hook holds the running windows (`holdsUndying`), so a window outlasts a lend, DESIGN §21.21 — the lock `PRIO_REVIVE` −100 after the substitutes (−100, registered first), the running windows `PRIO_UNDYING_HELD` −99 before them: a 傀儡师 holding 不死 does not switch, PRTS 分支特性信息 傀儡师 "未持有不死的情况下", DESIGN §22.11) → 淬羽赫默 S3's 不死 (−100.5: PRTS 衍生生命 "无畏者协议提供的不死效果仅在目标没有不死效果时才会触发"; its running windows held at −99 like the hammer's) → items' 复活 (M3茧甲, `PRIO_RESPAWN` −101: PRTS "复活" acts on a knock-out, which a 不死 prevents) → 埃芒加德 (−110); both 复活 revive in place and call `revivedInPlace` (a new deployment for the lock) | +| `fatal` | `{ unit, source, credit, dmg, amount, prevented }` | HP would reach 0 — set `prevented` (substitutes, kit savers, 不死 / 复活 items, 埃芒加德; 不屈 is a `death` hook). Fired by every HP loss of a unit without a boss pool — hits of any type, element bursts, 无来源 damage, `loseHp` 流失. Order: kits' own savers (10 … −60) → items' 不死 (坚固维式重锤 — once per deployment: `items/battle.js deploymentOf`, a key every deploy changes and an in-place 复活 changes too; one battle-level hook holds the running windows (`holdsUndying`), so a window outlasts a lend, DESIGN §21.21 — the lock `PRIO_REVIVE` −100 after the substitutes (−100, registered first), the running windows `PRIO_UNDYING_HELD` −99 before them: a 傀儡师 holding 不死 does not switch, PRTS 分支特性信息 傀儡师 "未持有不死的情况下", DESIGN §22.11) → 淬羽赫默 S3's 不死 (−100.5: PRTS 衍生生命 "无畏者协议提供的不死效果仅在目标没有不死效果时才会触发"; its running windows held at −99 like the hammer's) → items' 复活 (M3茧甲, `PRIO_RESPAWN` −101: PRTS "复活" acts on a knock-out, which a 不死 prevents) → 埃芒加德 (−110); both 复活 revive in place and call `revivedInPlace` (a new deployment for the lock, and a deploy-timed skill — `activateOnDeploy` — runs again from the next tick: started anew, or its window back to full; PRTS's 复活 is 退场 + a 0-time / 0-cost redeploy) | | `dollSwitch` | `{ unit, reason, done }` | content switches a 傀儡师 to its <替身> now (归溟幽灵鲨 S2 "技能结束后立刻切换为<替身>": no lethal HP loss); its trait does it unless it already is one or is not on the field, and sets `done` | | `dollSwap` | `{ unit, form }` | a 傀儡师 starts a switch — to its <替身> (`form` `'doll'`) or back to its <本体> (`null`); not when it is knocked out as the 替身 (不屈 rolls on it: "切换<替身>与<本体>时") | | `kill` | `{ killer, victim }` | victim HP reached 0 (a handler may revive by restoring HP) | diff --git a/docs/design/engine.md b/docs/design/engine.md index d663a7d0..75bb7a53 100644 --- a/docs/design/engine.md +++ b/docs/design/engine.md @@ -100,7 +100,7 @@ Base stats (from data): `maxHp, atk, def, res` (0–100), `aspd` (100 base), `ba | `hit` | `{ source, target, dmg }` | before mitigation; mutate `dmg` (DamageInfo) | | `damaged` | `{ source, target, amount, type, dmg }` | after application | | `heal` | `{ source, target, amount }` | before application (mutable amount) | -| `fatal` | `{ unit, source, credit, dmg, amount, prevented }` | HP would reach 0 — fired by every HP loss of a unit without a boss pool (hits of any type, element bursts, 无来源 damage, `loseHp`); set `prevented`. Order: kits' own savers (10 … −60) → items' 不死 (坚固维式重锤, once per deployment) `PRIO_REVIVE` −100 → items' 复活 (M3茧甲) `PRIO_RESPAWN` −101 → 埃芒加德 −110; the two 复活 revive in place and open a new deployment for the lock (§21.21); a running 坚固 window's hook sits at `PRIO_UNDYING_HELD` −99, before a 傀儡师's switch to its 替身 (−100, ahead of the lock — a 本体 holding 不死 does not switch, §22.11) | +| `fatal` | `{ unit, source, credit, dmg, amount, prevented }` | HP would reach 0 — fired by every HP loss of a unit without a boss pool (hits of any type, element bursts, 无来源 damage, `loseHp`); set `prevented`. Order: kits' own savers (10 … −60) → items' 不死 (坚固维式重锤, once per deployment) `PRIO_REVIVE` −100 → items' 复活 (M3茧甲) `PRIO_RESPAWN` −101 → 埃芒加德 −110; the two 复活 revive in place and open a new deployment for the lock (§21.21) and for a deploy-timed skill (`activateOnDeploy`, started again); a running 坚固 window's hook sits at `PRIO_UNDYING_HELD` −99, before a 傀儡师's switch to its 替身 (−100, ahead of the lock — a 本体 holding 不死 does not switch, §22.11) | | `kill` | `{ killer, victim }` | victim hp reached 0; a `killed` ability that keeps the enemy alive (a knock-out into a 重生 / form change, §21.4) hides it from every later handler, the kill count, credit and the bounty | | `death` | `{ unit }` | unit removed (ops may redeploy later); the `die` event and the death hooks fire only on removal | | `skillStart` / `skillEnd` | `{ unit, skill }` | | diff --git a/server/sim/content/items/battle.js b/server/sim/content/items/battle.js index 8874e265..87f542c6 100644 --- a/server/sim/content/items/battle.js +++ b/server/sim/content/items/battle.js @@ -246,8 +246,26 @@ function hammerState(battle, rt, u) { */ export function deploymentOf(u) { return `${u.deploySeq}:${u.mem.revives | 0}`; } -/** An in-place 复活 (M3茧甲, 埃芒加德) happened: a new deployment for the once-per-deployment lock (deploymentOf). */ -export function revivedInPlace(u) { if (u && u.mem) u.mem.revives = (u.mem.revives | 0) + 1; } +/** + * An in-place 复活 (M3茧甲, 埃芒加德) happened. PRTS's 复活 is 退场 + a 0-time / 0-cost redeploy (PRTS 盟约记录 备注) — a new + * deployment, of which the remake carries: the once-per-deployment lock (deploymentOf) and a deploy-timed skill ("部署后…秒内", + * SkillSpec `activateOnDeploy`: 缄默德克萨斯 S1–S3, 宴 S2, 斯卡蒂 S2, 砾's barrier …) running again — started anew when its window + * had ended, its window back to the full duration when it still ran (the next tick, out of the damage pipeline that set the + * revive off). Community report of 2026-10-06 (item 44): a 突袭 member counts such a running window as 技能就绪 + * (bonds/addon/battle.js raidPoll), and after an in-place revive it waited out the 10 s idle time instead of jumping at once + * as after a redeploy. The SP reset and the other 部署时 effects of PRTS's form stay unmodelled (DESIGN §21.21). + */ +export function revivedInPlace(u) { + if (!u || !u.mem) return; + u.mem.revives = (u.mem.revives | 0) + 1; + const sk = u.skill; + if (!sk || sk.noSkill || !sk.spec || !sk.spec.activateOnDeploy || !sk.battle) return; + const seq = u.deploySeq, n = u.mem.revives; + sk.battle.after(0, () => { + if (!u.alive || !u.deployed || u.deploySeq !== seq || u.mem.revives !== n || u.skill !== sk) return; + if (sk.active) { if (sk.duration > 0) sk.timeLeft = sk.duration; } else sk.activate('deploy', { free: true }); + }, { owner: u }); +} /** * Does `u` hold 坚固维式重锤's 不死 right now — a window started in this deployment that has not run out? The window lives diff --git a/test/golden/roster.json b/test/golden/roster.json index 6f5af6a0..8f91f9ce 100644 --- a/test/golden/roster.json +++ b/test/golden/roster.json @@ -720,8 +720,8 @@ "round": 10, "seed": 1517795683, "waveId": "act1autochess_h03", - "time": 162.6, - "ticks": 4878, + "time": 161.57, + "ticks": 4847, "reason": "cleared", "killed": 99, "total": 100, @@ -730,21 +730,21 @@ "rngDraws": 187, "errors": {"count":0,"kinds":[]}, "players": { - "p1": {"killed":99,"total":100,"perfect":false,"leaks":1,"leaksUncounted":0,"coins":10,"dmg":983940,"bossDmg":0,"heal":22157,"deaths":7,"layerGains":{"preciShip":8}} + "p1": {"killed":99,"total":100,"perfect":false,"leaks":1,"leaksUncounted":0,"coins":10,"dmg":988651,"bossDmg":0,"heal":21802,"deaths":7,"layerGains":{"preciShip":8}} }, "units": [ ["p1",1,"op","chess_char_4_17_b",1,"uniequip_002_hsguma",3046.73,1,7,0,237,466,0,20,0], - ["p1",2,"op","chess_char_4_18_b",1,"uniequip_002_mudrok",6104,1,9,4,57534,2092,2092,32,7], + ["p1",2,"op","chess_char_4_18_b",1,"uniequip_002_mudrok",6104,1,9,5,65239,2337,2337,37,8], ["p1",3,"op","chess_char_4_11_b",1,"uniequip_002_cathy",2644,1,124,0,2731,3521,0,3,1], - ["p1",4,"op","chess_char_4_19_b",2,"uniequip_002_flamtl",0,0,104,4,45091,4236,0,45,3], - ["p1",5,"op","chess_char_4_22_b",2,"uniequip_002_svrash",2169,1,122,10,105064,5538,0,27,3], - ["p1",6,"op","chess_char_4_16_b",2,"uniequip_002_texas2",1619,1,113,3,4767,5745,362,24,3], + ["p1",4,"op","chess_char_4_19_b",2,"uniequip_002_flamtl",0,0,104,5,40856,4236,0,40,3], + ["p1",5,"op","chess_char_4_22_b",2,"uniequip_002_svrash",2219,1,122,9,108386,5488,0,27,3], + ["p1",6,"op","chess_char_4_16_b",2,"uniequip_002_texas2",1619,1,113,4,16046,5745,362,23,5], ["p1",7,"op","chess_char_4_10_b",1,"uniequip_002_aroma",1664,1,4,0,0,0,0,0,0], - ["p1",8,"op","chess_char_4_12_b",2,"uniequip_002_glady",2232,1,5,2,6911,557,557,14,2], - ["p1",9,"op","chess_char_4_13_b",1,"uniequip_002_gnosis",1989,1,11,20,187170,2441,0,92,38], - ["p1",10,"op","chess_char_4_14_b",1,"uniequip_002_lionhd",1576,1,13,44,488221,7756,0,57,23], - ["p1",11,"op","chess_char_4_20_b",2,"uniequip_002_fartth",0,0,6,12,86215,1419,0,28,5], - ["p1",12,"op","chess_char_4_21_b",1,"uniequip_002_plosis",1534,1,14,0,0,9049,19146,96,2], + ["p1",8,"op","chess_char_4_12_b",2,"uniequip_002_glady",2232,1,5,1,5724,557,557,13,2], + ["p1",9,"op","chess_char_4_13_b",1,"uniequip_002_gnosis",1989,1,11,20,179263,2491,0,91,36], + ["p1",10,"op","chess_char_4_14_b",1,"uniequip_002_lionhd",1526,1,13,43,487394,7656,0,58,23], + ["p1",11,"op","chess_char_4_20_b",2,"uniequip_002_fartth",0,0,6,12,82773,1419,0,27,5], + ["p1",12,"op","chess_char_4_21_b",1,"uniequip_002_plosis",1484,1,14,0,0,8599,18546,100,2], ["p1",13,"token","token_10041_cathy_catsld",null,null,100,1,17,0,0,0,0,0,null], ["p1",14,"token","token_10041_cathy_catsld",null,null,100,1,16,0,0,0,0,0,null], ["-","~1","device","trap_1105_accrate",null,null,100,0,1,0,0,0,0,0,null], @@ -760,19 +760,19 @@ "enemy_1183_mlasrt": [1,1,0,988,7488], "enemy_1207_sfji_2": [1,1,0,0,16128], "enemy_1208_msfji_2": [4,4,0,0,120], - "enemy_1267_nhpbr": [45,45,0,12545,233280], - "enemy_1267_nhpbr_2": [24,24,0,8150,152064], + "enemy_1267_nhpbr": [45,45,0,12777,233280], + "enemy_1267_nhpbr_2": [24,24,0,7400,152064], "enemy_1270_nhstlk": [1,1,0,0,13824], - "enemy_1270_nhstlk_2": [15,15,0,6817,276480], + "enemy_1270_nhstlk_2": [15,15,0,7030,276480], "enemy_1325_cbgpro_2": [1,1,0,0,5299], "enemy_1425_lrcmra_2": [1,1,0,718,28800], - "enemy_1525_blkswb": [1,0,1,11702,37611], + "enemy_1525_blkswb": [1,0,1,11702,42321], "enemy_2050_smsha": [1,1,0,1902,46080] }, - "hooks": [1,122,4878,469,469,1417,1418,521,107,110,87,86,0,15601,1813,20,100,1,1,122,1,0,1], - "events": {"atk":626,"bounty":5,"deploy":19,"die":109,"dmg":1410,"engage":10,"fx":241,"heal":213,"layer":1,"leak":1,"skill":175,"spawn":117,"status":929}, - "fx": {"beam":6,"blink":2,"displace":4,"dodge":1,"explode":4,"explosion":23,"featherArrow":5,"flame":3,"frostNova":38,"garrison":1,"garrisonGrant":1,"healField":2,"overclock":1,"palsy":1,"pull":2,"revive":4,"riposte":1,"rockslide":7,"shieldBreak":8,"slash":23,"summon":1,"swordRain":9,"swordStorm":3,"telegraph":4,"thorns":3,"tornado":2,"tornadoPulse":10,"truesilver":3,"zone":69}, - "snaps": "a9c24b01 43e8c4cb 9f57bc24 f59bb440 6678caa3 a8a8f019 00aa512d 0d1483f9 2a3527a2 03e0de1a 3bc882ff 0bd6525a bdfd0551 2a029828 bba7125a e653cf29 998295f6" + "hooks": [1,122,4847,476,476,1436,1435,526,107,110,88,87,0,16368,1779,26,100,1,1,121,1,0,3], + "events": {"atk":634,"bounty":5,"deploy":19,"die":109,"dmg":1425,"engage":10,"fx":269,"heal":217,"layer":1,"leak":1,"skill":177,"spawn":117,"status":920}, + "fx": {"beam":6,"blink":2,"displace":4,"dodge":3,"explode":5,"explosion":23,"featherArrow":5,"flame":3,"frostNova":36,"garrison":1,"garrisonGrant":1,"healField":2,"overclock":1,"palsy":1,"pull":2,"revive":4,"riposte":3,"rockslide":8,"shieldBreak":10,"slash":24,"summon":1,"swordRain":29,"swordStorm":5,"telegraph":3,"thorns":3,"tornado":2,"tornadoPulse":10,"truesilver":3,"zone":69}, + "snaps": "a9c24b01 43e8c4cb 9f57bc24 f59bb440 6678caa3 a8a8f019 00aa512d 0d1483f9 2a3527a2 03e0de1a 8e417627 3f822e54 df66a000 eac29e3c 8acf1281 0f15b00b 8758da2f" }, "roster-013": { "about": "pass 0 band 5: chess_char_4_23_b chess_char_5_08_a chess_char_5_01_a chess_char_5_05_a chess_char_5_06_a chess_char_5_07_a chess_char_4_24_b chess_char_4_25_b chess_char_4_26_b chess_char_5_02_a chess_char_5_03_a chess_char_5_10_a", diff --git a/test/sim/feedback5-raid-revive.test.js b/test/sim/feedback5-raid-revive.test.js new file mode 100644 index 00000000..354e31fa --- /dev/null +++ b/test/sim/feedback5-raid-revive.test.js @@ -0,0 +1,79 @@ +// test/sim/feedback5-raid-revive.test.js — community report of 2026-10-06 (item 44) 「突袭盟约的干员如果带被动技能,复活之后不会 +// 立马突袭出去,还要等突袭CD」. A 突袭 member counts a running deploy-timed skill ("部署后…秒内": 缄默德克萨斯 S1–S3 …) as 技能 +// 就绪 (bonds/addon/battle.js raidPoll, GitHub #49 / #109), so after a redeploy it jumps at once when nothing is in its range. +// PRTS's 复活 (M3茧甲, 埃芒加德) is 退场 + a 0-time / 0-cost redeploy (PRTS 盟约记录 备注) — a new deployment — but the remake +// revives in place: her window, over by then, stayed over and she waited out the 10 s idle time. Now an in-place revive +// starts a deploy-timed skill again (content/items/battle.js revivedInPlace); 不屈's real redeploy already did. +// Run: node --test test/sim/feedback5-raid-revive.test.js + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.js'; + +const TEX = 'chess_char_4_16_a'; +const DUMMY = { enemy_rv: enemyRec({ key: 'enemy_rv', hp: 1e9, speed: 0, mass: 0, atk: 0 }) }; +const RAID = { raidShip: { count: 3, active: true, tier: 1, layers: 10 } }; + +/** + * 缄默德克萨斯 (S3, a 6 s deploy-timed skill) in 突袭 with `extra` (items / band): she jumps to a parked enemy, stays past her + * window, the enemy is removed, she is knocked out with nothing in range, a new enemy parks away from her. Returns how long + * after the knock-out her next jump comes (null: none within 15 s) and her skill state right after the revive. + */ +function reviveAndJump({ items, bandId = null, bonds = {} }) { + const h = makeBattle({ + seed: 3, autoFinish: false, timeLimit: 120, hooks: ['deploy', 'death'], defs: { enemies: DUMMY }, bandId, + bonds: { ...RAID, ...bonds }, enemies: [{ key: 'enemy_rv', pos: [9, 9] }], units: [{ chessId: TEX, row: 12, col: 3, skillIndex: 2, items }], + }); + h.step(); + const u = h.unit(TEX); + const jumps = () => h.hooksOf('deploy').filter((c) => c.unit === u && !c.initial); + assert.ok(h.runUntil(() => jumps().length >= 1, 10), 'her first jump'); + h.run(8); + assert.equal(u.skill.active, false, 'her 6 s window is over'); + for (const e of h.b.enemies.filter((x) => x.alive)) h.b.kill(e, null); + const t0 = h.b.time; + h.b.dealDamage(null, u, { amount: 1e9, type: 'true' }); + h.spawn('enemy_rv', { pos: [12, 9] }); + h.step(); + const state = { alive: u.alive, active: u.skill.active, timeLeft: u.skill.timeLeft }; + const n = jumps().length; + const jumped = h.runUntil(() => jumps().length > n && u.tileR === 12, 15); + checkInvariants(h.b); + return { after: jumped ? h.b.time - t0 : null, state, u, h }; +} + +test('M3茧甲 / 埃芒加德 revive 缄默德克萨斯 in place: her deploy-timed S3 runs again and she jumps at once (until 0.2.0 only after the 10 s idle)', () => { + for (const [label, opts] of [['M3茧甲', { items: ['chess_item_4_12_e_a'] }], ['埃芒加德', { bandId: 'band_ermengard' }]]) { + const { after, state } = reviveAndJump(opts); + assert.ok(state.alive, `${label}: revived in place`); + assert.ok(state.active, `${label}: her deploy-timed S3 runs again`); + assert.ok(after != null && after < 1, `${label}: jumped ${after?.toFixed(2)} s after the knock-out`); + } +}); + +test('不屈\'s 立刻重新部署 (a real redeploy) already jumped at once — unchanged', () => { + const { after } = reviveAndJump({ bonds: { indomShip: { count: 2, active: true, tier: 1, layers: 300 } } }); + assert.ok(after != null && after < 1, `jumped ${after?.toFixed(2)} s after the knock-out`); +}); + +test('an in-place revive inside the window sets it back to its full length; 砾 revived by M3茧甲 gets a fresh barrier', () => { + const h = makeBattle({ seed: 3, autoFinish: false, timeLimit: 60, units: [{ chessId: TEX, row: 12, col: 3, skillIndex: 2, items: ['chess_item_4_12_e_a'] }] }); + h.step(); + const u = h.unit(TEX); + h.run(3); + assert.ok(u.skill.active && u.skill.timeLeft < u.skill.duration - 2, 'half-way through the window'); + h.b.dealDamage(null, u, { amount: 1e9, type: 'true' }); + h.step(2); + assert.ok(u.alive && u.skill.active && u.skill.timeLeft > u.skill.duration - 0.2, `back to the full ${u.skill.duration} s (${u.skill.timeLeft.toFixed(2)})`); + checkInvariants(h.b); + + const g = makeBattle({ seed: 3, autoFinish: false, timeLimit: 60, units: [{ chessId: 'chess_char_2_12_a', row: 10, col: 4, items: ['chess_item_4_12_e_a'] }] }); + g.step(); + const gr = g.unit('chess_char_2_12_a'); + g.run(12); + assert.ok(!gr.findBuff('gravel:rats'), 'the deployment barrier has run out'); + g.b.dealDamage(null, gr, { amount: 1e9, type: 'true' }); + g.step(2); + assert.ok(gr.alive && gr.findBuff('gravel:rats')?.shield > 0, 'a fresh barrier after the revive'); + checkInvariants(g.b); +});