From ef636f941ce2a16cd3f165484c98063a543a781d Mon Sep 17 00:00:00 2001 From: sganggs Date: Sat, 3 Oct 2026 23:31:44 +0800 Subject: [PATCH] =?UTF-8?q?feedback2=20WH:=20=E8=8D=92=E8=8A=9C=E6=8B=89?= =?UTF-8?q?=E6=99=AE=E5=85=B0=E5=BE=B7=20S3's=20drones=20attack=20the=20en?= =?UTF-8?q?emy=20they=20reach=20like=20normal=20drones=20(PRTS=20flight:?= =?UTF-8?q?=20spread=201.3=20s,=20chase=202.0=20to=204.0,=20own=20funnel?= =?UTF-8?q?=20ramp,=20neither=20attack=20nor=20skill=20damage);=20?= =?UTF-8?q?=E4=BD=99=20S2=20teleports=20leaders=20that=20are=20not=20?= =?UTF-8?q?=E8=87=AA=E7=BC=9A=20and=20shows=20its=20pull=20effect=20only?= =?UTF-8?q?=20when=20it=20pulls=20(GitHub=20#32=20items=207=20and=201)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/sim/content/kits/tier6.js | 175 ++++++++++++---- test/content/kits_t6.test.js | 4 +- test/sim/feedback2-whitw2-yu.test.js | 286 +++++++++++++++++++++++++++ 3 files changed, 424 insertions(+), 41 deletions(-) create mode 100644 test/sim/feedback2-whitw2-yu.test.js diff --git a/server/sim/content/kits/tier6.js b/server/sim/content/kits/tier6.js index 8a99b390..1a51748e 100644 --- a/server/sim/content/kits/tier6.js +++ b/server/sim/content/kits/tier6.js @@ -51,9 +51,12 @@ // 6_16 溯光星源 link transfers the pre-mitigation arts amount × share to the other locked target(s); 能源解析 = the // 脆弱 status (同名效果取最高). // 6_17 耀骑士临光 "上一名部署干员" = the op of the same owner deployed right before her (deploy order). -// 6_18 荒芜拉普兰德 S3 drones are virtual (fx events), move at projectile_move_speed tiles/s, she stops attacking -// while they roam; 头狼 stage 2 "特殊能力失效" = silence; stage 3 = +1 drone (normal attacks hit once -// more, S3 releases one more drone). Base drone count 1. +// 6_18 荒芜拉普兰德 S3 drones are virtual (fx events) flying PRTS's 技能流程 (spread attack@times s, chase 2.0 → 4.0 +// tiles/s; the turn rate is not modelled: straight at the target); every drone is out, so she makes no +// normal attack herself, while each drone on its target attacks like a normal drone (her attack +// interval, ATK × its own funnel ramp; neither attack nor skill damage — PRTS 备注); 头狼 stage 2 +// "特殊能力失效" = silence; stage 3 = +1 drone (normal attacks hit once more; S3 releases one more drone, +// mid-skill too). Base drone count 1. // 6_19 锏 10 slashes every d_hit_interval, pulls every p_hit_interval, final blow (skill range) at the end; // S3 slashes and pulls air units too (PRTS 备注 "可对空"; a 静态刚体 — every drone of the mode — is hit // but stays put: Battle._displaceable). @@ -72,7 +75,8 @@ // any enemy on the field wasted both charges); the snow spreads along her facing line (≤ trig_cnt tiles); // S2 "目标点变为冻结状态" = the frozen 保护目标 token (icetgt, one at a time) on the tile reaching max snow, whose // snow is used up; spreads go to the thinnest 4-neighbour; the 20 % DoT ticks once per second. -// 6_03 S2 teleport = the ground-reachable (grid path) enemies of the skill grid moved onto his tile. +// 6_03 S2 teleport = the ground-reachable (grid path) enemies of the skill grid moved onto his tile, in the cast's +// tick (PRTS: 0.13 s after the damage), leaders included unless 自缚. // 6_04 S1 transfer: the ally takes ×(1 − share), she takes the rest as true damage from the attacker; module 新生代 // "30点伤害减免" = +30 effective DEF (physical) / −30 ÷ (1 − RES) before mitigation (arts). // 6_06 S2 the ASPD stacks last the whole battle; S1 "异常状态时可以释放" = cleanse + cast (tick check). @@ -106,7 +110,7 @@ import { absoluteRangeKeys, sortEnemyTargets, canTargetEnemy, aggroCmp } from '. import { aggregateMods } from '../../buffs.js'; import { COLS, ROWS, PULL_STOP_RADIUS, CHAIN_RADIUS } from '../../constants.js'; import { rotateOffset } from '../../dir.js'; -import { bodyDist, bodyInKeys, bodyInRadius, bodyKeys } from '../../body.js'; +import { bodyDist, bodyInKeys, bodyKeys } from '../../body.js'; import { hasHp } from '../../damage.js'; import { summonToken, TOKEN_IDS } from '../tokens.js'; @@ -199,12 +203,15 @@ function cleanseAbnormal(battle, u) { } /** * "传送至自身位置": a ground enemy that can reach `unit`'s tile on the ground grid is moved onto it (unblocked, its - * route re-planned from there — the engine then blocks it on that tile when capacity allows). Flyers / bosses stay, and - * so does a 自缚 unit (flag `selfBound`, e.g. 守墓石像's 转换模式): PRTS 余 S2 备注 "处于消失状态的/持有自缚的单位不视为可达 - * 目标" (束缚 alone does not exempt it, so not `noMove`). + * route re-planned from there — the engine then blocks it on that tile when capacity allows). Flyers stay, and so does a + * 自缚 unit (flag `selfBound`: 守墓石像's 转换模式, the 7 huge leaders, the 胄 parts' anchor): PRTS 余 S2 备注 "处于消失状态的/ + * 持有自缚的单位不视为可达目标" (束缚 alone does not exempt it, so not `noMove`). Being a leader is no exemption: PRTS gives + * 卢西恩 and 假想敌:铳 (both sections) 传送抗性 无 — they are teleported like any ground enemy and walk on from his tile (a + * patrol keeps looping: content/bosses.js patrolLoop). 静态刚体 forbids physical movement, not a teleport (the 备注 names + * only 消失 / 自缚 / 免疫传送); every 静态刚体 of the mode flies or is 自缚 anyway. */ function teleportEnemy(battle, unit, e) { - if (!e || !e.alive || e.isFlying || e.isBoss || e.s.flags.selfBound) return false; + if (!e || !e.alive || e.isFlying || e.s.flags.selfBound) return false; const r = unit.tileR, c = unit.tileC; if (!battle.grid.groundPassable(r, c)) return false; const er = Math.round(e.y), ec = Math.round(e.x); @@ -812,8 +819,10 @@ function yu(bb, chess, def) { // ep_damage_ratio × ATK 灼燃损伤 on the attacker (install below) skchr_yu_1: { kind: 'duration', mods: { hpPct: num(bb.max_hp), defPct: num(bb.def) } }, // S2 厚礼上宾: atk_scale × ATK arts on every enemy of the skill range + the ground-reachable ones teleported onto his - // tile; block +block_cnt, HP / ATK +, normal attacks deal arts damage. The burst hits air units too [ASSUMED: no 对空 - // note on PRTS]; the teleport takes ground units only ("地面可达目标") + // tile (leaders too, unless 自缚: teleportEnemy); block +block_cnt, HP / ATK +, normal attacks deal arts damage. The + // burst hits air units too [ASSUMED: no 对空 note on PRTS]; the teleport takes ground units only ("地面可达目标"). The + // 'pull' fx only when someone was teleported (each one also gets its own 'teleport' fx). PRTS's 0.13 s between the + // damage and the teleport is not modelled (same tick). skchr_yu_2: { kind: 'duration', mods: { hpPct: num(bb.max_hp), atkPct: num(bb.atk), blockCnt: num(bb.block_cnt) }, @@ -824,7 +833,7 @@ function yu(bb, chess, def) { for (const e of foes) battle.dealDamage(unit, e, { amount: unit.s.atk * num(bb.atk_scale, 1), type: 'arts', isSkill: true, tags: ['skill'] }); let n = 0; for (const e of foes) if (teleportEnemy(battle, unit, e)) n++; - battle.fx('pull', { x: unit.x, y: unit.y, id: unit.id, n }); + if (n > 0) battle.fx('pull', { x: unit.x, y: unit.y, id: unit.id, n }); }, }, }; @@ -2754,14 +2763,109 @@ function nearl2(bb, chess, def) { // ------------------------------------------------------------------------------------------------------------------ // 荒芜拉普兰德 chess_char_6_18 (驭械术师) — S3 终幕·浩劫; 头狼; 叙拉古的荣幸 +/** + * S3 终幕·浩劫's drone flight — PRTS 荒芜拉普兰德 S3 备注 "技能流程": ① for `attack@times` (1.3) s after the cast, or after + * a drone is added ("补充浮游单元"), the drones spread evenly outward from her, one along her facing ("散开的方向始终包括 + * 自身的朝向"): 初速度 0.1, 加速度 1.9, 最大速度 2.0; ② then each picks the target nearest to itself, ties nearest to her + * ("距离自身最近>距离本体最近"), anywhere on the field, and flies at it: 初速度 2.0, 加速度 1.0, 最大速度 4.0, restarting + * at 2.0 whenever its target leaves or turns unselectable on the way; ③ once there it stays on the target and attacks it + * like a normal drone ("此状态下的攻击行为同正常浮游单元"); when that target leaves / turns unselectable it reappears at a + * random point of the 1.5-side square around it and picks again (②). With no selectable target it circles (radius 0.9, + * 1.0 tiles/s, counter-clockwise) with its heading as the tangent, the circle on its left. Speeds in tiles/s. + */ +const WHITW2_SPREAD = Object.freeze({ v0: 0.1, acc: 1.9, max: 2 }); +const WHITW2_CHASE = Object.freeze({ v0: 2, acc: 1, max: 4 }); +const WHITW2_REAPPEAR_SIDE = 1.5; +const WHITW2_ORBIT = Object.freeze({ r: 0.9, v: 1 }); + function whitw2(bb, chess, def) { const t0 = tbb(def, 0), t1 = tbb(def, 1); const sid = selectedSkill(chess, def); - const speed = Math.max(0.1, num(bb['attack@projectile_move_speed'], 1)), R = num(bb['attack@range_radius'], 0.9); + const spreadTime = Math.max(0, num(bb['attack@times'], 1.3)), R = num(bb['attack@range_radius'], 0.9); const dmgScale = num(bb['attack@magic_atk_scale'], 1), fear = num(bb['attack@fear']), slow = num(bb['attack@move_speed']); // "非移动敌人": blocked, or not walking (stunned, bound, asleep, waiting, speed 0 …) const still = (e) => !!e.blockedBy || e.moving === false || !(e.s.moveSpeed > 0) || !!(e.s.flags.stun || e.s.flags.freeze || e.s.flags.bind || e.s.flags.sleep || e.s.flags.noMove); const skillGridW = skillGridOf(def); + + // ---- S3 drones (virtual: positions in unit.mem.drones, fx events for the client) -------------------------------- + const droneCount = (unit) => 1 + Math.floor(num(bb['attack@cnt'])) + ((unit.mem.wolfStage || 0) >= 3 ? 1 : 0); + // ① `k` drones leave her evenly spread, the first along her facing (row 0 is the bottom row: angles in the (col, row) + // plane, counter-clockwise) + const releaseDrones = (unit, k) => { + const a0 = Math.atan2(unit.fwd[0], unit.fwd[1]); + for (let i = 0; i < k; i++) { + const a = a0 + (2 * Math.PI * i) / k; + unit.mem.drones.push({ x: unit.x, y: unit.y, hx: Math.cos(a), hy: Math.sin(a), v: WHITW2_SPREAD.v0, age: 0, phase: 'spread', t: null, cd: 0, rampId: null, ramp: 0, orbit: null }); + } + }; + // speed v → v + acc·dt (capped); the distance covered at the mean of the two (exact under constant acceleration) + const accelerate = (d, lim, dt) => { const v1 = Math.min(lim.max, d.v + lim.acc * dt), s = ((d.v + v1) / 2) * dt; d.v = v1; return s; }; + // ② the selectable enemy nearest to the drone, ties broken by the one nearest to her + const pickTarget = (battle, unit, d, ok) => { + let best = null, bd = Infinity, bh = Infinity; + for (const e of battle.enemies) { + if (!ok(e)) continue; + const de = bodyDist(e, d.x, d.y), dh = bodyDist(e, unit.x, unit.y); + if (de < bd - 1e-9 || (de <= bd + 1e-9 && dh < bh - 1e-9)) { best = e; bd = de; bh = dh; } + } + return best; + }; + // nothing selectable: circle counter-clockwise on its left, its heading the tangent + const circle = (d, dt) => { + const { r, v } = WHITW2_ORBIT; + if (!d.orbit) d.orbit = { cx: d.x - d.hy * r, cy: d.y + d.hx * r }; + const a = Math.atan2(d.y - d.orbit.cy, d.x - d.orbit.cx) + (v / r) * dt; + d.x = d.orbit.cx + r * Math.cos(a); d.y = d.orbit.cy + r * Math.sin(a); + d.hx = -Math.sin(a); d.hy = Math.cos(a); + }; + // one tick of one drone: spread → (pick) → chase → on the target, attacking like a normal drone. Its attack clock runs + // all the time (one attack per interval of hers at most, whatever it chased in between). The turn rate (PRTS + // 转向速度 1/6 per frame = attack@projectile_turn_speed × 1/30 s) is not modelled: a chasing drone heads straight at + // its target [ASSUMED]. + const flyDrone = (battle, unit, d, dt, ok) => { + d.cd = Math.max(0, d.cd - dt); + if (d.phase === 'spread') { + const s = accelerate(d, WHITW2_SPREAD, dt); + d.x += d.hx * s; d.y += d.hy * s; + d.age += dt; + if (d.age + 1e-9 < spreadTime) return; + d.phase = 'seek'; + } + if (d.phase === 'lock' && !ok(d.t)) { + // ③ its target left / is no longer selectable: it reappears at a random point of the square around that spot + const h = WHITW2_REAPPEAR_SIDE / 2; + d.x = d.t.x + battle.rng.range(-h, h); + d.y = d.t.y + battle.rng.range(-h, h); + d.t = null; d.phase = 'seek'; + } else if (d.phase === 'chase' && !ok(d.t)) { d.t = null; d.phase = 'seek'; } + if (d.phase === 'seek') { + const t = pickTarget(battle, unit, d, ok); + if (!t) { circle(d, dt); return; } + d.t = t; d.phase = 'chase'; d.v = WHITW2_CHASE.v0; d.orbit = null; + battle.fx('droneLock', { x: t.x, y: t.y, id: t.id, src: unit.id }); // PRTS: the red wolf-eye mark over the target + } + const t = d.t; + if (d.phase === 'chase') { + const s = accelerate(d, WHITW2_CHASE, dt); + if (bodyDist(t, d.x, d.y) > s + 1e-9) { + const dx = t.x - d.x, dy = t.y - d.y, L = Math.hypot(dx, dy); + if (L > 1e-9) { d.hx = dx / L; d.hy = dy / L; } + d.x += d.hx * s; d.y += d.hy * s; + return; + } + d.phase = 'lock'; // reached: "追上时使目标恐惧…并锁定其攻击" + if (fear > 0) battle.applyStatus(t, 'fear', { duration: fear, source: unit }); + } + d.x = t.x; d.y = t.y; + if (d.cd > 1e-9 || !ok(t)) return; + d.cd = unit.s.interval; + const f = unit.profile?.funnel || { init: 0.2, delta: 0.15, max: 1.1 }; + d.ramp = d.rampId === t.id ? Math.min(f.max, d.ramp + f.delta) : f.init; + d.rampId = t.id; + battle.fx('drone', { x: d.x, y: d.y, id: unit.id }); + battle.dealDamage(unit, t, { amount: unit.s.atk * unit.s.atkScaleMul * d.ramp, type: 'arts', tags: ['droneAttack'] }); + }; + const skills = { // S1 慵怠者悲鸣: passive 浮游单元+1 (trait: one more hit per attack); toggled on: ATK +atk and the drones lock a random // non-moving enemy anywhere on the field (re-locking when it moves or falls; install), else her range. The whole-field @@ -2836,43 +2940,33 @@ function whitw2(bb, chess, def) { }, { owner: unit }); } }, + // S3 终幕·浩劫: ATK +atk; 1 + attack@cnt drones (+1 from 头狼 stage 3) fly the PRTS 技能流程 (WHITW2_* above). Every + // drone is out, so she makes no normal attack of her own (`noAttack`); a drone on its target attacks it every attack + // interval of hers (her live ASPD; the first hit as it arrives) for ATK × its OWN funnel ramp — the trait's init, + // +delta per hit on the same target, the cap (头狼 stage 1 raises it), back to init on a new target (PRTS 分支特性信息 + // 驭械术师 "浮游单元攻击不同目标…时,上述的伤害立刻恢复至初始值"). That damage is arts and neither a normal attack nor + // skill damage (PRTS S3 备注 "该技能释放的浮游单元造成的伤害不属于普通攻击/技能直接伤害": no 'attack' hook, isAttack / + // isSkill false — the 叙拉古 6 assassin proc and the on-attack items skip it), and 缴械 does not stop it (PRTS 驭械术师 + // "通过技能释放的浮游单元…不受缴械类效果制约"). Around every drone (attack@range_radius): move speed attack@move_speed + // and, once per second, attack@magic_atk_scale × ATK arts (不叠加: one hit per enemy whatever the number of drones). skill: { kind: 'duration', mods: { atkPct: num(bb.atk) }, attack: { noAttack: true }, onStart({ battle, unit }) { - const n = 1 + Math.floor(num(bb['attack@cnt'])) + ((unit.mem.wolfStage || 0) >= 3 ? 1 : 0); - unit.mem.drones = Array.from({ length: n }, () => ({ x: unit.x, y: unit.y, t: null, locked: null })); + unit.mem.drones = []; unit.mem.droneAcc = 0; - battle.fx('drones', { x: unit.x, y: unit.y, id: unit.id, n }); + releaseDrones(unit, droneCount(unit)); + battle.fx('drones', { x: unit.x, y: unit.y, id: unit.id, n: unit.mem.drones.length }); }, onTick({ battle, unit, dt }) { const D = unit.mem.drones; if (!D) return; + // 头狼 stage 3 reached while the skill runs: the extra drone is released like the others (PRTS ① "补充浮游单元") + const add = droneCount(unit) - D.length; + if (add > 0) { releaseDrones(unit, add); battle.fx('drones', { x: unit.x, y: unit.y, id: unit.id, n: add }); } const ok = (e) => e && e.alive && !e.hidden && canTargetEnemy(unit, e, ANY); - const taken = new Set(D.filter((d) => ok(d.t)).map((d) => d.t)); - for (const d of D) { - if (!ok(d.t)) { - let best = null, bd = Infinity, bestFree = null, bfd = Infinity; - for (const e of battle.enemies) { - if (!ok(e)) continue; - const dist = bodyDist(e, d.x, d.y); - if (dist < bd - 1e-9) { bd = dist; best = e; } - if (!taken.has(e) && dist < bfd - 1e-9) { bfd = dist; bestFree = e; } - } - d.t = bestFree ?? best; - d.locked = null; - if (d.t) taken.add(d.t); - } - if (!d.t) continue; - const dx = d.t.x - d.x, dy = d.t.y - d.y, dist = Math.hypot(dx, dy), step = speed * dt; - if (dist <= step) { d.x = d.t.x; d.y = d.t.y; } else { d.x += (dx / dist) * step; d.y += (dy / dist) * step; } - if (d.locked !== d.t && bodyInRadius(d.t, d.x, d.y, R)) { - d.locked = d.t; - if (fear > 0) battle.applyStatus(d.t, 'fear', { duration: fear, source: unit }); - battle.fx('droneLock', { x: d.x, y: d.y, id: d.t.id, src: unit.id }); - } - } + for (const d of D) flyDrone(battle, unit, d, dt, ok); const near = new Set(); for (const d of D) for (const e of battle.foesInRadius(d.x, d.y, R)) if (ok(e)) near.add(e); if (slow) for (const e of near) battle.addBuff(e, { key: 'whitw2:slow', duration: 0.2, refresh: 'replace', mods: { moveMul: Math.max(0, 1 + slow) }, source: unit }); @@ -2880,7 +2974,8 @@ function whitw2(bb, chess, def) { if (unit.mem.droneAcc + 1e-9 >= 1) { unit.mem.droneAcc -= 1; for (const e of near) if (e.alive) battle.dealDamage(unit, e, { amount: unit.s.atk * dmgScale, type: 'arts', isSkill: true, tags: ['skill', 'drone'] }); - for (const d of D) battle.fx('drone', { x: d.x, y: d.y, id: unit.id }); + // (a drone on its target already pulses with each of its attacks) + for (const d of D) if (d.phase !== 'lock') battle.fx('drone', { x: d.x, y: d.y, id: unit.id }); } }, onEnd({ unit }) { unit.mem.drones = null; }, diff --git a/test/content/kits_t6.test.js b/test/content/kits_t6.test.js index b5ca4e51..d7773f95 100644 --- a/test/content/kits_t6.test.js +++ b/test/content/kits_t6.test.js @@ -715,9 +715,11 @@ test('耀骑士临光: S3 summons 耀阳 (90 % true + stun 3 s around it), true }); test('荒芜拉普兰德: S3 drones chase enemies anywhere, fear on catch, 100 % ATK arts per second around them', () => { + // (one enemy, far outside her range: every drone picks the enemy nearest to itself — PRTS S3 备注 ② — so a second, + // nearer enemy would draw all of them; the full flight is in test/sim/feedback2-whitw2-yu.test.js) const h = battle({ units: [{ chessId: 'chess_char_6_18_a', row: 10, col: 3, carryState: { sp: 68 } }], - enemies: [{ key: 'enemy_dummy', pos: [10, 4] }, { key: 'enemy_dummy2', pos: [12, 9] }], + enemies: [{ key: 'enemy_dummy2', pos: [12, 9] }], }); const u = h.unit('chess_char_6_18_a'); const bb = u.def.skill.bb; diff --git a/test/sim/feedback2-whitw2-yu.test.js b/test/sim/feedback2-whitw2-yu.test.js new file mode 100644 index 00000000..a8a95f73 --- /dev/null +++ b/test/sim/feedback2-whitw2-yu.test.js @@ -0,0 +1,286 @@ +// GitHub issue #32 (after v0.1.1), items 7 and 1 — DESIGN §22.9 / §22.10. +// - Item 7 「易拉三技能浮游单元无法造成伤害,只能造成三技能自带dot伤害」: 荒芜拉普兰德 S3 终幕·浩劫. PRTS 荒芜拉普兰德 S3 备注 +// "技能流程": ① for attack@times (1.3) s the drones spread evenly from her, one along her facing (0.1 → 2.0 tiles/s at +// 1.9/s²); ② each then picks the target nearest to itself (ties: nearest to her) and chases it (2.0 → 4.0 at 1.0/s²); +// ③ on the target it attacks like a normal drone ("此状态下的攻击行为同正常浮游单元"), a target lost → it reappears in +// the 1.5-side square around it and picks again; nothing selectable → it circles (radius 0.9, 1.0 tiles/s, counter- +// clockwise). The drones' damage is neither a normal attack nor skill damage ("该技能释放的浮游单元造成的伤害不属于普通 +// 攻击/技能直接伤害"); every drone is out, so she makes no attack of her own. Before 0.1.2 the skill only dealt its +// once-per-second aura. +// - Item 1 「余二技能有伤害和特效,但不能拉怪」: 余 S2 厚礼上宾 teleports the ground-reachable enemies of x-1 onto his tile — +// leaders too unless 自缚 (PRTS 卢西恩 / 假想敌:铳 "传送抗性=无"; it skipped every leader), and its 'pull' fx plays only +// when someone was teleported (it played with nobody pulled). +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.js'; +import { TICK } from '../../server/sim/constants.js'; + +const BOTH = ['chess_char_6_18_a', 'chess_char_6_18_b']; +const dummy = (key, o = {}) => enemyRec({ key, hp: 1e7, speed: 0, ...o }); +const approx = (a, b, msg = '', eps = 1e-6) => assert.ok(Math.abs(a - b) <= eps * Math.max(1, Math.abs(b)), `${msg} ${a} ≈ ${b}`); +const done = (h) => { checkInvariants(h.b); assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0])); }; +/** 荒芜拉普兰德 on (10,4) facing RIGHT with S3 (her default) ready; `enemies` parked. */ +function wolf(id, enemies, o = {}) { + return makeBattle({ + seed: 3, timeLimit: 400, autoFinish: false, captureNoisy: true, hooks: ['damaged', 'attack', 'statusApplied', 'death', 'skillStart'], + defs: { enemies: Object.fromEntries(enemies.map((e) => [e.key, e.rec ?? dummy(e.key)])) }, + units: [{ chessId: id, row: 10, col: 4, dir: 'RIGHT', carryState: o.carry ?? { sp: 999 } }], + enemies: enemies.map((e) => ({ key: e.key, pos: e.pos })), ...o.battle, + }); +} +const droneHits = (h, u, f = () => true) => h.hooksOf('damaged').filter((c) => c.source === u && (c.dmg?.tags || []).includes('droneAttack') && f(c)); +/** Each drone's attacks, seen from its attack clock (reset to her interval when it attacks): [[{ t, ramp, target }]]. */ +function watchDrones(h, u) { + const log = []; + let last = []; + h.b.on('tick', () => { + const D = u.mem.drones || []; + D.forEach((d, i) => { + log[i] ??= []; + if (d.cd > (last[i] ?? 0) + 1e-9) log[i].push({ t: h.b.time, ramp: d.ramp, target: d.t }); + }); + last = D.map((d) => d.cd); + }); + return log; +} + +// ===================================================================================================================== +// item 7 — 荒芜拉普兰德 S3 终幕·浩劫 + +test('荒芜拉普兰德 S3 ① ②: the drones spread attack@times s (evenly, one along her facing, 0.1 → 2.0 tiles/s), then chase at 2.0 → 4.0 tiles/s', () => { + for (const id of BOTH) { + // 联防 field (21 columns): room for a chase long enough to reach 4.0 + const h = wolf(id, [{ key: 'far', pos: [10, 17] }], { battle: { kind: 'unite' } }); + const u = h.unit(id), bb = u.def.skill.bb; + assert.ok(h.runUntil(() => u.skill.active, 5), 'S3 fires with an enemy anywhere on the field (GDGLOW_SKILL_2)'); + const t0 = h.b.time; + const D = u.mem.drones; + assert.equal(D.length, 1 + bb['attack@cnt'], '浮游单元+attack@cnt (one drone of her own)'); + for (let i = 0; i < D.length; i++) { + const a = (2 * Math.PI * i) / D.length; // RIGHT = +col; row 0 is the bottom row, so the angles run counter-clockwise + approx(D[i].hx, Math.cos(a), `drone ${i} heading x`, 1e-12); + approx(D[i].hy, Math.sin(a), `drone ${i} heading y`, 1e-12); + } + const ticks = Math.round(bb['attack@times'] / TICK); + assert.equal(ticks, 39, 'attack@times = 1.3 s (PRTS ① "1.3s内")'); + h.step(ticks - 1); + const t = (ticks - 1) * TICK; + const spread = t <= 1 ? 0.1 * t + 0.95 * t * t : 1.05 + 2 * (t - 1); // v = min(2.0, 0.1 + 1.9 t) + for (const d of D) { + assert.equal(d.phase, 'spread'); + approx(Math.hypot(d.x - u.x, d.y - u.y), spread, 'spread distance', 1e-9); + } + h.step(1); + const far = h.enemies()[0]; + for (const d of D) assert.ok(d.phase === 'chase' && d.t === far, 'then every drone chases the one enemy'); + const d0 = D[0]; + approx(d0.v, 2 + TICK, 'chase: 初速度 2.0, 加速度 1.0', 1e-9); + h.step(Math.round(2 / TICK)); + approx(d0.v, 4, '最大速度 4.0', 1e-12); + // drone 0 flies straight on: 1.65 tiles of spread, then 11.35 more to the enemy (2 s to reach 4.0 covering 6 tiles, + // 5.35 at 4.0) → it lands its first hit ≈ 1.3 + 2 + 1.3375 s after the cast + assert.ok(h.runUntil(() => droneHits(h, u).length > 0, 5)); + const first = droneHits(h, u)[0]; + assert.ok(Math.abs(first.t - t0 - 4.6375) <= 2 * TICK, `first drone hit at ${(first.t - t0).toFixed(3)} s`); + done(h); + } +}); + +test('荒芜拉普兰德 S3 ③: a drone on its target attacks every attack interval of hers for ATK × its own funnel ramp; she makes no attack; neither attack nor skill damage; 缴械 does not stop it', () => { + for (const id of BOTH) { + const h = wolf(id, [{ key: 'e', pos: [10, 6] }]); + const u = h.unit(id), bb = u.def.skill.bb; + const log = watchDrones(h, u); + assert.ok(h.runUntil(() => u.skill.active, 5)); + const t0 = h.b.time; + h.run(6); + h.b.applyStatus(u, 'disarm', { duration: 3, source: null }); + assert.ok(u.s.flags.disarm, 'disarmed'); + const td = h.b.time; + h.run(8); + assert.ok(u.skill.active); + const f = u.profile.funnel, e = h.enemies()[0]; + assert.equal(h.hooksOf('attack').filter((c) => c.attacker === u && c.t >= t0).length, 0, 'no normal attack of hers during S3'); + const hits = droneHits(h, u); + assert.ok(hits.length >= 3 * 8, `${hits.length} drone hits in 14 s`); + for (const c of hits) { + assert.equal(c.type, 'arts'); + assert.equal(c.target, e); + assert.ok(!c.dmg.isAttack && !c.dmg.isSkill, 'PRTS: 不属于普通攻击/技能直接伤害'); + } + const scale = (c) => c.amount / (u.s.atk * u.s.dmgDealtMul); + assert.equal(hits.filter((c) => Math.abs(scale(c) - f.init) < 1e-6).length, u.mem.drones.length, 'each drone starts its own ramp at init'); + assert.ok(hits.every((c) => scale(c) <= f.max + 1e-9) && hits.some((c) => Math.abs(scale(c) - f.max) < 1e-6), 'up to the trait cap'); + assert.ok(hits.some((c) => c.t > td && c.t < td + 3), '缴械 does not stop the drones'); + // per drone: init, +delta per hit (capped), one hit per attack interval of hers (rounded up to whole ticks) + const gap = Math.ceil(u.s.interval / TICK - 1e-9) * TICK; + assert.equal(log.length, 1 + bb['attack@cnt']); + for (const L of log) { + assert.ok(L.length >= 8); + L.forEach((a, k) => { + approx(a.ramp, Math.min(f.max, f.init + k * f.delta), `hit ${k} ramp`, 1e-9); + if (k) approx(a.t - L[k - 1].t, gap, `hit ${k} gap`, 1e-6); + }); + } + // the aura stays: attack@magic_atk_scale × ATK arts once per second on the enemies around the drones + const aura = h.hooksOf('damaged').filter((c) => c.source === u && (c.dmg?.tags || []).includes('drone')); + assert.ok(aura.length >= 10 && aura.every((c) => Math.abs(c.amount - u.s.atk * u.s.dmgDealtMul * bb['attack@magic_atk_scale']) < 1e-6)); + assert.ok(h.hooksOf('statusApplied').filter((c) => c.status === 'fear' && c.target === e).length >= 1, '恐惧 on the catch'); + done(h); + } +}); + +test('荒芜拉普兰德 S3: every drone picks the enemy nearest to itself; a dead target sends it to the 1.5 square around it, the next lock restarts its ramp; nothing left → it circles (0.9, 1 tile/s, counter-clockwise)', () => { + for (const id of BOTH) { + const h = wolf(id, [{ key: 'weak', pos: [10, 6], rec: dummy('weak', { hp: 900 }) }, { key: 'e', pos: [12, 9] }]); + const u = h.unit(id); + const log = watchDrones(h, u); + let deadAt = null, deadPos = null; + h.b.on('death', (c) => { if (c.unit.defId === 'enemy_weak' || c.unit.defId === 'weak') { deadAt = h.b.time; deadPos = { x: c.unit.x, y: c.unit.y }; } }); + assert.ok(h.runUntil(() => u.skill.active, 5)); + const D = u.mem.drones; + const weak = h.enemies().find((x) => /weak/.test(x.defId)), far = h.enemies().find((x) => !/weak/.test(x.defId)); + h.step(Math.round(u.def.skill.bb['attack@times'] / TICK)); + assert.ok(D.every((d) => d.t === weak), 'PRTS ②: the nearest to each drone — all three take the weak one (no spreading over free targets)'); + assert.ok(h.runUntil(() => deadAt != null, 20), 'the weak one falls'); + h.step(1); + const f = u.profile.funnel; + for (const d of D) { + assert.equal(d.t, far, 'then the remaining enemy'); + // reappeared in the square around the fallen target, then one chase step (< 4 tiles/s × 1 tick) + assert.ok(Math.max(Math.abs(d.x - deadPos.x), Math.abs(d.y - deadPos.y)) <= 0.75 + 4 * TICK + 1e-9, `drone at (${d.y.toFixed(2)}, ${d.x.toFixed(2)})`); + } + assert.ok(h.runUntil(() => log.every((L) => L.some((a) => a.target === far)), 20)); + for (const L of log) approx(L.find((a) => a.target === far).ramp, f.init, 'a new target: back to init', 1e-9); + // nothing selectable: circle on its left + h.b.dealDamage(null, far, { amount: 1e9, type: 'true' }); + h.step(2); + for (const d of D) { + assert.ok(d.orbit && d.phase === 'seek'); + const a0 = Math.atan2(d.y - d.orbit.cy, d.x - d.orbit.cx); + approx(Math.hypot(d.x - d.orbit.cx, d.y - d.orbit.cy), 0.9, 'radius', 1e-9); + h.step(3); + const a1 = Math.atan2(d.y - d.orbit.cy, d.x - d.orbit.cx); + const turn = ((a1 - a0) % (2 * Math.PI) + 3 * Math.PI) % (2 * Math.PI) - Math.PI; + approx(turn, (3 * TICK * 1) / 0.9, 'counter-clockwise at 1.0 tiles/s', 1e-9); + } + done(h); + } +}); + +test('荒芜拉普兰德 S3: 头狼 stage 3 reached while the skill runs releases one more drone (PRTS ① "补充浮游单元"), which spreads along her facing and attacks too', () => { + const id = 'chess_char_6_18_a'; + const h = wolf(id, [{ key: 'e', pos: [10, 6] }], { carry: {} }); + const u = h.unit(id), bb = u.def.skill.bb; + // hold her SP until 头狼 stage 2 (2 × interval s on the field), then cast + let armed = false; + h.b.on('tick', () => { + if (armed || !u.skill || u.skill.active) return; + if ((u.mem.wolfStage || 0) >= 2) { armed = true; u.skill.gainSp(u.skill.spCost, 'init', true); } else u.skill.sp = 0; + }); + const iv = u.def.talents[0].bb.interval; + assert.ok(h.runUntil(() => u.skill.active, 2 * iv + 3)); + assert.equal(u.mem.drones.length, 1 + bb['attack@cnt']); + assert.ok(h.runUntil(() => u.mem.wolfStage === 3, iv + 2)); + h.step(1); + assert.ok(u.skill.active, 'stage 3 inside the skill'); + const D = u.mem.drones; + assert.equal(D.length, 2 + bb['attack@cnt'], '数量+1'); + const nd = D.at(-1); + assert.equal(nd.phase, 'spread'); + approx(nd.hx, 1, 'along her facing', 1e-12); + assert.ok(Math.hypot(nd.x - u.x, nd.y - u.y) < 0.1, 'from her'); + const tn = h.b.time; + assert.ok(h.runUntil(() => nd.rampId != null, 10), 'the new drone reaches the enemy and attacks'); + assert.ok(droneHits(h, u, (c) => c.t >= tn).length >= 1); + done(h); +}); + +// ===================================================================================================================== +// item 1 — 余 S2 厚礼上宾 + +/** 余 on (10,5) with S2 (`sp` carried in); parked enemies (`[key, row, col, tag?]`). */ +function yuS2(chessId, foes, sp = 999) { + const hold = (r, c) => ({ motion: 'WALK', start: [r, c], end: [9, 2], checkpoints: [{ type: 'WAIT', time: 99 }] }); + const h = makeBattle({ + seed: 5, timeLimit: 120, autoFinish: false, + units: [{ chessId, row: 10, col: 5, dir: 'RIGHT', skillIndex: 1, carryState: { sp } }], + enemies: foes.map(([key, r, c, tag]) => ({ key, route: hold(r, c), time: 0, tag })), + }); + h.step(1); + return h; +} + +test('余 S2: a leader inside x-1 is teleported onto his tile like any ground enemy (PRTS 卢西恩 / 假想敌:铳 传送抗性 无); a 自缚 leader stays', () => { + for (const id of ['chess_char_6_03_a', 'chess_char_6_03_b']) { + // (S2 kept empty until the test's own hit: “萨米的意志”'s 冰凌 strikes 余 at once) + const h = yuS2(id, [['enemy_2016_csphtm', 10, 7, 'boss'], ['enemy_9017_achunt', 11, 6, 'boss'], ['enemy_9033_acdeer', 9, 4, 'boss']], 0); + const yu = h.unit(id); + assert.ok(!yu.skill.active && !yu.skill.ready); + const [lucien, gun, sami] = ['enemy_2016_csphtm', 'enemy_9017_achunt', 'enemy_9033_acdeer'].map((k) => h.b.enemies.find((e) => e.defId === k)); + assert.ok(lucien.isBoss && gun.isBoss && sami.isBoss, 'three leaders'); + assert.ok(sami.s.flags.selfBound && !lucien.s.flags.selfBound && !gun.s.flags.selfBound, '“萨米的意志” is 自缚 (content/bosses.js SELF_BOUND)'); + const samiAt = [sami.x, sami.y]; + const fx0 = h.eventsOf('fx').length; + yu.skill.gainSp(yu.skill.spCost, 'init', true); + h.b.dealDamage(gun, yu, { amount: 10, type: 'phys', isAttack: true }); + assert.ok(yu.skill.active && yu.skill.id === 'skchr_yu_2', 'S2 cast on the hit (TAKE_DAMAGE)'); + for (const e of [lucien, gun]) assert.deepEqual([e.y, e.x], [yu.tileR, yu.tileC], `${e.name} teleported onto 余`); + assert.deepEqual([sami.x, sami.y], samiAt, '自缚: not a reachable target (PRTS 余 S2 备注)'); + const fx = h.eventsOf('fx').slice(fx0); + assert.deepEqual(fx.filter((e) => e[1] === 'teleport').map((e) => e[4].id).sort(), [lucien.id, gun.id].sort()); + assert.deepEqual(fx.filter((e) => e[1] === 'pull').map((e) => e[4].n), [2], 'one pull fx: two teleported'); + h.step(1); + assert.equal(lucien.blockedBy, yu, '卢西恩 (blockable) is blocked on his tile; 铳 is 不可阻挡'); + assert.equal(gun.blockedBy, null); + done(h); + } +}); + +test('余 S2 with nobody to pull (the usual first hit: a ranged enemy outside x-1): damage-free cast, no teleport and no pull fx', () => { + // 深池暗影术师 (range 2.5) on (9,7) and a 萨卡兹枯朽前锋 on (10,8): both outside x-1 (|Δrow| + |Δcol| = 3) + const h = yuS2('chess_char_6_03_a', [['enemy_1168_dumage', 9, 7], ['enemy_1422_lrsldr', 10, 8]]); + const yu = h.unit('chess_char_6_03_a'); + const fx0 = h.eventsOf('fx').length; + assert.ok(h.runUntil(() => yu.skill.active, 10), 'the 深池暗影术师 hits him from outside x-1: S2 (TAKE_DAMAGE)'); + const fx = h.eventsOf('fx').slice(fx0).filter((e) => e[1] === 'teleport' || e[1] === 'pull'); + assert.deepEqual(fx, [], 'nobody inside x-1: no teleport, no pull fx'); + for (const e of h.b.enemies) assert.ok(!(Math.round(e.y) === yu.tileR && Math.round(e.x) === yu.tileC), `${e.name} stays`); + done(h); +}); + +test('余 S2 on the real leader rounds: 假想敌:铳 teleported onto 余 walks on to its next patrol checkpoint; 卢西恩 is blocked there, then resumes its loop', () => { + const W = JSON.parse(fs.readFileSync(new URL('../../data/waves.json', import.meta.url), 'utf8')); + const pool = (hp) => ({ hp, maxHp: hp, damage(pid, a) { this.hp = Math.max(0, this.hp - a); } }); + const X1 = [[2, 0], [1, -1], [1, 0], [1, 1], [0, -2], [0, -1], [0, 1], [0, 2], [-1, -1], [-1, 0], [-1, 1], [-2, 0]]; + for (const [tplId, key, board] of [['act1autochess_h07_02', 'enemy_9017_achunt', [11, 5]], ['act2autochess_h07_05', 'enemy_2016_csphtm', [10, 7]]]) { + const tpl = W[tplId]; + const h = makeBattle({ kind: 'boss', stageId: 'act2autochess_m01', waveTemplate: tpl, sharedBoss: pool(3e6), seed: 9, autoFinish: false, + units: [{ chessId: 'chess_char_6_03_a', row: board[0], col: board[1], dir: 'RIGHT', skillIndex: 1 }], setup(b) { b.enemyOverrides = tpl.overrides; } }); + h.step(1); + const yu = h.unit('chess_char_6_03_a'); + const L = () => h.b.enemies.find((e) => e.defId === key && e.alive); + const inX1 = (e) => X1.some(([dr, dc]) => Math.round(e.y) === yu.tileR + dr && Math.round(e.x) === yu.tileC + dc); + h.b.on('tick', () => { if (!yu.skill.active && !L()?.mem.yuTest) yu.skill.sp = 0; }); // keep S2 for the test's hit + assert.ok(h.runUntil(() => L() && inX1(L()), 90), `${key} walks into x-1`); + const e = L(); + assert.ok(e.isBoss && !e.s.flags.selfBound && e.route.legs.length > 1, 'a patrolling leader'); + e.mem.yuTest = true; + const leg0 = e.route.legIdx; + yu.skill.gainSp(yu.skill.spCost, 'init', true); + h.b.dealDamage(e, yu, { amount: 10, type: 'phys', isAttack: true }); + assert.ok(yu.skill.active, 'S2'); + assert.deepEqual([e.y, e.x], [yu.tileR, yu.tileC], 'teleported onto 余'); + assert.equal(e.route.pts, null, 'its path is re-planned from there'); + if (key === 'enemy_9017_achunt') { + assert.ok(h.runUntil(() => e.route.legIdx !== leg0, 25), '不可阻挡: walks on to its next patrol checkpoint'); + } else { + h.step(1); + assert.equal(e.blockedBy, yu, 'blocked by 余 (block +2)'); + assert.ok(h.runUntil(() => e.route.legIdx !== leg0 && !(Math.round(e.y) === yu.tileR && Math.round(e.x) === yu.tileC), 30), 'blinks past and resumes its loop'); + } + assert.ok(e.alive && !h.b.enemies.some((x) => x === e && x.leaked), 'never leaks'); + done(h); + } +});