diff --git a/docs/SIM.md b/docs/SIM.md index 08a53a3f..1d660a6e 100644 --- a/docs/SIM.md +++ b/docs/SIM.md @@ -596,7 +596,9 @@ and the 浮空 weight rule, then `statusApplied { source, target, status, durati = the target carried no buff of that status before (a refresh / a weaker "取最高" application is not an entry: "进入…时"). Effects follow the official term table (`gamedata_const.termDescriptionDict`, `ba.*`). Same-key statuses refresh to the longer duration, except the "同名效果取最高" ones marked *strongest* below: the strongest value wins, a weaker application never overrides it and, -if it outlasts it, resumes when the strong one expires (pass `refresh` to opt out); `stackAs` = the strength such an +if it outlasts it, resumes when the strong one expires (pass `refresh` to opt out) — every such waiting application keeps +its own end, and they resume strongest first (`buff.data.tail` is a chain ordered by strength: of 30 % / 25 % / 20 % +applied one after another, the 25 % follows the 30 %, then the 20 % — GitHub #342, PR #348); `stackAs` = the strength such an application competes with instead of its `value`, which stays its effect (Raidian S3's 虚弱: PRTS 备注 "在叠加时视为90%…的 虚弱(仅影响叠加优先级,不影响实际效果)"). `battle.applyStrongest(target, key, { duration, value, mods: (v) => mods, source })` gives a content effect that is not a diff --git a/server/sim/battle/status.js b/server/sim/battle/status.js index b4aeefb0..9b93244e 100644 --- a/server/sim/battle/status.js +++ b/server/sim/battle/status.js @@ -274,23 +274,39 @@ export class BattleStatus { } /** - * "同名效果取最高": keep the strongest value; a weaker one that outlasts it resumes afterwards (buff.data.tail). A + * "同名效果取最高": keep the strongest value, then resume weaker effects in order of strength at their original expiry times. + * `buff.data.tail` links the waiting effects, with decreasing strength and increasing expiry times. A * `plain` template (applyStrongest) is an ordinary invisible buff, not a status. `stackAs` (finite): the strength this * application competes with instead of its value (applyStatus opts.stackAs); kept in `data.stackAs` / the tail. */ _applyValuedStatus(target, key, tpl, duration, value, source, stackAs = null) { const as = Number.isFinite(stackAs) ? stackAs : null; const strength = (v, s = null) => Math.abs(Number.isFinite(s) ? s : Number.isFinite(v) ? v : tpl.valued); - // (no stackAs: the very objects of before — data { value, tail }, tails { value, until }) const entry = (v, s, extra) => (Number.isFinite(s) ? { value: v, stackAs: s, ...extra } : { value: v, ...extra }); + const after = (tail, until) => { + while (tail && tail.until <= until) tail = tail.tail; + return tail || null; + }; + const insertTail = (tail, incoming) => { + if (!tail) return incoming; + const diff = strength(incoming.value, incoming.stackAs) - strength(tail.value, tail.stackAs); + if (diff > 1e-12) return { ...incoming, tail: after(tail, incoming.until) }; + if (diff < -1e-12) { + if (incoming.until <= tail.until) return tail; + return { ...tail, tail: insertTail(tail.tail, incoming) }; + } + // Equal-priority waiting effects keep the longest-lived application's value, including its stackAs. + const kept = incoming.until > tail.until ? incoming : tail; + return { ...kept, tail: after(tail.tail, kept.until) }; + }; const make = (v, dur, tail, s = null) => ({ ...(tpl.buff || null), // extra buff fields of the status (抵抗: the 麻痹 decay tick) key, duration: dur, refresh: 'replace', mods: tpl.mods(v), flags: tpl.flags || null, status: tpl.plain ? null : key, visible: !tpl.plain, source, data: entry(v, s, { tail }), onExpire: ({ battle, unit, buff }) => { - const t = buff.data.tail; - if (t && t.until - battle.time > 1e-6 && unit.alive) battle.addBuff(unit, make(t.value, t.until - battle.time, null, t.stackAs)); + const t = after(buff.data.tail, battle.time + 1e-6); + if (t && unit.alive) battle.addBuff(unit, make(t.value, t.until - battle.time, t.tail || null, t.stackAs)); }, }); const old = target.buffs.find((b) => b.key === key && (tpl.plain || b.status === key)); @@ -299,17 +315,17 @@ export class BattleStatus { const oldAs = old.data && Number.isFinite(old.data.stackAs) ? old.data.stackAs : null; const oldEnd = this.time + old.timeLeft, newEnd = this.time + duration; const oldTail = old.data && old.data.tail; - const longerTail = (a, b) => (!a ? b : !b ? a : (b.until > a.until ? b : a)); if (strength(value, as) > strength(oldV, oldAs) + 1e-12) { - // stronger: takes over now; the weaker old one (or its tail) resumes if it lasts longer - const tail = longerTail(oldEnd > newEnd ? entry(oldV, oldAs, { until: oldEnd }) : null, oldTail && oldTail.until > newEnd ? oldTail : null); + // Stronger: take over now, retaining every old effect that can still become active afterwards. + const tail = after(entry(oldV, oldAs, { until: oldEnd, tail: oldTail }), newEnd); this.addBuff(target, make(value, duration, tail, as)); } else if (strength(value, as) < strength(oldV, oldAs) - 1e-12) { - // weaker: never overrides; remembered as the tail when it outlasts the running one - if (newEnd > oldEnd && (!oldTail || newEnd > oldTail.until)) old.data = { ...old.data, value: oldV, tail: entry(value, as, { until: newEnd }) }; + // Weaker: insert among the waiting effects instead of replacing the one with the latest expiry. + if (newEnd > oldEnd) old.data = { ...old.data, value: oldV, tail: insertTail(oldTail, entry(value, as, { until: newEnd })) }; } else if (duration > old.timeLeft) { old.timeLeft = duration; old.duration = Math.max(old.duration, duration); + old.data.tail = after(oldTail, newEnd); } } diff --git a/test/content/strongest-status-overlap.test.js b/test/content/strongest-status-overlap.test.js new file mode 100644 index 00000000..498cc304 --- /dev/null +++ b/test/content/strongest-status-overlap.test.js @@ -0,0 +1,56 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { makeBattle, enemyRec } from '../helpers/battleHarness.js'; + +test('Dorothy, Pozëmka, and Schwarz DEF reductions fall back to the strongest unexpired application', () => { + const h = makeBattle({ + autoFinish: false, + timeLimit: 10, + flags: { dpInit: 0, dpPerSec: 0 }, + defs: { enemies: { enemy_dummy: enemyRec({ key: 'enemy_dummy', hp: 1e9, def: 1000, speed: 0 }) } }, + units: [ + { uid: 1, diy: { slot: 'chess_char_6_diy1_a', charId: 'char_4048_doroth', skillIndex: 0, uniEquipId: null }, elite: true, row: 10, col: 4 }, + { uid: 2, kind: 'token', tokenId: 'token_10025_doroth_recttp', ownerUid: 1, row: 9, col: 6 }, + { uid: 3, diy: { slot: 'chess_char_6_diy2_a', charId: 'char_4055_bgsnow', skillIndex: 0, uniEquipId: null }, elite: true, row: 10, col: 5 }, + { uid: 4, kind: 'token', tokenId: 'token_10026_bgsnow_subbow', ownerUid: 3, row: 9, col: 5 }, + { uid: 5, diy: { slot: 'chess_char_5_diy1_a', charId: 'char_340_shwaz', skillIndex: 2, uniEquipId: null }, elite: true, row: 9, col: 4 }, + ], + }); + h.step(); + // Keep the real kits and projectile travel; prevent extra attacks refreshing their reductions. + for (const unit of h.allies()) unit.atkCd = 100; + const trap = h.unit(2), typewriter = h.unit(4), schwarz = h.unit(5); + const enemy = h.spawn('enemy_dummy', { pos: [9, 6] }); + h.step(); + assert.equal(enemy.s.def, 700, 'Dorothy S1 applies the initial 30% reduction'); + + h.run(0.9 - h.b.time); + assert.equal(h.b.forceAttack(typewriter, [enemy]), true); + h.run(1.8 - h.b.time); + schwarz.skill.gainSp(999); + schwarz.atkCd = 0; + h.step(); + assert.equal(schwarz.skill.active, true, 'Schwarz S3 guarantees her armor-piercing talent'); + schwarz.atkCd = 100; + h.run(2.1 - h.b.time); + + const reductions = h.hooksOf('statusApplied').filter((event) => event.target === enemy && event.status === 'defDown'); + assert.deepEqual(reductions.map(({ source, value, duration }) => [source.id, value, duration]), [ + [trap.id, 0.3, 5], + [typewriter.id, 0.25, 5], + [schwarz.id, 0.2, 5], + ]); + for (const [index, expectedTime] of [[0, 0], [1, 1], [2, 2]]) { + assert.ok(Math.abs(reductions[index].t - expectedTime) < 0.1, `reduction ${index + 1} lands near ${expectedTime}s`); + } + assert.equal(enemy.s.def, 700, 'weaker applications do not reduce the active strength'); + + h.run(5.1 - h.b.time); + assert.equal(enemy.s.def, 750, 'the typewriter reduction survives Dorothy S1 expiring'); + h.run(6.1 - h.b.time); + assert.equal(enemy.s.def, 800, 'Schwarz reduction survives the typewriter reduction expiring'); + h.run(7.1 - h.b.time); + assert.equal(enemy.s.def, 1000, 'DEF returns to its base value after the final application expires'); + assert.deepEqual(h.b.errors, []); + h.invariants(); +}); diff --git a/test/golden/diy.json b/test/golden/diy.json index c467dac7..4c23ba5a 100644 --- a/test/golden/diy.json +++ b/test/golden/diy.json @@ -2403,15 +2403,15 @@ "bossHpLeft": null, "rngDraws": 0, "errors": {"count":0,"kinds":[]}, - "players": {"p1":{"killed":51,"total":96,"perfect":false,"leaks":45,"leaksUncounted":0,"coins":0,"dmg":294912,"bossDmg":0,"heal":232713,"deaths":15,"layerGains":{}}}, + "players": {"p1":{"killed":51,"total":96,"perfect":false,"leaks":45,"leaksUncounted":0,"coins":0,"dmg":294912,"bossDmg":0,"heal":232708,"deaths":15,"layerGains":{}}}, "units": [ ["p1",1,"op","chess_char_5_diy1_b",0,"uniequip_002_shu",3270,1,118,5,16598,16808,5472,47,2], ["p1",2,"op","chess_char_5_diy2_b",1,"uniequip_002_shu",3270,1,120,1,2287,16353,17674,22,1], ["p1",3,"op","chess_char_5_diy1_b",2,"uniequip_002_shu",3270,1,119,5,30488,15560,32515,90,3], ["p1",4,"op","chess_char_6_diy1_a",0,null,2333.85,1,117,3,24548,27318,9004,88,2], ["p1",5,"op","chess_char_6_diy2_a",1,null,2513,1,99,1,8037,34719,37158,50,2], - ["p1",6,"op","chess_char_6_diy1_a",2,null,0,0,104,6,30371,33925,18939,121,1], - ["p1",7,"op","chess_char_6_diy2_b",0,"none",0,0,102,6,40221,40728,12451,139,3], + ["p1",6,"op","chess_char_6_diy1_a",2,null,0,0,104,6,30371,33925,18935,121,1], + ["p1",7,"op","chess_char_6_diy2_b",0,"none",0,0,102,6,40221,40723,12451,139,3], ["p1",8,"op","chess_char_6_diy1_b",1,"none",0,0,95,3,17536,32106,25999,78,2], ["p1",9,"op","chess_char_6_diy2_b",2,"none",0,0,5,3,17720,6178,25141,72,2], ["p1",10,"op","chess_char_6_diy1_b",0,"uniequip_002_shu",0,0,8,0,15926,8337,2961,37,0], @@ -2421,11 +2421,11 @@ ["-","~2","device","trap_1105_accrate",null,null,100,1,2,0,0,0,0,0,null], ["-","~3","device","trap_1105_accrate",null,null,100,1,3,0,0,0,0,0,null] ], - "enemies": {"enemy_1229_darmy":[27,18,9,42204,86393],"enemy_1229_darmy_2":[57,33,18,151066,218478],"enemy_9007_acelem":[12,0,11,64874,94148]}, + "enemies": {"enemy_1229_darmy":[27,18,9,42200,86393],"enemy_1229_darmy_2":[57,33,18,151051,218478],"enemy_9007_acelem":[12,0,11,64889,94148]}, "hooks": [1,120,6900,1655,1655,4214,4508,24466,66,104,21,20,0,16520,0,98,96,38,1,66,0,42,0], "events": {"atk":1711,"deploy":21,"die":66,"dmg":4508,"engage":12,"fx":146,"heal":369,"leak":38,"skill":41,"spawn":111,"status":104}, "fx": {"beam":16,"burst":42,"healField":46,"teleport":42}, - "snaps": "37fbb702 9cbcf13c 0a89a448 750d277b d9592437 862401cb 093e7be6 1be132e4 3c1f9fb3 49416bde 2829f9ff 004d18cc 4a040151 bbe05ad5 c52beaeb 202f1244 3901f434 601d0167 a4eb9728 5fc3c9a2 2267d1c9 4e912d69 fd2bfd2e" + "snaps": "37fbb702 9cbcf13c 0a89a448 750d277b d9592437 be9b7730 7dbdaeb3 ac878763 fcb3fa2d 0d8b02dc 4a580dd9 9469a886 763426bb cd35262b 13c864f5 dcf9735a cfb24522 27704e21 83f9d1ea 25515c68 00b3c803 21fe5f4f 6cf1426c" }, "diy-060": { "about": "5_diy1_a=pallas/S1 5_diy2_a=pallas/S2 5_diy1_a=pallas/S3 5_diy2_b=pallas/S1 5_diy1_b=pallas/S2 5_diy2_b=pallas/S3 5_diy1_b=pallas/S1/002_pallas 5_diy2_b=pallas/S2/002_pallas 5_diy1_b=pallas/S3/002_pallas 5_diy2_b=pallas/S1/003_pallas 5_diy1_b=pallas/S2/003_pallas 5_diy2_b=pallas/S3/003_pallas", @@ -5248,29 +5248,29 @@ "bossHpLeft": null, "rngDraws": 0, "errors": {"count":0,"kinds":[]}, - "players": {"p1":{"killed":0,"total":123,"perfect":false,"leaks":123,"leaksUncounted":0,"coins":0,"dmg":306066,"bossDmg":0,"heal":32670,"deaths":17,"layerGains":{}}}, + "players": {"p1":{"killed":0,"total":123,"perfect":false,"leaks":123,"leaksUncounted":0,"coins":0,"dmg":307696,"bossDmg":0,"heal":37911,"deaths":17,"layerGains":{}}}, "units": [ - ["p1",1,"op","chess_char_6_diy1_b",0,"uniequip_002_radian",1158,1,4,0,33930,0,0,127,4], - ["p1",2,"op","chess_char_6_diy2_b",1,"uniequip_002_radian",1158,1,5,0,41774,0,0,124,4], - ["p1",3,"op","chess_char_6_diy1_b",2,"uniequip_002_radian",1158,1,11,0,60012,0,0,137,3], - ["p1",4,"op","chess_char_6_diy2_b",0,"uniequip_003_radian",993,1,13,0,40817,0,0,140,4], - ["p1",5,"op","chess_char_6_diy1_b",1,"uniequip_003_radian",993,1,6,0,43040,0,0,121,4], + ["p1",1,"op","chess_char_6_diy1_b",0,"uniequip_002_radian",1158,1,4,0,34135,0,0,127,4], + ["p1",2,"op","chess_char_6_diy2_b",1,"uniequip_002_radian",1158,1,5,0,41953,0,0,124,4], + ["p1",3,"op","chess_char_6_diy1_b",2,"uniequip_002_radian",1158,1,11,0,60038,0,0,137,3], + ["p1",4,"op","chess_char_6_diy2_b",0,"uniequip_003_radian",993,1,13,0,40604,0,0,140,4], + ["p1",5,"op","chess_char_6_diy1_b",1,"uniequip_003_radian",993,1,6,0,42960,0,0,121,4], ["p1",6,"op","chess_char_6_diy2_b",2,"uniequip_003_radian",993,1,14,0,62016,0,0,132,3], - ["p1",7,"op","chess_char_5_diy1_a",0,null,0,0,147,0,1742,13278,7559,25,1], - ["p1",8,"op","chess_char_5_diy2_a",1,null,0,0,148,0,5534,14951,4707,35,1], + ["p1",7,"op","chess_char_5_diy1_a",0,null,0,0,149,0,3483,17332,8610,34,1], + ["p1",8,"op","chess_char_5_diy2_a",1,null,0,0,147,0,5147,16241,5568,34,1], ["p1",9,"op","chess_char_5_diy1_a",2,null,0,0,130,0,1625,4555,220,8,0], - ["p1",10,"op","chess_char_5_diy2_b",0,"none",0,0,149,0,8888,10867,5572,46,1], + ["p1",10,"op","chess_char_5_diy2_b",0,"none",0,0,148,0,9046,11213,5918,46,1], ["p1",11,"op","chess_char_5_diy1_b",1,"none",0,0,133,0,3696,5730,435,15,0], - ["p1",12,"op","chess_char_5_diy2_b",2,"none",0,0,134,0,2992,10413,14176,43,1], + ["p1",12,"op","chess_char_5_diy2_b",2,"none",0,0,134,0,2992,9964,17159,47,1], ["-","~1","device","trap_1105_accrate",null,null,100,1,1,0,0,0,0,0,null], ["-","~2","device","trap_1105_accrate",null,null,100,1,2,0,0,0,0,0,null], ["-","~3","device","trap_1105_accrate",null,null,100,1,3,0,0,0,0,0,null] ], - "enemies": {"enemy_1010_demon":[54,0,53,29077,128397],"enemy_1422_lrsldr_2":[39,0,39,21409,104996],"enemy_1425_lrcmra_2":[30,0,30,9310,72672]}, - "hooks": [1,149,6900,1053,1053,986,986,1657,17,139,26,26,0,29616,226,17,123,122,1,18,0,0,0], - "events": {"atk":1053,"deploy":23,"die":17,"dmg":986,"engage":12,"fx":9,"heal":67,"leak":122,"skill":52,"spawn":138,"status":234}, + "enemies": {"enemy_1010_demon":[54,0,53,37546,132382],"enemy_1422_lrsldr_2":[39,0,39,17859,103701],"enemy_1425_lrcmra_2":[30,0,30,9630,71612]}, + "hooks": [1,149,6900,1072,1072,1001,1001,2279,17,139,26,26,0,30067,226,17,123,122,1,20,0,0,0], + "events": {"atk":1072,"deploy":23,"die":17,"dmg":1001,"engage":12,"fx":9,"heal":71,"leak":122,"skill":52,"spawn":138,"status":234}, "fx": {"shield":8,"undying":1}, - "snaps": "422f4ca7 82d85ded 1385aefd a28b5e3b a21d6686 d83862af 7e5ba79d e77bedc9 b9807ab8 28b73dbc 909a17e9 b7d76569 d8a709e8 011fc05a 22f68ccc 8d128c96 d14e6a62 7b6b8033 bc6418b2 000d2486 8070923b 23199b14 c39148dc" + "snaps": "422f4ca7 82d85ded f9912cdd d2129b24 b99085df a39a1c44 8adc939d 29eff8bc 80335586 a23ffeab ddb0ec35 0c8321e8 31df363f 198f71d3 407a9f76 14edeeda 025ea6cd 9741503f 4b8237b4 98be51c7 45f5682e b173bb87 5b2dd744" }, "diy-123": { "about": "5_diy1_b=slent2/S1/002_slent2 5_diy2_b=slent2/S2/002_slent2 5_diy1_b=slent2/S3/002_slent2 5_diy2_b=slent2/S1/003_slent2 5_diy1_b=slent2/S2/003_slent2 5_diy2_b=slent2/S3/003_slent2 6_diy1_a=slent2/S1 6_diy2_a=slent2/S2 6_diy1_a=slent2/S3 6_diy2_b=slent2/S1 6_diy1_b=slent2/S2 6_diy2_b=slent2/S3", @@ -5290,26 +5290,26 @@ "bossHpLeft": null, "rngDraws": 11, "errors": {"count":0,"kinds":[]}, - "players": {"p1":{"killed":12,"total":36,"perfect":false,"leaks":24,"leaksUncounted":0,"coins":0,"dmg":163954,"bossDmg":0,"heal":112142,"deaths":21,"layerGains":{}}}, + "players": {"p1":{"killed":12,"total":36,"perfect":false,"leaks":24,"leaksUncounted":0,"coins":0,"dmg":158628,"bossDmg":0,"heal":111125,"deaths":21,"layerGains":{}}}, "units": [ - ["p1",1,"op","chess_char_5_diy1_b",0,"uniequip_002_slent2",0,0,57,0,8245,5577,12709,42,2], - ["p1",2,"op","chess_char_5_diy2_b",1,"uniequip_002_slent2",0,0,54,0,22019,9588,6117,65,3], - ["p1",3,"op","chess_char_5_diy1_b",2,"uniequip_002_slent2",0,0,56,0,3686,6734,13453,37,1], - ["p1",4,"op","chess_char_5_diy2_b",0,"uniequip_003_slent2",0,0,52,0,21984,8140,11594,47,2], - ["p1",5,"op","chess_char_5_diy1_b",1,"uniequip_003_slent2",819.96,1,60,0,28128,4330,2400,38,2], - ["p1",6,"op","chess_char_5_diy2_b",2,"uniequip_003_slent2",1068.01,1,58,0,17952,2762,11014,44,1], - ["p1",7,"op","chess_char_6_diy1_a",0,null,724.73,1,55,0,10681,2855,6823,50,2], - ["p1",8,"op","chess_char_6_diy2_a",1,null,0,0,53,0,12848,6062,5102,58,2], - ["p1",9,"op","chess_char_6_diy1_a",2,null,0,0,48,1,5235,7339,11935,51,1], - ["p1",10,"op","chess_char_6_diy2_b",0,"none",0,0,47,0,10824,44211,14116,51,2], - ["p1",11,"op","chess_char_6_diy1_b",1,"none",0,0,59,0,13464,3751,878,38,2], - ["p1",12,"op","chess_char_6_diy2_b",2,"none",0,0,51,0,8888,50121,16001,60,1] + ["p1",1,"op","chess_char_5_diy1_b",0,"uniequip_002_slent2",0,0,57,0,11446,6763,12809,48,2], + ["p1",2,"op","chess_char_5_diy2_b",1,"uniequip_002_slent2",1865,1,54,0,21728,5602,7475,71,3], + ["p1",3,"op","chess_char_5_diy1_b",2,"uniequip_002_slent2",1116.5,1,56,0,7663,8790,15155,51,2], + ["p1",4,"op","chess_char_5_diy2_b",0,"uniequip_003_slent2",0,0,53,0,19296,9125,8872,43,2], + ["p1",5,"op","chess_char_5_diy1_b",1,"uniequip_003_slent2",0,0,60,0,23328,4125,1375,31,2], + ["p1",6,"op","chess_char_5_diy2_b",2,"uniequip_003_slent2",0,0,58,0,12288,3897,9218,35,1], + ["p1",7,"op","chess_char_6_diy1_a",0,null,0,0,55,0,7895,4480,6020,42,2], + ["p1",8,"op","chess_char_6_diy2_a",1,null,1392.65,1,52,0,16254,3375,4934,67,2], + ["p1",9,"op","chess_char_6_diy1_a",2,null,0,0,50,1,7402,9240,11944,58,1], + ["p1",10,"op","chess_char_6_diy2_b",0,"none",0,0,49,0,10824,49145,18299,58,2], + ["p1",11,"op","chess_char_6_diy1_b",1,"none",0,0,59,0,12056,4225,1168,35,2], + ["p1",12,"op","chess_char_6_diy2_b",2,"none",0,0,51,0,8448,39925,13856,59,1] ], - "enemies": {"enemy_1005_yokai_2":[12,12,0,334,2893],"enemy_1005_yokai_3":[12,0,8,76865,64685],"enemy_9009_acfort":[12,0,11,74270,96377]}, - "hooks": [1,60,6900,1019,1019,858,858,1795,33,52,21,21,0,24163,0,0,36,19,1,32,0,0,0], - "events": {"atk":1103,"deploy":24,"die":33,"dmg":858,"engage":12,"fx":49,"heal":250,"leak":19,"skill":42,"spawn":48,"status":28}, - "fx": {"beam":46,"undying":3}, - "snaps": "a5b373d8 cade2571 25bd2c7f 01adb744 fbb45b57 6848708b 0a96115c 7e128c01 21df8c78 e6903500 2f41b832 aa0ba68b 8641acbf 21932ee2 038f1d76 a983d0f1 3ced4395 63969cf1 ccc7caac 2ce3eb58 ecd3dfd9 4ef03b03 eb5d21bd" + "enemies": {"enemy_1005_yokai_2":[12,12,0,335,2893],"enemy_1005_yokai_3":[12,0,8,77142,51740],"enemy_9009_acfort":[12,0,11,71214,103995]}, + "hooks": [1,60,6900,1033,1033,846,846,1544,33,52,22,20,0,24223,0,0,36,19,1,36,0,0,0], + "events": {"atk":1104,"deploy":24,"die":33,"dmg":846,"engage":12,"fx":49,"heal":260,"leak":19,"skill":42,"spawn":48,"status":29}, + "fx": {"beam":45,"undying":4}, + "snaps": "a5b373d8 cade2571 25bd2c7f 01adb744 fbb45b57 e30ff540 3b72daed 51b4c8b9 06683530 b735ddf5 82033c8c 713018f8 4395803f 890d91a1 2e056bbf 2e2e98df d852cfe2 19169929 86dc378b 79c1efd7 d9de2b5f 90c00c25 ea46d144" }, "diy-124": { "about": "6_diy1_b=slent2/S1/002_slent2 6_diy2_b=slent2/S2/002_slent2 6_diy1_b=slent2/S3/002_slent2 6_diy2_b=slent2/S1/003_slent2 6_diy1_b=slent2/S2/003_slent2 6_diy2_b=slent2/S3/003_slent2 5_diy1_a=haruka/S1 5_diy2_a=haruka/S2 5_diy1_a=haruka/S3 5_diy2_b=haruka/S1 5_diy1_b=haruka/S2 5_diy2_b=haruka/S3", @@ -5329,15 +5329,15 @@ "bossHpLeft": null, "rngDraws": 0, "errors": {"count":0,"kinds":[]}, - "players": {"p1":{"killed":3,"total":147,"perfect":false,"leaks":144,"leaksUncounted":0,"coins":0,"dmg":388624,"bossDmg":0,"heal":186324,"deaths":1,"layerGains":{}}}, + "players": {"p1":{"killed":3,"total":147,"perfect":false,"leaks":144,"leaksUncounted":0,"coins":0,"dmg":388624,"bossDmg":0,"heal":186275,"deaths":1,"layerGains":{}}}, "units": [ ["p1",1,"op","chess_char_6_diy1_b",0,"uniequip_002_slent2",2298,1,6,0,21267,0,30997,124,4], ["p1",2,"op","chess_char_6_diy2_b",1,"uniequip_002_slent2",2298,1,11,0,30498,0,13312,138,5], ["p1",3,"op","chess_char_6_diy1_b",2,"uniequip_002_slent2",2298,1,14,0,17697,0,14062,99,2], - ["p1",4,"op","chess_char_6_diy2_b",0,"uniequip_003_slent2",1965,1,4,0,31024,0,5800,77,4], + ["p1",4,"op","chess_char_6_diy2_b",0,"uniequip_003_slent2",1965,1,4,0,31024,0,5870,76,4], ["p1",5,"op","chess_char_6_diy1_b",1,"uniequip_003_slent2",1965,1,5,0,52007,0,10668,130,5], - ["p1",6,"op","chess_char_6_diy2_b",2,"uniequip_003_slent2",2358,1,7,0,33734,19361,24981,114,2], - ["p1",7,"op","chess_char_5_diy1_a",0,null,1556.5,1,12,1,18973,0,14769,124,5], + ["p1",6,"op","chess_char_6_diy2_b",2,"uniequip_003_slent2",2358,1,7,0,33734,19312,24981,114,2], + ["p1",7,"op","chess_char_5_diy1_a",0,null,1556.5,1,12,1,18973,0,14651,124,5], ["p1",8,"op","chess_char_5_diy2_a",1,null,1556.5,1,15,0,36496,0,16816,75,2], ["p1",9,"op","chess_char_5_diy1_a",2,null,1556.5,1,8,1,31939,48618,9950,102,3], ["p1",10,"op","chess_char_5_diy2_b",0,"none",1531.8,1,10,1,20540,21770,18960,127,5], @@ -5347,11 +5347,11 @@ ["-","~2","device","trap_1105_accrate",null,null,100,1,2,0,0,0,0,0,null], ["-","~3","device","trap_1105_accrate",null,null,100,1,3,0,0,0,0,0,null] ], - "enemies": {"enemy_1010_demon_2":[36,0,34,129029,143213],"enemy_1071_dftman":[51,1,50,10701,69708],"enemy_1072_dlancer":[60,2,56,49435,175702]}, - "hooks": [1,163,6900,1602,1602,1824,1824,1019,4,144,42,38,0,42759,60,66,147,140,1,4,0,0,0], - "events": {"atk":1836,"deploy":13,"die":4,"dmg":1818,"engage":12,"fx":794,"heal":616,"leak":140,"skill":80,"spawn":162,"status":757}, + "enemies": {"enemy_1010_demon_2":[36,0,34,129029,143213],"enemy_1071_dftman":[51,1,50,10701,69708],"enemy_1072_dlancer":[60,2,56,49387,175702]}, + "hooks": [1,163,6900,1601,1601,1824,1824,1018,4,144,42,38,0,42759,60,66,147,140,1,4,0,0,0], + "events": {"atk":1835,"deploy":13,"die":4,"dmg":1818,"engage":12,"fx":794,"heal":614,"leak":140,"skill":80,"spawn":162,"status":757}, "fx": {"aoe":98,"heal":310,"levitate":60,"shield":326}, - "snaps": "1b8091e0 73c802f9 110ffccb 73e97743 2c3d94d2 37aed2ff 5fdabeab 69cb41ab 491632d5 aef350f1 30f8d397 418bbed0 483f862b e8d524ca 1a85cf2d 0771fb68 0ed53d18 5abc2708 a6f0038b 0f8d27f3 f803bfaf 669dc197 e8d45f8a" + "snaps": "1b8091e0 73c802f9 110ffccb 73e97743 2c3d94d2 37aed2ff 5fdabeab 69cb41ab 491632d5 aef350f1 30f8d397 418bbed0 483f862b e8d524ca 1a85cf2d 0771fb68 0ed53d18 5abc2708 83618567 204d2f27 488b9d83 57bbc743 6a9e58ce" }, "diy-125": { "about": "5_diy1_b=haruka/S1/002_haruka 5_diy2_b=haruka/S2/002_haruka 5_diy1_b=haruka/S3/002_haruka 6_diy1_a=haruka/S1 6_diy2_a=haruka/S2 6_diy1_a=haruka/S3 6_diy2_b=haruka/S1 6_diy1_b=haruka/S2 6_diy2_b=haruka/S3 6_diy1_b=haruka/S1/002_haruka 6_diy2_b=haruka/S2/002_haruka 6_diy1_b=haruka/S3/002_haruka", @@ -5822,22 +5822,22 @@ ["p1",3,"op","chess_char_6_diy1_a",2,null,1810.9,1,11,0,0,0,53690,111,2], ["p1",4,"op","chess_char_6_diy2_b",0,"none",2005.9,1,13,0,0,0,46002,87,4], ["p1",5,"op","chess_char_6_diy1_b",1,"none",2005.9,1,6,18,704057,0,15788,61,3], - ["p1",6,"op","chess_char_6_diy2_b",2,"none",2005.9,1,14,0,0,0,26930,79,3], + ["p1",6,"op","chess_char_6_diy2_b",2,"none",2005.9,1,14,0,0,0,27264,78,3], ["p1",7,"op","chess_char_5_diy1_a",0,null,0,0,7,0,0,81970,41023,72,12], ["p1",8,"op","chess_char_5_diy2_a",1,null,1492,1,9,0,0,98191,51694,93,4], - ["p1",9,"op","chess_char_5_diy1_a",2,null,217.03,1,10,0,0,107130,60043,84,0], + ["p1",9,"op","chess_char_5_diy1_a",2,null,217.03,1,10,0,0,107130,60487,84,0], ["p1",10,"op","chess_char_5_diy2_b",0,"none",1653,1,12,0,0,22049,53056,73,18], - ["p1",11,"op","chess_char_5_diy1_b",1,"none",1653,1,15,0,0,64626,18934,43,2], + ["p1",11,"op","chess_char_5_diy1_b",1,"none",1653,1,15,0,0,64626,18156,43,2], ["p1",12,"op","chess_char_5_diy2_b",2,"none",1653,1,8,0,0,90649,44508,81,0], ["-","~1","device","trap_1105_accrate",null,null,100,1,1,0,0,0,0,0,null], ["-","~2","device","trap_1105_accrate",null,null,100,1,2,0,0,0,0,0,null], ["-","~3","device","trap_1105_accrate",null,null,100,1,3,0,0,0,0,0,null] ], "enemies": {"enemy_1006_shield_3":[21,0,16,372723,566446],"enemy_1043_zomsbr":[66,60,5,22690,479046],"enemy_1402_tgshd_2":[36,10,20,69202,794079]}, - "hooks": [1,138,6900,1408,1408,1120,1120,13592,71,112,55,52,70,22853,566,8,123,41,1,71,0,0,0], - "events": {"atk":2335,"deploy":12,"die":71,"dmg":1110,"engage":2,"fx":458,"heal":1178,"leak":41,"skill":107,"spawn":138,"status":209}, + "hooks": [1,138,6900,1407,1407,1120,1120,13591,71,112,55,52,70,22853,566,8,123,41,1,71,0,0,0], + "events": {"atk":2334,"deploy":12,"die":71,"dmg":1110,"engage":2,"fx":458,"heal":1178,"leak":41,"skill":107,"spawn":138,"status":209}, "fx": {"aoe":70,"expose":272,"healAoe":70,"shield":40,"takeoff":6}, - "snaps": "0a2a0a58 9edb0805 ec947f3f 7ffcd830 23c8b514 9268451b 1587cab5 93203dcf 675b3282 b8e42ac4 d194e3af c0d85745 cb282efe f152e307 02944050 4dab08fa 73f1afb0 1a6a7b38 89999054 6738780f f5ef296b 9876bcdc 9e74e0c9" + "snaps": "0a2a0a58 9edb0805 ec947f3f 7ffcd830 23c8b514 9268451b 1587cab5 93203dcf 675b3282 b8e42ac4 d194e3af c0d85745 4520de90 8fa30b22 3c71ad2d 7af3c5a9 1b07ebf9 d6841316 a0cdffb6 e3a8979b d1066696 fe5e3f62 b6c3a091" }, "diy-134": { "about": "5_diy1_b=monstr/S1/002_monstr 5_diy2_b=monstr/S2/002_monstr 5_diy1_b=monstr/S3/002_monstr 6_diy1_a=monstr/S1 6_diy2_a=monstr/S2 6_diy1_a=monstr/S3 6_diy2_b=monstr/S1 6_diy1_b=monstr/S2 6_diy2_b=monstr/S3 6_diy1_b=monstr/S1/002_monstr 6_diy2_b=monstr/S2/002_monstr 6_diy1_b=monstr/S3/002_monstr", @@ -6036,7 +6036,7 @@ ], "enemies": {"enemy_1288_duskls":[27,4,22,19614,114889],"enemy_1288_duskls_2":[57,12,45,7003,313267],"enemy_1292_duskld":[51,9,38,77789,451811]}, "hooks": [1,153,6900,1638,1638,2202,2465,332,35,140,20,18,0,28180,18680,57,135,105,1,84,0,0,6], - "events": {"atk":1961,"deploy":18,"die":35,"dmg":2465,"engage":9,"fx":119,"heal":279,"leak":105,"skill":42,"spawn":147,"status":1300}, + "events": {"atk":1961,"deploy":18,"die":35,"dmg":2465,"engage":9,"fx":119,"heal":279,"leak":105,"skill":42,"spawn":147,"status":1298}, "fx": {"aoe":5,"dodge":43,"ember":49,"revive":22}, "snaps": "3e9a4d11 f9a475f7 d909dafe 07c79a9f 76e65988 7dff2527 631c9a48 90e879b4 cfe3ca91 ccb275cc 2445e31f 2132570f ae5cb98a c2376534 c8afbc93 fbbb00d5 98187a92 3228cfb6 f8b78613 5724ac77 b963f388 bb322dda fee7681a" } diff --git a/test/sim/strongest-status.test.js b/test/sim/strongest-status.test.js new file mode 100644 index 00000000..8c056517 --- /dev/null +++ b/test/sim/strongest-status.test.js @@ -0,0 +1,125 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { makeBattle, enemyRec } from '../helpers/battleHarness.js'; + +function setup() { + const h = makeBattle({ + defs: { enemies: { enemy_test: enemyRec({ key: 'enemy_test', hp: 1e6, atk: 100, def: 1000, speed: 0 }) } }, + enemies: [{ key: 'enemy_test', pos: [11, 8] }], content: 'none', autoFinish: false, + }); + h.step(); + return { h, e: h.enemy('enemy_test') }; +} + +const orders = [[0, 1, 2], [0, 2, 1], [1, 0, 2], [1, 2, 0], [2, 0, 1], [2, 1, 0]]; +const approx = (actual, expected) => assert.ok(Math.abs(actual - expected) < 1e-6, `${actual} ≈ ${expected}`); + +for (const plain of [false, true]) { + for (const order of orders) { + test(`${plain ? 'applyStrongest' : 'fragile'}: three effects expire in strength order after application order ${order}`, () => { + const { h, e } = setup(); + const effects = [{ value: 0.8, duration: 2 }, { value: 0.6, duration: 5 }, { value: 0.3, duration: 10 }]; + const key = plain ? 'test:strongest' : 'fragile'; + for (const i of order) { + if (plain) h.b.applyStrongest(e, key, { ...effects[i], mods: (v) => ({ dmgTakenMul: 1 + v }) }); + else h.b.applyStatus(e, key, effects[i]); + } + for (const [elapsed, multiplier, damage] of [[0, 1.8, 180], [2.1, 1.6, 160], [3, 1.3, 130], [5, 1, 100]]) { + if (elapsed) h.run(elapsed); + approx(e.s.dmgTakenMul, multiplier); + assert.equal(h.b.dealDamage(null, e, { amount: 100, type: 'true' }), damage); + assert.equal(e.buffs.filter((b) => b.key === key).length, multiplier === 1 ? 0 : 1); + } + assert.deepEqual(h.b.errors, []); + }); + } +} + +test('strongest statuses: a weaker effect that expires under the winner never resumes', () => { + const { h, e } = setup(); + h.b.applyStatus(e, 'fragile', { value: 0.6, duration: 3 }); + h.b.applyStatus(e, 'fragile', { value: 0.3, duration: 10 }); + h.b.applyStatus(e, 'fragile', { value: 0.8, duration: 5 }); + h.run(5.1); + approx(e.s.dmgTakenMul, 1.3); + h.run(5); + assert.equal(e.findBuff('fragile'), null); +}); + +test('strongest statuses: equal-strength refresh skips expired fallbacks but keeps the later one', () => { + const { h, e } = setup(); + for (const [value, duration] of [[0.8, 2], [0.6, 5], [0.3, 10]]) h.b.applyStatus(e, 'fragile', { value, duration }); + h.run(1); + h.b.applyStatus(e, 'fragile', { value: 0.8, duration: 6 }); + h.b.applyStatus(e, 'fragile', { value: 0.8, duration: 1 }); + h.run(5.1); + approx(e.s.dmgTakenMul, 1.8); + h.run(1); + approx(e.s.dmgTakenMul, 1.3); + h.run(3); + assert.equal(e.findBuff('fragile'), null); +}); + +test('strongest statuses: refreshing a waiting effect preserves the weaker later fallback', () => { + const { h, e } = setup(); + for (const [value, duration] of [[0.8, 2], [0.6, 5], [0.3, 10]]) h.b.applyStatus(e, 'fragile', { value, duration }); + h.run(1); + h.b.applyStatus(e, 'fragile', { value: 0.6, duration: 6 }); + h.run(4.1); + approx(e.s.dmgTakenMul, 1.6); + h.run(2); + approx(e.s.dmgTakenMul, 1.3); + h.run(3); + assert.equal(e.findBuff('fragile'), null); +}); + +test('strongest statuses: stackAs controls every fallback without changing its actual effect', () => { + const { h, e } = setup(); + h.b.applyStatus(e, 'weaken', { value: 0.1, stackAs: 0.9, duration: 2 }); + h.b.applyStatus(e, 'weaken', { value: 0.2, stackAs: 0.8, duration: 5 }); + h.b.applyStatus(e, 'weaken', { value: 0.3, duration: 10 }); + approx(e.s.atk, 90); + h.run(2.1); + approx(e.s.atk, 80); + h.run(3); + approx(e.s.atk, 70); + h.run(5); + approx(e.s.atk, 100); +}); + +test('strongest statuses: equal-priority waiting effects keep the longest-lived application value', () => { + const { h, e } = setup(); + h.b.applyStatus(e, 'weaken', { value: 0.9, duration: 2 }); + h.b.applyStatus(e, 'weaken', { value: 0.1, stackAs: 0.8, duration: 5 }); + h.b.applyStatus(e, 'weaken', { value: 0.3, duration: 10 }); + h.b.applyStatus(e, 'weaken', { value: 0.2, stackAs: 0.8, duration: 7 }); + h.b.applyStatus(e, 'weaken', { value: 0.4, stackAs: 0.8, duration: 6 }); + h.b.applyStatus(e, 'weaken', { value: 0.5, stackAs: 0.8, duration: 7 }); + h.run(2.1); + approx(e.s.atk, 80); + h.run(3); + approx(e.s.atk, 80); + h.run(2); + approx(e.s.atk, 70); + h.run(3); + approx(e.s.atk, 100); +}); + +for (const plain of [false, true]) { + test(`${plain ? 'removeBuff' : 'removeStatus'} discards every waiting strongest effect`, () => { + const { h, e } = setup(); + const key = plain ? 'test:strongest' : 'fragile'; + for (const [value, duration] of [[0.8, 2], [0.6, 5], [0.3, 10]]) { + if (plain) h.b.applyStrongest(e, key, { value, duration, mods: (v) => ({ dmgTakenMul: 1 + v }) }); + else h.b.applyStatus(e, key, { value, duration }); + } + assert.equal(plain ? h.b.removeBuff(e, key) : h.b.removeStatus(e, key), 1); + h.b.applyStatus(e, 'fragile', { value: 0.1, duration: 1 }); + h.run(2.1); + approx(e.s.dmgTakenMul, 1); + h.run(8); + approx(e.s.dmgTakenMul, 1); + assert.equal(e.findBuff(key), null); + assert.deepEqual(h.b.errors, []); + }); +}