diff --git a/CHANGELOG.md b/CHANGELOG.md index 256510ca..0dbeb957 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -70,6 +70,7 @@ - 自制投石机「索敌不受阻挡影响」:被站在排气格栅上(隐匿)的干员阻挡、范围内又没有别的干员时不会攻击,和原版一样;其他敌人仍会攻击阻挡自己的隐匿干员(社区反馈)。 - 升级调度中心后,新增的商品栏位立即放上新卡,不用等下一次刷新(社区反馈;官方教程「升级后将出现更多的商品栏位」)。 - 被冻结或晕眩的敌人仍会被接触到的干员阻挡:干员再部署到它身边、或阻挡数空出来时会立即挡住它,隐匿的敌人也随之破隐,不用再等冻结或晕眩结束;沉睡、浮空、恐惧和不可阻挡的敌人仍然不会被阻挡(GitHub #232,感谢 @ccyl、@kukiC)。 +- 蕾缪安三技能锁定时攻击范围里没有敌人,技能不会再自动结束:剩余子弹和已有的锁定都保留,下一个敌人进入范围就立即继续锁定,子弹打完才结束并开始轰炸,等待期间锁定标记一直显示(社区反馈;弹药类技能打完才结束,PRTS 卫戍协议帮助「通常不会自动关闭技能」)。 ### 界面 diff --git a/docs/history/0.2.0.md b/docs/history/0.2.0.md index 9297f58c..51c00153 100644 --- a/docs/history/0.2.0.md +++ b/docs/history/0.2.0.md @@ -1980,3 +1980,50 @@ here, each reproduced first; the rest were answered (not a bug, already fixed in the parent), `test/match/playtest6-matchflow.test.js` #235 (2; the 联防 one fails on the parent: losses = the LP settled, no result ticker, gone in the next phase), SP_E2E `test/ui/resultbox.e2e.test.js` (3, mock variants `unite[,through][,helper|,dead]`). Docs: PLAYING.md §1 / §5, design/network.md, design/client.md, design/match.md. + +#### 25.22.8 Community report 「蕾缪安3技能范围里没人好像会自动结束」 (relayed by the owner): 蕾缪安's S3 waits with its bullets while nothing is in her range — `kits/ops/chess_char_6_01-lemuen.js`, `render/fx/locks.js` + +- **Seen** (a script on the parent 0255afd: S3 cast with 5 bullets, the locked enemy killed by someone else after 2 + locks, another enemy alive outside her range): the skill ended at the next lock tick + (1.00 s, `skillEnd` reason `ammo`), 3 bullets thrown away, 2 shells; an enemy that came into her range 3 s later found + it over. A locked walker leaving her range ended it the same way (4 locks, 1 bullet thrown away). In golden the elite + 蕾缪安 of roster-018 ended three of her five casts early (2 / 6, 5 / 9 and 6 / 9 bullets left). +- **Official**: the skill text 「每0.5秒消耗1发子弹依次锁定敌人……攻击装有5发弹药,打完后结束(可随时停止技能)」: an ammo + skill ends when its bullets are spent or when the player stops it. PRTS 卫戍协议/帮助 §作战阶段 技能操作: 「可执行的技能 + 操作包括开启/关闭技能,通常不会自动关闭技能」 — the only automatic close is 玛露希尔's 召唤使魔; 蕾缪安's S3 is on the + basic strategy. The client (charpack char_4193_lemuen, battle/buff_template_data.json): the S3 mode's lock is an attack + that needs a target (`_minTargetNum` 1, `_allowNoTarget` 0, cooldown `aim_interval`); the skill buff `lemuen_s_3` + switches to the S3_End mode (the bombardment) on `ON_BUFF_FINISH` only, and nothing ends it for want of a target. PRTS + S3 note: a mark follows its target and stays where the target left. +- **Cause**: the kit's lock tick ended the skill when `pickLock` found nobody and anyone had been locked — + `if (!e) { if (m.lemLocks.length) skill.end('ammo'); return; }` (chess_char_6_01-lemuen.js:169 on the parent), there + since the initial import with no decision behind it. +- **Now**: with nothing to lock the skill waits, bullets and locks kept — the marks follow their enemies (out of her range + too) or stay where they left — with its lock ready: the next enemy that comes into her range (or is 通缉) is locked on + the next tick, then every `aim_interval` s. The skill ends when the bullets are spent and the shells fall on every mark + in lock order, as before. While it waits she gains no SP (a running ammo skill holds it, as before); knocked out or + withdrawn, no bombardment (unchanged); a battle that ends meanwhile ends without shells (nothing left to hit). The S3 + `lock` fx carry `hold`: render/fx/locks.js keeps such a reticle while the shooter's view has the skill bit (UF.SKILL) + and counts its LOCK_T (5 game s) from the skill's end — without it the reticles faded 5 s into a wait. S2's aim lock + (no `hold`) keeps its timeout. +- Unchanged and noted: the client lock's 0.1 s pre-delay is not modelled (the lock lands on its tick, as before). +- Seen on the way, not changed (S2, not this report): 归乡邀约 spends a bullet on every normal attack (5 of its 6 gone in + 8 s of attacks on an enemy that is not 通缉), while PRTS's S2 note says only the aimed snipe at a 通缉 target spends one + (「若目标为被通缉状态,则攻击改为消耗1发弹药的瞄准狙击,否则不消耗弹药」) — left to the owner: fixed, S2 would hold its + buff until 通缉 enemies come, a large change. +- Golden: 3 scenarios — traced on both trees with an instrumented copy (SkillRuntime start / end logged, the round's + fields and the carried SP); in each the first divergence is an S3 the parent ended with bullets left and nothing in her + range: + - roster-018: the elite 蕾缪安's first S3 (7.63 s) ended at 9.63 s with 2 of 6 bullets left; now the 6th goes at + 12.00 s (first differing snapshot window 0–10 s). + - boss-boss_3-pair: her second S3 (27.8 s) ended at 30.3 s with 4 of 9 left; now all 9 by 34.43 s (window 30–40 s); the + boss now falls at 199.53 s (before: `forced` at the 200 s cap with 145080 HP left). + - coop2-NORMAL-14-standins: ai_0's 蕾缪安 (her own kit) — in round 11's battle her S3 ended at 17.2 s (6 of 8 left) and + 77.97 s (7 of 9); now all bullets are spent and the third cast does not come, so she enters the round's 联防 with + 25.96 SP instead of 10.27, casts S3 there and kills one more of h_0's leaked enemies: h_0 loses 1 LP less (rounds + 11–12 LP 18 → 19, 8 → 9; lpLost 30 → 29; ai_0 kills +1, dmgDealt +288). + - bonds, standins and diy unchanged. +- Tests: `test/sim/feedback5-lemuen-s3-wait.test.js` (3: the killed target and the walker who leaves fail on the parent — + the skill ended —; the battle that ends while she waits passes on both), `test/render/fxproj.test.js` +1 (a held lock + stays while the shooter's skill bit is on, then LOCK_T after; an unheld one times out — fails on the parent's + renderer); `test/content/kits_t6.test.js`, `kits_alt_t6.test.js`, `test/sim/feedback3-ammo-bar.test.js` pass unchanged. diff --git a/public/js/render/fx.js b/public/js/render/fx.js index 9e0c9650..47096091 100644 --- a/public/js/render/fx.js +++ b/public/js/render/fx.js @@ -18,7 +18,8 @@ // blasts explosions (fireball, flash, shockwave + coloured ground ring, sparks, smoke; heavy ones add debris // and a scorch mark) for blast fx kinds and shell impacts // bombard 蕾缪安 S3: fx 'lock' keeps a reticle on the locked enemy until the 'bombard' of the shell fired at it -// (or LOCK_T game s after the shooter's last lock / shell; the S2 aim lock until the snipe's 'crit'); 'bombardShell' = a shell fired now that +// (or LOCK_T game s after the shooter's last lock / shell — a held S3 lock, `hold`, LOCK_T after her skill +// ends: it waits with its bullets while nothing is in range; the S2 aim lock until the snipe's 'crit'); 'bombardShell' = a shell fired now that // lands on the spot after `t` game s — a launch streak leaves the shooter upwards and the shell drops out // of the sky onto the spot (a long flight climbs out of her first), a closing warning ring marks the spot // meanwhile; 'bombard' is the big explosion at the impact diff --git a/public/js/render/fx/locks.js b/public/js/render/fx/locks.js index 52dea118..2d4c6a7d 100644 --- a/public/js/render/fx/locks.js +++ b/public/js/render/fx/locks.js @@ -4,6 +4,7 @@ import { SHOT_HEIGHT, bodyZ } from './camera.js'; import { FX_KINDS } from './kinds.js'; import { easeOut } from './limits.js'; +import { UF } from '../../../../shared/constants.js'; /** * Lock reticle: how long (game s) a lock outlives the last sign of its shooter's S3 going on — its latest lock, shell or @@ -18,9 +19,11 @@ export class FxLocks { /** * fx 'lock': a reticle on the locked enemy (following its view; at the last spot once it is gone) until its shell * lands (mortar / _landed) or the aimed shot fires ('crit' from the same shooter); at most LOCK_T game seconds after - * the shooter's last lock / shell / bombard (_touchLocks). + * the shooter's last lock / shell / bombard (_touchLocks). A held lock (`hold`: 蕾缪安 S3, whose skill waits with its + * bullets and locks while nothing is in range) waits as long as its shooter's skill runs (the view's UF.SKILL), its + * LOCK_T counted from the skill's end. */ - _lock(view, src, x, y, z) { + _lock(view, src, x, y, z, hold = false) { let L = this.lockFree.pop(); if (!L) { const P = this.P; @@ -34,7 +37,7 @@ export class FxLocks { L = { ring, core }; } L.view = view; L.id = view ? view.id : null; L.src = src ?? null; L.x = x; L.y = y; L.z = z; - L.t = 0; L.idle = 0; L.max = LOCK_T / this._ts(); L.out = -1; L.shell = false; L.sx = x; L.sy = y; + L.t = 0; L.idle = 0; L.max = LOCK_T / this._ts(); L.out = -1; L.shell = false; L.sx = x; L.sy = y; L.hold = !!hold; L.ring.tint = L.core.tint = FX_KINDS.lock.c; L.ring.alpha = L.core.alpha = 0; L.ring.visible = L.core.visible = true; @@ -77,10 +80,12 @@ export class FxLocks { L.t += dt; L.idle += dt; if (L.out < 0 && L.idle >= L.max) L.out = 0; - // the shooter knocked out / withdrawn: the sim fires no more shells — only a shell already in the air still lands + // the shooter knocked out / withdrawn: the sim fires no more shells — only a shell already in the air still lands; + // a held lock waits while its shooter's skill runs (蕾缪安 S3 with nothing in range: its bullets and locks wait) if (L.out < 0 && !L.shell && L.src != null && this.ctx.view) { const sv = this._viewOf(L.src); if (!sv || sv.alive === false) L.out = 0; + else if (L.hold && (sv.flags & UF.SKILL)) L.idle = 0; } if (L.out >= 0) { L.out += dt; if (L.out >= LOCK_FADE) { this._freeLock(L); continue; } } const v = L.view; diff --git a/public/js/render/fx/simfx.js b/public/js/render/fx/simfx.js index 1c474b5e..4adf4327 100644 --- a/public/js/render/fx/simfx.js +++ b/public/js/render/fx/simfx.js @@ -50,7 +50,7 @@ export class FxSim { * counter, dp, coin, crate, down, beam, bolt, strike, volley, pillar, lp, chill, element, flame), except kinds whose * archetype is 'none' (hitCap: a leader hit cancelled by 限伤 draws nothing); unknown kinds get a generic sparkle. `extra` keys used: id (anchor unit — or the shooter of a `pt` kind), r | radius, dur | duration, * t (shell flight, game s), src / from / to / target / targets (unit ids), fx, fy / fromX, fromY / tx, ty (positions), - * element, n, scale, kind, tiles. + * element, n, scale, kind, tiles, hold ('lock': the reticle waits while the shooter's skill runs). */ simFx(kind, x, y, extra) { const ex = extra && typeof extra === 'object' ? extra : {}; @@ -178,9 +178,10 @@ export class FxSim { case 'mark': case 'reticle': { if (kind === 'lock') { // `id` is always the locked enemy: the reticle sticks to its view even a little off the event's spot + // (`hold`: 蕾缪安 S3 — the reticle waits while her skill runs, however long nothing is in range) const lv = at.v || this._viewOf(ex.id); this._touchLocks(ex.src ?? null); - this._lock(lv, ex.src ?? null, lv ? lv.x : at.x, lv ? lv.y : at.y, lv ? bodyZ(cam, lv, SHOT_HEIGHT.aim) : at.z + 0.55); + this._lock(lv, ex.src ?? null, lv ? lv.x : at.x, lv ? lv.y : at.y, lv ? bodyZ(cam, lv, SHOT_HEIGHT.aim) : at.z + 0.55, !!ex.hold); break; } const v = at.v; diff --git a/server/sim/content/kits/ops/chess_char_6_01-lemuen.js b/server/sim/content/kits/ops/chess_char_6_01-lemuen.js index 14a61aaa..3e4f8df1 100644 --- a/server/sim/content/kits/ops/chess_char_6_01-lemuen.js +++ b/server/sim/content/kits/ops/chess_char_6_01-lemuen.js @@ -152,6 +152,14 @@ function lemuen(bb, chess, def) { }; return { skills, + // S3 礼炮·强制追思, the lock phase: one lock every aim_interval s on an enemy in her range (the least locked first, + // then the lowest DEF), one bullet each; the skill ends when the bullets are spent, then bombard. With nothing in + // range it waits — bullets and locks kept (the marks follow their enemies / stay where they left) — and locks the + // next enemy that comes at once: an ammo skill has no time limit ("攻击装有5发弹药,打完后结束(可随时停止技能)"; in + // the client data her S3 lock needs a target and only the spent bullets end the skill buff lemuen_s_3) and the 卫戍协议 + // automation never stops a skill (PRTS 卫戍协议/帮助 技能操作: "通常不会自动关闭技能"). Until 0.2.0 the skill ended at + // the first lock tick with nothing in range (community report: 「蕾缪安3技能范围里没人好像会自动结束」). Its fx 'lock' + // carries `hold`: the renderer keeps those reticles up while her skill runs (render/fx/locks.js). skill: { kind: 'ammo', ammo: Math.max(1, Math.floor(num(bb['attack@trigger_time'], 5))), @@ -164,11 +172,11 @@ function lemuen(bb, chess, def) { if (!unit.canAct) return; m.lemAcc += dt; if (m.lemAcc + 1e-9 < aim) return; - m.lemAcc -= aim; const e = pickLock(battle, unit, m.lemLocks); - if (!e) { if (m.lemLocks.length) skill.end('ammo'); return; } + if (!e) { m.lemAcc = aim; return; } // nothing to lock: wait, the next lock ready the moment an enemy comes + m.lemAcc -= aim; m.lemLocks.push({ e, x: e.x, y: e.y }); - battle.fx('lock', { x: e.x, y: e.y, id: e.id, src: unit.id }); + battle.fx('lock', { x: e.x, y: e.y, id: e.id, src: unit.id, hold: 1 }); skill.ammoLeft--; battle.emit('ammoUsed', { unit, left: skill.ammoLeft, skill }); if (skill.active && skill.ammoLeft <= 0) skill.end('ammo'); diff --git a/test/golden/fields.json b/test/golden/fields.json index 252eaef3..6e087ff7 100644 --- a/test/golden/fields.json +++ b/test/golden/fields.json @@ -224,104 +224,102 @@ "round": 14, "seed": 1428555614, "waveId": "act1autochess_h07_03", - "time": 200, - "ticks": 6000, - "reason": "forced", + "time": 199.53, + "ticks": 5986, + "reason": "cleared", "killed": 21, "total": 21, "unspawned": 0, - "bossHpLeft": 145080, + "bossHpLeft": 0, "rngDraws": 1385, "errors": {"count":0,"kinds":[]}, "players": { - "p1": {"killed":21,"total":21,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":1679564,"bossDmg":1583484,"heal":2172,"deaths":0,"layerGains":{}}, - "p2": {"killed":0,"total":0,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":2359162,"bossDmg":2271437,"heal":16328,"deaths":0,"layerGains":{}} + "p1": {"killed":21,"total":21,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":1881663,"bossDmg":1745302,"heal":2230,"deaths":0,"layerGains":{}}, + "p2": {"killed":0,"total":0,"perfect":true,"leaks":0,"leaksUncounted":0,"coins":0,"dmg":2302143,"bossDmg":2254698,"heal":16832,"deaths":0,"layerGains":{}} }, "units": [ - ["p1",1,"op","chess_char_6_03_b",2,"uniequip_002_yu",3430,1,7,0,0,0,605,0,1], + ["p1",1,"op","chess_char_6_03_b",2,"uniequip_002_yu",3430,1,7,0,0,0,638,0,1], ["p1",2,"op","chess_char_6_06_b",2,"uniequip_002_pepe",2939,1,11,0,0,0,0,0,0], ["p1",3,"op","chess_char_6_07_b",2,"uniequip_002_siege2",3101,1,9,0,0,0,0,0,0], - ["p1",4,"op","chess_char_6_01_b",2,"uniequip_002_lemuen",2131.5,1,13,1,877126,0,0,62,8], - ["p1",5,"op","chess_char_6_02_b",2,"uniequip_002_sbell2",1933,1,17,0,349841,28,2,63,3], - ["p1",6,"op","chess_char_6_04_b",2,"uniequip_002_skadi2",1214.37,1,19,1,35820,8433,1241,0,5], - ["p1",7,"op","chess_char_6_05_b",2,"uniequip_002_pasngr",2307.2,1,21,2,293209,1008,323,97,8], - ["p1",8,"op","chess_char_6_08_b",2,"uniequip_002_reed2",1668,1,5,1,3064,0,1,11,1], - ["p1",9,"op","chess_char_6_09_b",0,"uniequip_002_cello",1322,1,15,1,21050,0,0,8,8], - ["p1",10,"op","chess_char_6_11_b",2,"uniequip_002_mlyss",1893,1,23,0,89834,0,0,206,5], + ["p1",4,"op","chess_char_6_01_b",2,"uniequip_002_lemuen",2131.5,1,13,2,1071144,0,0,62,8], + ["p1",5,"op","chess_char_6_02_b",2,"uniequip_002_sbell2",1933,1,17,1,348232,165,10,63,3], + ["p1",6,"op","chess_char_6_04_b",2,"uniequip_002_skadi2",1214.37,1,19,1,35824,8763,1252,0,5], + ["p1",7,"op","chess_char_6_05_b",2,"uniequip_002_pasngr",2307.2,1,21,4,284196,1248,326,96,8], + ["p1",8,"op","chess_char_6_08_b",2,"uniequip_002_reed2",1668,1,5,0,13,0,3,6,1], + ["p1",9,"op","chess_char_6_09_b",0,"uniequip_002_cello",1322,1,15,1,10883,0,0,7,7], + ["p1",10,"op","chess_char_6_11_b",2,"uniequip_002_mlyss",1893,1,23,0,88919,0,0,206,5], ["p1",11,"token","token_10017_skadi2_dedant",null,null,130.4,0,26,0,0,0,0,0,null], - ["p2",12,"op","chess_char_6_15_b",2,"uniequip_002_qiubai",3985.92,1,31,6,345376,3222,0,183,3], - ["p2",13,"op","chess_char_6_19_b",2,"uniequip_002_blkkgt",2767,1,12,1,27218,0,0,6,1], + ["p2",12,"op","chess_char_6_15_b",2,"uniequip_002_qiubai",3985.92,1,31,3,358064,3029,0,188,3], + ["p2",13,"op","chess_char_6_19_b",2,"uniequip_002_blkkgt",2767,1,12,0,5,0,0,0,1], ["p2",14,"op","chess_char_5_01_b",1,"uniequip_002_excu2",2314,1,10,0,0,0,0,0,0], - ["p2",15,"op","chess_char_6_17_b",2,"uniequip_002_nearl2",6215.89,1,46,0,293226,0,0,85,4], - ["p2",16,"op","chess_char_6_12_b",1,"uniequip_002_rosmon",3117.74,1,16,2,189809,0,0,74,4], - ["p2",17,"op","chess_char_6_13_b",1,"uniequip_002_angel2",2392.2,1,20,3,1205701,5257,423,365,6], - ["p2",18,"op","chess_char_6_14_b",2,"uniequip_002_lumen",2050.92,1,22,0,0,0,2846,42,1], - ["p2",19,"op","chess_char_6_16_b",2,"uniequip_002_halo2",1554.12,1,25,0,87945,932,0,163,4], - ["p2",20,"op","chess_char_6_18_b",2,"uniequip_002_whitw2",1541,1,6,3,182528,0,0,13,3], - ["p2",21,"op","chess_char_6_20_b",2,"uniequip_002_agoat2",1631.88,1,18,0,0,0,13059,42,2], + ["p2",15,"op","chess_char_6_17_b",2,"uniequip_002_nearl2",6215.89,1,48,0,280519,0,0,81,4], + ["p2",16,"op","chess_char_6_12_b",1,"uniequip_002_rosmon",3117.74,1,16,2,186844,0,0,74,4], + ["p2",17,"op","chess_char_6_13_b",1,"uniequip_002_angel2",2392.2,1,20,4,1216961,5257,494,363,6], + ["p2",18,"op","chess_char_6_14_b",2,"uniequip_002_lumen",2050.92,1,22,0,0,0,2448,44,1], + ["p2",19,"op","chess_char_6_16_b",2,"uniequip_002_halo2",1554.12,1,25,0,86149,981,0,163,4], + ["p2",20,"op","chess_char_6_18_b",2,"uniequip_002_whitw2",1541,1,6,4,173602,0,0,5,3], + ["p2",21,"op","chess_char_6_20_b",2,"uniequip_002_agoat2",1631.88,1,18,0,0,0,13890,48,2], ["-","~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], ["-","~4","device","trap_1105_accrate",null,null,100,1,4,0,0,0,0,0,null], - ["p1","~1","token","token_10030_mlyss_wtrman",null,null,0,0,24,0,8947,1880,0,18,1], - ["p1","~2","token","token_10030_mlyss_wtrman",null,null,860.02,0,41,0,674,1020,0,1,null], - ["p1","~1","token","token_10017_skadi2_dedant",null,null,100,0,48,0,0,0,0,0,null], - ["p2","~1","token","token_10019_nearl2_sword",null,null,6480,0,50,0,9120,0,0,0,null], + ["p1","~1","token","token_10030_mlyss_wtrman",null,null,1557.36,1,24,0,42452,0,0,90,1], + ["p1","~2","token","token_10030_mlyss_wtrman",null,null,1880.37,0,41,0,0,0,0,0,null], + ["p1","~1","token","token_10017_skadi2_dedant",null,null,100,0,49,0,0,0,0,0,null], + ["p2","~1","token","token_10019_nearl2_sword",null,null,6480,0,50,0,0,0,0,0,null], ["p1","~2","token","token_10017_skadi2_dedant",null,null,130.4,0,53,0,0,0,0,0,null], - ["p2","~2","token","token_10019_nearl2_sword",null,null,6480,0,55,0,9120,0,0,0,null], - ["p1","~3","token","token_10017_skadi2_dedant",null,null,100,0,57,0,0,0,0,0,null], - ["p2","~3","token","token_10019_nearl2_sword",null,null,6480,1,60,0,9120,0,0,0,null] + ["p2","~2","token","token_10019_nearl2_sword",null,null,6480,0,55,0,0,0,0,0,null], + ["p1","~3","token","token_10017_skadi2_dedant",null,null,100,0,58,0,0,0,0,0,null], + ["p2","~3","token","token_10019_nearl2_sword",null,null,6480,1,60,0,0,0,0,0,null] ], "enemies": { "enemy_1197_sfshu_2": [2,2,0,0,28809], "enemy_1198_msfshu_2": [6,6,0,0,24], - "enemy_1209_sfden_2": [2,2,0,2901,154793], + "enemy_1209_sfden_2": [2,2,0,0,154793], "enemy_1210_msfden_2": [2,2,0,0,90], - "enemy_9021_acduml": [1,0,0,0,3854920], - "enemy_9023_acdums": [9,9,0,5690,90] + "enemy_9021_acduml": [1,1,0,0,4000000], + "enemy_9023_acdums": [9,9,0,6252,90] }, - "hooks": [1,58,6000,1444,1444,3043,3408,2091,22,32,68,62,368,46997,1655,3,22,0,1,22,0,2,0], - "events": {"atk":1595,"deploy":32,"die":32,"dmg":3120,"engage":15,"fx":1016,"heal":279,"skill":130,"spawn":55,"status":506}, + "hooks": [1,58,5986,1498,1498,3074,3446,2102,22,32,67,60,370,46778,1625,2,22,0,1,21,0,2,0], + "events": {"atk":1634,"deploy":32,"die":32,"dmg":3158,"engage":13,"fx":1025,"heal":282,"skill":127,"spawn":55,"status":511}, "fx": { - "airstrike": 121, - "appear": 1, + "airstrike": 130, "beam": 9, "blink": 3, - "bombard": 65, - "bombardShell": 65, + "bombard": 69, + "bombardShell": 69, "buff": 3, "burst": 2, "copy": 1, - "disappear": 1, - "displace": 3, + "displace": 1, "dp": 5, - "drone": 338, - "droneLock": 16, + "drone": 337, + "droneLock": 17, "drones": 3, - "explode": 132, + "explode": 133, "finale": 1, "firewall": 1, "garrisonGrant": 1, - "lightning": 73, - "link": 6, - "lock": 65, + "lightning": 76, + "link": 4, + "lock": 69, "lure": 3, "phase": 18, "radiantSword": 3, - "scorchBurst": 2, - "slash": 11, - "snow": 16, + "scorchBurst": 1, + "slash": 5, + "snow": 15, "split": 1, "steal": 6, "storm": 8, "summon": 14, "sunBurst": 3, "talent": 4, - "telegraph": 4, + "telegraph": 3, "tide": 5, - "wanted": 3 + "wanted": 2 }, - "snaps": "4c79f145 ebcda26d 068f0e59 260d03ab 4d134b3f 74d79bd3 96b17d7f aa35887e 6f63c25c 204e2f98 9ab4f3f0 f9ac2510 13eb96e3 7db1e342 0bf684aa 45383cbf d2edb802 be365f88 86b51adc d438ceb4" + "snaps": "4c79f145 ebcda26d 068f0e59 f77a47b8 1b3b0779 8e1af8f0 1007ce9e d942d746 2f1bb88d ece27d81 47c5042c 2a8ee6a5 39f34644 5c84574d dd568842 0b4a024a 5841ee36 0333ed5c df19d0bf fd9c01b0" }, "boss-boss_3-solo": { "about": "假想敌:管 solo pool 2000000: chess_char_5_08_b chess_char_5_11_b chess_char_5_05_b chess_char_5_06_b chess_char_5_13_b chess_char_5_07_b chess_char_5_02_b chess_char_5_03_b chess_char_5_10_b chess_char_5_12_b", diff --git a/test/golden/matches.json b/test/golden/matches.json index d7764abf..dbcd1589 100644 --- a/test/golden/matches.json +++ b/test/golden/matches.json @@ -1559,12 +1559,12 @@ }, { "round": 11, - "h_0": [1,18,12,0,2,5,8,"def68119",93,"arcaneShip 1/15, kazimierzShip 0/8, miraShip 1/0, preciShip 1/26, siracusaShip 1/18, soloShip 1/0, suntShip 1/0, swiftShip 1/34, yanShip 0/13",21,25,4,102], + "h_0": [1,19,12,0,2,5,8,"def68119",93,"arcaneShip 1/15, kazimierzShip 0/8, miraShip 1/0, preciShip 1/26, siracusaShip 1/18, soloShip 1/0, suntShip 1/0, swiftShip 1/34, yanShip 0/13",21,25,4,102], "ai_0": [1,23,13,0,0,5,8,"c0b11e3b",224,"arcaneShip 0/2, deputShip 1/30, kazimierzShip 0/8, lateranoShip 1/39, miraShip 1/4, preciShip 0/2, raidShip 0/2, sargonShip 0/2, siracusaShip 1/40, skillfulShip 1/52, suntShip 1/12, swiftShip 1/47",25,25,0,98] }, { "round": 12, - "h_0": [1,8,14,0,0,6,8,"13e7bef4",103,"arcaneShip 1/15, kazimierzShip 0/8, miraShip 1/0, preciShip 1/31, siracusaShip 1/18, suntShip 1/0, swiftShip 1/39, yanShip 0/13",18,31,13,117], + "h_0": [1,9,14,0,0,6,8,"13e7bef4",103,"arcaneShip 1/15, kazimierzShip 0/8, miraShip 1/0, preciShip 1/31, siracusaShip 1/18, suntShip 1/0, swiftShip 1/39, yanShip 0/13",18,31,13,117], "ai_0": [1,22,12,0,1,6,8,"defd2806",262,"arcaneShip 0/2, deputShip 1/32, kazimierzShip 0/8, lateranoShip 1/57, miraShip 1/6, preciShip 0/2, raidShip 0/2, sargonShip 0/2, siracusaShip 1/42, skillfulShip 1/62, suntShip 1/14, swiftShip 1/49",30,31,1,112] }, { @@ -1584,7 +1584,7 @@ "title": {"id":"comment_6","name":"挥金如土","picId":"comment_icon_6","text":"消耗资金最多"}, "trophies": 3, "reward": 280, - "stats": {"activatedLayers":0,"bossDamage":0,"dmgDealt":2083752,"gold":129,"itemsEquipped":15,"kills":231,"leaks":63,"lpLost":30,"merges":4,"perfectRounds":8,"refreshes":22}, + "stats": {"activatedLayers":0,"bossDamage":0,"dmgDealt":2083752,"gold":129,"itemsEquipped":15,"kills":231,"leaks":63,"lpLost":29,"merges":4,"perfectRounds":8,"refreshes":22}, "lineup": [], "bonds": ["arcaneShip 0/21","kazimierzShip 0/8","preciShip 0/42","raidShip 0/16","siracusaShip 0/30","swiftShip 0/44","yanShip 0/13"] }, @@ -1598,7 +1598,7 @@ "title": {"id":"comment_1","name":"卫戍之星","picId":"comment_icon_1","text":"对敌方领袖造成伤害最高(仅胜利时)"}, "trophies": 4, "reward": 400, - "stats": {"activatedLayers":336,"bossDamage":390000,"dmgDealt":2919224,"gold":141,"itemsEquipped":23,"kills":258,"leaks":41,"lpLost":11,"merges":8,"perfectRounds":11,"refreshes":27}, + "stats": {"activatedLayers":336,"bossDamage":390000,"dmgDealt":2919512,"gold":141,"itemsEquipped":23,"kills":259,"leaks":41,"lpLost":11,"merges":8,"perfectRounds":11,"refreshes":27}, "lineup": ["4_21_a@12,3","5_20_a@12,7","3_21_b@11,3[1_05_e_b+3_01_e_b]","6_05_a@11,8","3_01_b@10,3[1_01_e_a+6_05_e_a]","3_18_b@10,7[4_02_e_a+4_11_e_a]","4_16_b@10,8[4_10_e_a+4_04_e_a]","6_01_a@9,8[2_01_e_a+1_02_e_a]"], "bonds": ["arcaneShip 0/2","deputShip 0/32","kazimierzShip 0/8","lateranoShip 1/83","miraShip 1/12","preciShip 1/2","raidShip 0/2","sargonShip 0/2","siracusaShip 1/84","skillfulShip 1/74","suntShip 1/14","swiftShip 1/67"] } diff --git a/test/golden/roster.json b/test/golden/roster.json index b95f591e..86f3af4f 100644 --- a/test/golden/roster.json +++ b/test/golden/roster.json @@ -1129,14 +1129,14 @@ "round": 13, "seed": 264387324, "waveId": "act1autochess_h06", - "time": 195, - "ticks": 5850, + "time": 194.97, + "ticks": 5849, "reason": "cleared", "killed": 120, "total": 120, "unspawned": 0, "bossHpLeft": null, - "rngDraws": 549, + "rngDraws": 490, "errors": {"count":0,"kinds":[]}, "players": { "p1": { @@ -1146,64 +1146,62 @@ "leaks": 0, "leaksUncounted": 0, "coins": 12, - "dmg": 1634623, + "dmg": 1565127, "bossDmg": 0, - "heal": 33055, + "heal": 31396, "deaths": 1, - "layerGains": {"lateranoShip":8,"preciShip":8,"sargonShip":23} + "layerGains": {"lateranoShip":12,"preciShip":12,"sargonShip":26} } }, "units": [ - ["p1",1,"op","chess_char_6_03_b",2,"uniequip_002_yu",3430,1,3,0,0,0,1317,0,1], - ["p1",2,"op","chess_char_6_06_b",2,"uniequip_002_pepe",2939,1,6,0,0,0,0,0,0], - ["p1",3,"op","chess_char_6_07_b",2,"uniequip_002_siege2",3287.06,1,9,0,6177,1792,0,2,1], - ["p1",4,"op","chess_char_6_20_a",2,null,1311.22,1,4,0,0,0,22720,45,1], - ["p1",5,"op","chess_char_6_01_b",2,"uniequip_002_lemuen",1558.2,1,7,12,177469,0,0,23,5], - ["p1",6,"op","chess_char_6_02_b",2,"uniequip_002_sbell2",3981.98,1,10,8,54371,1751,197,11,2], - ["p1",7,"op","chess_char_6_04_b",2,"uniequip_002_skadi2",1693.88,1,13,4,48553,8129,105,0,5], - ["p1",8,"op","chess_char_6_05_b",2,"uniequip_002_pasngr",0,0,15,20,243670,11371,3228,39,7], - ["p1",9,"op","chess_char_6_08_b",2,"uniequip_002_reed2",1768.08,1,5,2,78780,0,2839,18,2], - ["p1",10,"op","chess_char_6_09_b",0,"uniequip_002_cello",1401.32,1,8,2,28299,0,0,12,13], - ["p1",11,"op","chess_char_6_11_b",2,"uniequip_002_mlyss",2006.58,1,11,18,153886,2952,641,152,5], - ["p1",12,"op","chess_char_6_12_b",1,"uniequip_002_rosmon",4214.73,1,14,48,795747,7667,1111,70,4], + ["p1",1,"op","chess_char_6_03_b",2,"uniequip_002_yu",4026.82,1,3,0,0,0,0,0,0], + ["p1",2,"op","chess_char_6_06_b",2,"uniequip_002_pepe",3450.39,1,6,0,0,0,0,0,0], + ["p1",3,"op","chess_char_6_07_b",2,"uniequip_002_siege2",3640.57,1,9,0,0,0,0,0,0], + ["p1",4,"op","chess_char_6_20_a",2,null,1452.24,1,4,0,0,0,22245,41,2], + ["p1",5,"op","chess_char_6_01_b",2,"uniequip_002_lemuen",1725.78,1,7,9,194843,0,0,18,5], + ["p1",6,"op","chess_char_6_02_b",2,"uniequip_002_sbell2",4202.34,1,10,4,49828,1259,566,10,2], + ["p1",7,"op","chess_char_6_04_b",2,"uniequip_002_skadi2",1876.05,1,13,2,30324,9629,331,0,4], + ["p1",8,"op","chess_char_6_05_b",2,"uniequip_002_pasngr",0,0,15,18,231847,10065,3224,37,7], + ["p1",9,"op","chess_char_6_08_b",2,"uniequip_002_reed2",1958.23,1,5,4,57431,0,1066,8,1], + ["p1",10,"op","chess_char_6_09_b",0,"uniequip_002_cello",1552.03,1,8,0,9057,0,0,6,6], + ["p1",11,"op","chess_char_6_11_b",2,"uniequip_002_mlyss",2222.38,1,11,20,158083,1500,152,153,4], + ["p1",12,"op","chess_char_6_12_b",1,"uniequip_002_rosmon",4659.3,1,14,55,762444,7150,658,73,4], ["p1",13,"token","token_10017_skadi2_dedant",null,null,100,0,16,0,0,0,0,0,null], - ["p1",14,"token","token_10030_mlyss_wtrman",null,null,0,0,12,3,14239,2620,0,38,1], + ["p1",14,"token","token_10030_mlyss_wtrman",null,null,2420.79,1,12,6,41576,2426,0,89,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], - ["p1","~1","token","token_10030_mlyss_wtrman",null,null,0,0,37,0,4916,2245,59,5,null], - ["p1","~2","token","token_10030_mlyss_wtrman",null,null,0,0,52,0,640,2810,389,5,null], + ["p1","~1","token","token_10030_mlyss_wtrman",null,null,0,0,37,0,5009,2915,729,10,null], + ["p1","~2","token","token_10030_mlyss_wtrman",null,null,0,0,52,0,512,2733,355,4,null], ["p1","~1","token","token_10017_skadi2_dedant",null,null,117.4,0,60,0,0,0,0,0,null], - ["p1","~3","token","token_10030_mlyss_wtrman",null,null,0,0,69,0,1028,2421,0,3,null], - ["p1","~1","token","token_10040_siege2_vlion",null,null,4240,0,100,0,475,0,0,1,null], + ["p1","~3","token","token_10030_mlyss_wtrman",null,null,1313.01,0,71,1,9719,1878,770,12,null], + ["p1","~4","token","token_10030_mlyss_wtrman",null,null,1405.84,0,100,1,5828,1493,713,12,null], ["p1","~2","token","token_10017_skadi2_dedant",null,null,100,0,102,0,0,0,0,0,null], - ["p1","~4","token","token_10030_mlyss_wtrman",null,null,2185.72,1,121,3,15883,0,0,30,1], - ["p1","~5","token","token_10030_mlyss_wtrman",null,null,2137.36,0,133,2,9943,498,449,12,null], - ["p1","~3","token","token_10017_skadi2_dedant",null,null,100,1,139,0,0,0,0,0,null], - ["p1","~6","token","token_10030_mlyss_wtrman",null,null,2062,1,150,0,548,0,0,1,null] + ["p1","~5","token","token_10030_mlyss_wtrman",null,null,1054.21,0,132,2,8626,1773,586,12,null], + ["p1","~3","token","token_10017_skadi2_dedant",null,null,117.4,1,138,0,0,0,0,0,null] ], "enemies": { "enemy_1005_yokai": [1,1,0,0,1327], "enemy_1009_lurker": [1,1,0,0,3650], "enemy_1023_jmage": [1,1,0,697,3650], - "enemy_1092_mdgint": [1,1,0,1156,165888], + "enemy_1092_mdgint": [1,1,0,1397,165888], "enemy_1166_dusbr": [1,1,0,0,4977], "enemy_1197_sfshu_2": [1,1,0,0,5557], "enemy_1198_msfshu_2": [3,3,0,0,12], "enemy_1210_msfden_2": [1,1,0,0,45], "enemy_1229_darmy": [27,27,0,0,188117], "enemy_1229_darmy_2": [57,57,0,0,520059], - "enemy_1275_dwlock_2": [21,21,0,18938,487711], - "enemy_1283_sgkill": [1,1,0,2701,19907], - "enemy_1364_spnaxe_2": [1,1,0,4260,135851], + "enemy_1275_dwlock_2": [21,21,0,27266,487711], + "enemy_1283_sgkill": [1,1,0,2984,19907], + "enemy_1364_spnaxe_2": [1,1,0,1086,66355], "enemy_1367_dseed": [2,2,0,0,9953], "enemy_1439_dslntf": [1,1,0,0,49766], "enemy_2002_bearmi_2": [1,1,0,0,18248], - "enemy_9008_acbunn": [1,1,0,9545,19907] + "enemy_9008_acbunn": [1,1,0,2434,19907] }, - "hooks": [1,148,5850,530,530,1385,1542,3782,127,132,48,43,27,29676,402,6,122,0,1,127,8,6,0], - "events": {"atk":636,"bounty":6,"deploy":24,"die":132,"dmg":1462,"engage":16,"fx":258,"heal":231,"layer":8,"skill":91,"spawn":148,"status":437}, - "fx": {"beam":5,"bombard":27,"bombardShell":27,"burst":6,"camouflage":2,"copy":2,"dp":5,"firewall":1,"garrison":6,"lightning":97,"link":1,"lock":27,"lure":2,"scorchBurst":11,"snow":12,"spGain":2,"split":5,"storm":7,"summon":3,"talent":1,"tide":5,"wanted":4}, - "snaps": "9cdb845f 1a3da81a b8f8fa18 18adb6a1 045ac790 92daeb67 b8964a05 72d66b06 7650d4e4 fac284c3 d14f07dd e93511be da2827c7 b87b3885 004babab e8850449 0fff5530 45bf7443 2e7fa788 2015799d" + "hooks": [1,146,5849,541,541,1343,1500,4125,125,131,36,32,37,24823,332,7,122,0,1,125,11,8,0], + "events": {"atk":632,"bounty":6,"deploy":22,"die":131,"dmg":1420,"engage":13,"fx":271,"heal":198,"layer":11,"skill":68,"spawn":146,"status":377}, + "fx": {"beam":6,"bombard":31,"bombardShell":31,"burst":8,"camouflage":2,"copy":1,"dp":4,"explode":1,"garrison":8,"lightning":98,"link":1,"lock":37,"lure":2,"scorchBurst":13,"snow":7,"split":5,"storm":7,"summon":2,"talent":1,"tide":4,"wanted":2}, + "snaps": "462bd251 0720b450 e6cc2e2b e9020690 49bdb529 f3e5da0b 9e37e4e7 1f15c3ae 4e3f15d4 2dfffd4f 241bcf3a f616e60d 30648a5c 32b08230 6b3af4f0 8f125452 d62f0b43 82439da4 0cca37a4 0eb827d6" }, "roster-019": { "about": "pass 0 band 7: chess_char_6_15_b chess_char_6_19_b chess_char_5_01_b chess_char_5_05_b chess_char_6_17_b chess_char_6_13_b chess_char_6_14_b chess_char_6_16_b chess_char_6_18_b chess_char_6_20_b chess_char_5_02_b chess_char_5_03_b", diff --git a/test/render/fxproj.test.js b/test/render/fxproj.test.js index f579d89a..f9ca7042 100644 --- a/test/render/fxproj.test.js +++ b/test/render/fxproj.test.js @@ -18,6 +18,7 @@ import * as SIM from '../../server/sim/constants.js'; import { installFakePixi, fakeViewCtx } from './fakepixi.js'; import { presetCamera } from '../../public/js/render/projection.js'; import { PROJ, HIT_TINT } from '../../public/js/render/style.js'; +import { UF } from '../../shared/constants.js'; let fake, FX, T; before(async () => { @@ -354,6 +355,23 @@ describe('蕾缪安 S3: lock, bombardShell, bombard', () => { assert.equal(fx.locks.length, 0); }); + test('a held S3 lock (fx `hold`) waits as long as her skill runs with nothing in range, then LOCK_T after it ends; an unheld lock still times out', () => { + // 0.2.0 community report: S3 no longer ends by itself with nothing in her range — it waits with its bullets and locks + const { fx, lem } = setup(); + lem.flags = UF.SKILL; // her S3 runs (the snapshot's skill bit) + fx.simFx('lock', 8, 10, { id: 21, src: 1, hold: 1 }); + fx.simFx('lock', 9, 11.5, { id: 23, src: 1 }); // an S2 aim lock: no hold + const [held, plain] = fx.locks; + run(fx, 20 / 2); // 20 game s at 2×, nothing new from her + assert.ok(fx.locks.includes(held) && held.out < 0, 'the held reticle stays while her skill waits'); + assert.ok(!fx.locks.includes(plain), 'the unheld one timed out after LOCK_T'); + lem.flags = 0; // the skill ended + run(fx, 4 / 2); + assert.ok(held.out < 0, 'LOCK_T counts from the skill end'); + run(fx, 1 / 2 + 0.3); + assert.equal(fx.locks.length, 0, 'gone LOCK_T after the skill ended (no shell came)'); + }); + test('a lock sticks to the enemy named in `id` even when its view is a little off the event spot', () => { const { fx, e1 } = setup(); fx.simFx('lock', 9, 10, { id: 21, src: 1 }); // e1 drawn at (8, 10): a fast walker between snapshots diff --git a/test/sim/feedback5-lemuen-s3-wait.test.js b/test/sim/feedback5-lemuen-s3-wait.test.js new file mode 100644 index 00000000..87ddf1bd --- /dev/null +++ b/test/sim/feedback5-lemuen-s3-wait.test.js @@ -0,0 +1,106 @@ +// Community report (0.2.0, relayed by the owner): 「蕾缪安3技能范围里没人好像会自动结束」 — 蕾缪安's S3 礼炮·强制追思 ended by +// itself when nothing was left in her range. The kit ended the skill at the first lock tick with no enemy to lock (once +// it had locked one), so the bombardment fell at once and the bullets left were thrown away. Official: an ammo skill +// ends when its bullets are spent ("攻击装有5发弹药,打完后结束(可随时停止技能)"); in the client data her S3 lock needs a +// target and only the spent bullets end the skill buff (lemuen_s_3 → the S3_End bombardment mode); the 卫戍协议 +// automation never stops a skill (PRTS 卫戍协议/帮助 技能操作: "通常不会自动关闭技能"). Now the skill waits — bullets and +// locks kept, the marks following their enemies — and locks the next enemy that comes into her range at once. +// +// Flat stage: 蕾缪安 on (10,3) faces right — her 3-9 range covers cols 3–7 on her row, 3–6 one row off, 3–5 two rows off. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.js'; + +const LEM = 'chess_char_6_01_a'; +const dummy = (key) => enemyRec({ key, hp: 1e7, speed: 0 }); +const DEFS = { + enemies: { + enemy_dummy: dummy('enemy_dummy'), enemy_far: dummy('enemy_far'), enemy_late: dummy('enemy_late'), + enemy_walker: enemyRec({ key: 'enemy_walker', hp: 1e7, speed: 1.5 }), + }, +}; + +/** 蕾缪安 with a full S3 at once (the harness has no battle-start operation cooldown); h.dmg / h.locks / h.shells logs. */ +function lemuen(enemies, opts = {}) { + const dmg = [], ends = []; + const h = makeBattle({ + defs: DEFS, seed: 7, timeLimit: 200, units: [{ chessId: LEM, row: 10, col: 3, carryState: { sp: 99 } }], enemies, + hooks: ['skillStart', 'skillEnd', 'ammoUsed'], ...opts, + setup(b) { + b.on('damaged', (c) => dmg.push({ src: c.source?.id ?? null, tgt: c.target.id, tags: c.dmg?.tags ?? [], t: b.time }), { priority: -999 }); + b.on('skillEnd', ({ unit, reason }) => { if (unit.defId === LEM) ends.push({ reason, t: b.time }); }); + }, + }); + const u = h.unit(LEM); + const fxOf = (kind) => h.eventsOf('fx').filter((e) => e[1] === kind).map((e) => ({ x: e[2], y: e[3], t: e[4]?.t, ...e[4] })); + return { h, u, dmg, ends, locks: () => fxOf('lock'), shells: () => fxOf('bombardShell'), bombs: () => dmg.filter((d) => d.src === u.id && d.tags.includes('bombard')) }; +} +const inRange = (h, u) => h.b.enemiesInKeys(u.rangeKeys, u, u.profile); + +test('the locked enemy dies and nothing else is in her range: S3 keeps running with its bullets and locks — no early bombardment', () => { + const { h, u, ends, locks, shells, bombs } = lemuen([{ key: 'enemy_dummy', pos: [10, 5] }, { key: 'enemy_far', pos: [10, 9] }]); + assert.ok(h.runUntil(() => u.skill.active, 5), 'S3 cast'); + const full = u.skill.ammoMax; + assert.ok(h.runUntil(() => u.skill.ammoLeft === full - 2, 5), 'two locks'); + h.b.kill(h.enemy('enemy_dummy')); + assert.equal(inRange(h, u).length, 0, 'nothing left in her range (enemy_far on (10,9) is out of it)'); + h.run(10); + assert.equal(u.skill.active, true, 'the skill waits (the report: it ended by itself)'); + assert.deepEqual(ends, [], 'no skill end'); + assert.equal(u.skill.ammoLeft, full - 2, 'the bullets left are kept'); + assert.equal(locks().length, 2, 'no new lock without a target'); + assert.equal(shells().length, 0, 'no shell before the skill ends'); + assert.equal(bombs().length, 0, 'no bombardment'); + assert.equal(u.skill.sp, 0, 'no SP while the ammo skill runs'); + assert.equal(u.mem.lemLocks.length, 2, 'the locks are kept'); + assert.ok(locks().every((l) => l.hold === 1), 'fx lock `hold`: the renderer keeps these reticles up while her skill runs'); + checkInvariants(h.b); +}); + +test('a locked walker leaves her range: the skill waits; the next enemy that comes is locked at once, and the spent bullets bomb every mark — the walker where it is now', () => { + const { h, u, ends, locks, shells, bombs, dmg } = lemuen([{ key: 'enemy_walker', route: { motion: 'WALK', start: [9, 5], end: [9, 10], checkpoints: [] } }]); + assert.ok(h.runUntil(() => u.skill.active, 5), 'S3 cast'); + const walker = h.enemy('enemy_walker'); + const full = u.skill.ammoMax; + assert.ok(h.runUntil(() => inRange(h, u).length === 0, 10), 'the walker walks out of her range'); + const left = u.skill.ammoLeft; + assert.ok(left > 0 && left < full, `locked it ${full - left} times, ${left} bullet(s) left`); + h.run(3); + assert.equal(u.skill.active, true, 'S3 waits with nothing in range'); + assert.equal(u.skill.ammoLeft, left, 'bullets kept'); + assert.ok(walker.alive && inRange(h, u).length === 0, 'the walker is still out of her range'); + // the next enemy in range is locked the moment it comes (her lock is ready), every 0.5 s after that + const late = h.spawn('enemy_late', { pos: [10, 6] }); + const t0 = h.b.time; + h.step(); + const first = locks().find((l) => l.id === late.id); + assert.ok(first, 'the newcomer is locked on the next tick'); + assert.ok(h.runUntil(() => !u.skill.active, 10), 'the skill ends once the bullets are spent'); + assert.deepEqual(ends.map((e) => e.reason), ['ammo']); + assert.equal(h.hooksOf('ammoUsed').filter((c) => c.unit === u).length, full, 'every bullet spent on a lock'); + assert.ok(h.b.time - t0 <= (left - 1) * u.def.skill.bb['attack@aim_interval'] + 0.1, 'the newcomer took the bullets left, 0.5 s apart'); + // the shells follow in lock order: the walker's marks first (it is followed out of her range), then the newcomer's + const walkerLocks = full - left; + h.run(0.3 * (full + 2)); + const s = shells(); + assert.equal(s.length, full, 'one shell per lock'); + const spread = u.def.skill.bb['attack@emit_offset'] + 0.01; + for (let i = 0; i < walkerLocks; i++) assert.ok(s[i].x > 6.5, `shell ${i} falls on the walker out of her range (x ${s[i].x.toFixed(2)})`); + for (let i = walkerLocks; i < full; i++) assert.ok(Math.abs(s[i].x - 6) <= spread && Math.abs(s[i].y - 10) <= spread, `shell ${i} on the newcomer`); + assert.equal(bombs().filter((d) => d.tgt === walker.id).length, walkerLocks, 'the walker is hit by its own shells only'); + assert.equal(bombs().filter((d) => d.tgt === late.id).length, left, 'the newcomer by its own'); + assert.equal(dmg.filter((d) => d.src === u.id && !d.tags.includes('bombard') && d.t < ends[0].t).length, 0, 'no normal attack during S3'); + checkInvariants(h.b); +}); + +test('the last enemy dies while she waits: the battle ends cleared, nothing more to bomb, no error', () => { + const { h, u, shells } = lemuen([{ key: 'enemy_dummy', pos: [10, 5] }]); + assert.ok(h.runUntil(() => u.skill.active && u.mem.lemLocks?.length === 2, 5), 'two locks'); + h.b.kill(h.enemy('enemy_dummy')); + assert.ok(h.runUntil(() => h.b.finished, 5), 'the battle ends'); + assert.equal(h.result().reason, 'cleared'); + assert.equal(shells().length, 0, 'no shell after the battle'); + assert.deepEqual(h.b.contentErrors ?? [], []); + checkInvariants(h.b); +});