diff --git a/docs/ASSETS.md b/docs/ASSETS.md index d823d6da..f0493f1a 100644 --- a/docs/ASSETS.md +++ b/docs/ASSETS.md @@ -291,7 +291,7 @@ Other renderer rules from research 07 §5.4–5.5: - **Emotes and 玩法说明 pages** (`public/js/data.js artUrls / nextArtUrl`, `ui/guide.js guideStage`): the local-client picture (`data/local-assets.json`) first, then the mirror copy (`ui['emoticon/…']`, `ui['guide/…']`), each tried in turn when one fails to load; when none is left — none listed, or every copy failed (for example data/assets.json lists the downloaded pages but the files are not on disk yet: a `git pull` and restart without setup) — the neutral emote glyph, and for a page the official tips text (`config.tips`). The rest of the local-client art (the 3D board, the official HUD sprites, module type icons, the two enemy models above) is not downloaded: the client looks it up in `data/local-assets.json` only (most of the HUD sprites are on the mirror too, DESIGN §22.5); docs/DEPLOY.md §6 lists what falls back without it. - **Tokens:** - - Without an avatar, use `chars[owner].avatar` with a 召唤物 badge, or `prof.battlecard.token`. + - Without an avatar, use `chars[owner].avatar` with a 召唤物 badge, or `prof.battlecard.token` — except 圣聆初雪's 保护目标(冻结状态) (PRTS 无头像; the frozen gate), drawn as a procedural ice diamond (`render/units.js ICE_TOKENS`). - Without a Spine, draw the avatar sprite with a bob tween. - `spineVariant` names the skin-variant model that stands in for the missing default model. - **Enemies without a spine** (for example `enemy_9016_acstmr`): draw `icon` in a diamond. Enemies with no manifest entry at all (`enemy_5601_entlec` 心烛): draw a procedural glyph. diff --git a/public/js/render/textures.js b/public/js/render/textures.js index 60fe2388..c525c0cc 100644 --- a/public/js/render/textures.js +++ b/public/js/render/textures.js @@ -1077,7 +1077,7 @@ const DIAMOND_MAX = 160; // ≈ 16 MB of 160×160 canvases at most (plus t * @param {{ enemy?: boolean, golden?: boolean }} [o] */ export function diamondTexture(key, img, color, o = {}) { - const k = `${key}|${img ? 1 : 0}|${color}|${o.golden ? 1 : 0}`; + const k = `${key}|${img ? 1 : 0}|${color}|${o.golden ? 1 : 0}|${o.ice ? 1 : 0}`; let tex = _diamonds.get(k); if (tex) { _diamonds.delete(k); _diamonds.set(k, tex); return tex; } const P = PIXI(); @@ -1095,6 +1095,18 @@ export function diamondTexture(key, img, color, o = {}) { const sc = Math.max((S - 20) / img.width, (S - 20) / img.height) * 1.02; const w = img.width * sc, hh = img.height * sc; try { c.drawImage(img, h - w / 2, h - hh / 2, w, hh); } catch { /* tainted/broken image */ } + } else if (o.ice) { + // 圣聆初雪's frozen gate (保护目标(冻结状态), PRTS 无头像, no model in the manifest): a snowflake on a frosty field + const g = c.createRadialGradient(h, h, 4, h, h, h); + g.addColorStop(0, 'rgba(205,240,255,0.95)'); g.addColorStop(1, 'rgba(0,0,0,0)'); + c.fillStyle = g; c.fillRect(0, 0, S, S); + c.strokeStyle = '#f2fbff'; c.lineWidth = 6; c.lineCap = 'round'; + for (let i = 0; i < 6; i++) { + const a = (i * Math.PI) / 3, x1 = h + Math.cos(a) * 34, y1 = h + Math.sin(a) * 34; + c.beginPath(); c.moveTo(h, h); c.lineTo(x1, y1); c.stroke(); + const bx = h + Math.cos(a) * 20, by = h + Math.sin(a) * 20; + for (const s of [-1, 1]) { c.beginPath(); c.moveTo(bx, by); c.lineTo(bx + Math.cos(a + s * 0.8) * 11, by + Math.sin(a + s * 0.8) * 11); c.stroke(); } + } } else { // procedural glyph (e.g. 心烛 enemy_5601_entlec has no art anywhere) const g = c.createRadialGradient(h, h, 4, h, h, h); diff --git a/public/js/render/units.js b/public/js/render/units.js index 739c3988..a63b050d 100644 --- a/public/js/render/units.js +++ b/public/js/render/units.js @@ -101,6 +101,11 @@ export function dieClipDur(entry) { /** World step (x = col, y = row) of a direction. */ export const DIR_STEP = Object.freeze({ UP: [0, 1], RIGHT: [1, 0], DOWN: [0, -1], LEFT: [-1, 0] }); const nowMs = () => (globalThis.performance ? globalThis.performance.now() : Date.now()); +/** Units with no art in the game data drawn as an ice diamond: 圣聆初雪 S2's frozen protection point (保护目标(冻结状态), PRTS + * 无头像; data/assets.json has no avatar or model for it, so the token fallback showed 圣聆初雪's own face) — the marker of a + * frozen gate. */ +const ICE_TOKENS = new Set(['token_10058_sbell2_icetgt']); +const ICE_FRAME = 0x9fe6ff; /** How long a view waits for its avatar before showing the image-less placeholder diamond. */ const PIC_WAIT_MS = 400; /** @@ -391,6 +396,7 @@ export class UnitView { _frameColor() { if (this.isEnemy) return this.isBoss ? ENEMY_FRAME.boss : this.tier >= 2 ? ENEMY_FRAME.elite : ENEMY_FRAME.normal; + if (ICE_TOKENS.has(this.info.defId)) return ICE_FRAME; if (this.golden) return 0xffc600; return TIER_COLORS[this.tier] || TIER_COLORS[1]; } @@ -401,7 +407,8 @@ export class UnitView { // is missing or still loading after PIC_WAIT_MS). _loadPicture() { const a = this.ctx.assets; - const url = a && (a.picture ? a.picture(this.info.avatar) || a.picture(this.info.defId) || a.picture(this.info.spine) : null); + // (an ICE_TOKENS unit takes no picture: the token fallback would be its owner's face — assets.js tokenAvatarUrl) + const url = ICE_TOKENS.has(this.info.defId) ? null : a && (a.picture ? a.picture(this.info.avatar) || a.picture(this.info.defId) || a.picture(this.info.spine) : null); this._pic = { key: String(this.info.avatar || this.info.defId || 'unknown'), color: this._frameColor(), img: null, state: 'none', shown: null, t0: nowMs() }; if (!url || !a.image) return; const cached = typeof a.imageNow === 'function' ? a.imageNow(url) : null; @@ -420,7 +427,7 @@ export class UnitView { let want = pic.state === 'img' ? 'img' : 'placeholder'; if (pic.state === 'wait' && nowMs() - pic.t0 < PIC_WAIT_MS) want = null; if (!want || pic.shown === want) return; - this.fallback.texture = diamondTexture(pic.key, want === 'img' ? pic.img : null, pic.color, { enemy: this.isEnemy, golden: this.golden }); + this.fallback.texture = diamondTexture(pic.key, want === 'img' ? pic.img : null, pic.color, { enemy: this.isEnemy, golden: this.golden, ice: ICE_TOKENS.has(this.info.defId) }); pic.shown = want; } diff --git a/server/sim/content/enemies.js b/server/sim/content/enemies.js index b782968b..ee62250f 100644 --- a/server/sim/content/enemies.js +++ b/server/sim/content/enemies.js @@ -92,7 +92,7 @@ // hidden tabs (shared/protocol.js fxForm). // Custom hook: 'lpLoss' {amount, reason, source} — leader "扣除目标生命" effects; also summed into result.lpLoss. -import { TICK, MOVE_SCALE, ELEMENT, ATTACK_PAUSE, PROJECTILE_SPEEDS, ALLY_COLLIDER_RADIUS } from '../constants.js'; +import { TICK, MOVE_SCALE, ELEMENT, ATTACK_PAUSE, PROJECTILE_SPEEDS, ALLY_COLLIDER_RADIUS, COLS } from '../constants.js'; import { canTargetAlly, sortAllyTargets, aggroCmp, enemyStealthed, areaSelectable, auraSelectable } from '../targeting.js'; import { mitigate, periodicDamage } from '../damage.js'; @@ -141,8 +141,9 @@ const STEALTH_RESTORE_BY_KEY = Object.freeze({ }); /** 假想敌:再生 shield aura radius (PRTS 假想敌:再生 天赋 "进入此形态时,使半径1.8范围内的其他敌方单位(无视其可选性)获得5层…护盾"). */ const ACPUPP_AURA_RADIUS = 1.8; -/** 重弩突袭者 直击 reach along a row/column [ASSUMED]. */ -const CROSS_REACH = 6; +/** 重弩突袭者 直击 reach along a row/column [ASSUMED]; the skill's length and its charge before the bolt (PRTS 直击 "蓄力1.4s后 + * 向目标方向发射1支弩箭…※技能持续2.5s"). */ +const CROSS_REACH = 6, CROSS_SKILL = 2.5, CROSS_CHARGE = 1.4; /** 暴鸰 投弹: the target's tile and its 8 neighbours (PRTS "对目标及其周围八格的我方单位造成100%物理伤害"). */ const BOMB_REACH = 1; /** 暴鸰 投弹: the bomb leaves on the OnAttack event of the cast's Attack clip (the official battle prefab's Boomb ability: @@ -175,8 +176,32 @@ const BUGS_PER_CAST = 3; const TORTURER_RADIUS = 1; /** Barrel zone radius of 咸鳞汁推荐者 [ASSUMED]. */ const BARREL_RADIUS = 1.5; -/** "短时间" DEF-down of 碎骨's grenade [ASSUMED]. */ -const SKULSR_DEFDOWN_DUR = 3; +/** 碎骨's grenade (PRTS 碎骨 天赋 "未被阻挡时发射榴弹对目标及其周围八格的我方单位造成相当于攻击力26%的物理伤害,并令其在5秒内 + * 防御力下降50%"; the blackboard has only defdown.def −0.5): share of ATK on every unit hit, DEF-down duration (s). + * Until 0.1.3: the full attack on the target, 100 % on the 4 neighbours, 3 s [ASSUMED]. */ +const SKULSR_GRENADE_SCALE = 0.26, SKULSR_DEFDOWN_DUR = 5; +/** Splash radius (中点判定 around the target) of 烹泉 / 沏虹's attack (PRTS 天赋 "普通攻击对目标对及目标周围半径1.0范围内的所有 + * 我方单位造成法术普通伤害(无视迷彩,不可对空)") and of 集团军重型火炮's shell (PRTS "对目标半径1.0范围内的所有我方单位造成攻击力 + * 100%的物理伤害(此弹道会强制击中主目标,碰撞无视迷彩,不可对空)"). */ +const TEA_SPLASH_RADIUS = 1, SHELL_SPLASH_RADIUS = 1; +/** 集团军重型火炮's 【燃烧区域】: PRTS "碰撞半径1.5" with the source note "1.5倍可变半径" — ×1.5 on the blackboard's + * ProjectileBoomRange.attack@projectile_range (1). Until 0.1.3 the zone took the blackboard's 1. */ +const UACANN_ZONE_SCALE = 1.5; +/** 烹泉 / 沏虹 死亡爆炸 radius (PRTS 天赋 "死亡爆炸(爆炸半径1.25,造成攻击力100%法术溅射伤害并施加15s【烹泉减益】…)"; until + * 0.1.3 the attack range, 2). */ +const TEA_BOOM_RADIUS = 1.25; +/** “庞贝”'s self-blast while blocked (PRTS 天赋 "对<固定半径>半径1.4范围内的所有我方单位造成1000预计算的无途径法术溅射伤害(不可 + * 对空)"; the blackboard has only the damage and the 10 s; until 0.1.3: radius 1 [ASSUMED]). */ +const POMPEII_BLAST_RADIUS = 1.4; +/** “巨大的丑东西” first form: its unblocked ranged attack (PRTS 天赋 "未被阻挡时,可对半径2.5范围内的1名非飞行的我方单位进行远程攻击, + * 对目标及其周围8格内的所有我方单位造成攻击力100%的物理伤害" — the data's attack radius is 0, so it had no ranged attack until + * 0.1.3) and its self-destruct ("首次被击倒后进行持续10s的重生…:重生开始的2.17s后进行自爆,对半径3.0范围内所有我方单位造成攻击力 + * 150%的物理伤害和16s晕眩"; until 0.1.3: radius 2.5 at once [ASSUMED]). */ +const MCM_RANGE = 2.5, MCM_BOMB_DELAY = 2.17; +/** “复仇者” 【冲锋】 (PRTS 技能 "仅自身未被阻挡且存在符合条件的可选目标时可触发:选择3.0半径内位于自身下个检查点前的后续路径上(包含 + * 自身当前所在地块)的距离自身最近的我方单位…"): the search radius. The blackboard's range_radius is 1.5; PRTS's figure is the + * one used. Until 0.1.3: any unit within 1.5, 隐匿 / 迷彩 ones included. */ +const RUSH_RADIUS = 3; /** W's C4 fuse and blast radius [ASSUMED]. */ const C4_FUSE = 1, C4_RADIUS = 1; /** Every Nth attack for "数次攻击后" abilities without an SP cost in the data [ASSUMED]; with a cost N = spCost + 1 @@ -185,13 +210,14 @@ const NTH_ATTACK = 3; export const nthOf = (s) => (s && s.sp > 0 ? s.sp + 1 : NTH_ATTACK); /** 鼠王 【唱沙】 cross reach and 【沙狱】 "及其周围" radius [ASSUMED]. */ const DRIFT_REACH = 1, SANDSTORM_RADIUS = 1; -/** 巨大的丑东西 self-destruct radius ("大范围晕眩") [ASSUMED]. */ -const MCM_BOMB_RADIUS = 2.5; +/** “巨大的丑东西” self-destruct radius (PRTS 天赋 "对半径3.0范围内所有我方单位…"; 2.5 [ASSUMED] until 0.1.3). */ +const MCM_BOMB_RADIUS = 3; /** 自在 【纬地经天】: tiles covered along the row and the column of the centre ("十字型") [ASSUMED]. */ const XI_CROSS_REACH = 2; -/** 扎罗: form-2 "远程" attack radius, 【远古威慑】 aura radius / ASPD (no numbers in the data), 溶血骇惧 HP loss per second - * reached at the end of duration_bleed (share of max HP, rising linearly — "逐渐加速失去生命值") [ASSUMED]. */ -const WOLF_RANGE = 2.5, WOLF_AWE_RADIUS = 2.5, WOLF_AWE_ASPD = -30, WOLF_BLEED_PEAK = 0.05; +/** 扎罗 (PRTS 扎罗,“狼之主” 天赋; no numbers in the data): form-2 attack radius ("进行远程攻击,普通攻击为2连击,攻击范围半径1.25"), + * 【远古威慑】 aura radius / ASPD ("自身1.5半径范围内的我方单位攻击速度-50(指定状况下生效)" — during the 重生 and in the second form). + * Until 0.1.3: 2.5 / 2.5 / −30, the aura only during the 重生 [ASSUMED]. */ +const WOLF_RANGE = 1.25, WOLF_AWE_RADIUS = 1.5, WOLF_AWE_ASPD = -50; /** 失衡 movement speed that turns 弧光锋卫's per-interval bleed into HP per tile moved [ASSUMED]. */ const UNBALANCE_SPEED = 5; /** 乌顶巨角卢鲁 【角力对决】: the operator cannot be pushed (fixed tiles) ⇒ "更多伤害" multiplier [ASSUMED]. */ @@ -680,6 +706,55 @@ const onHitStatus = (key, dur, value) => ({ dealt(c, b, e) { if (dur > 0) b.appl * 受到…伤害": areaAllies — a 迷彩 ally is hit, an unblocking 隐匿 one not; the attack itself still needs a target). */ const hitAllInRadius = (r) => ({ before(c, b, e) { const l = areaAllies(b, e, e.x, e.y, r); if (l.length) c.targets = byPriority(e, l); } }); +/** + * A normal attack that splashes around its target (PRTS: "对主目标造成…普通伤害,对溅射目标造成…溅射伤害"): 碎骨's grenade, + * “巨大的丑东西” / “墓碑”'s unblocked ranged attack, 烹泉 / 沏虹's attack, 集团军重型火炮's shell. Its mode is fixed when the + * attack starts (`before`): `unblocked` = only an attack begun while not blocked splashes (the others are the plain melee + * hit at `meleeScale`). The main target takes the engine's attack at `scale` × ATK (e.profile.atkScale, a normal attack: + * on-hit effects apply); when it lands (the main hit's 'hit', before its dodge) the others around the target take + * `scale` × ATK as a splash — an area selection (areaAllies / areaAlliesInTiles: no 隐匿 ally that does not block the + * enemy, no airborne 起飞 one for a ground enemy; 迷彩 is not checked — DESIGN §22.12). `tiles` 1 = the target's tile and + * its 8 neighbours (格子判定), else `radius` around the target (中点判定). `noAir`: "不可对空" (a flying ally — the 炎佑 + * dragon — is skipped; without it "可溅射飞行单位"). `dodge`: the splash hits may be dodged (烹泉 / 沏虹, whose PRTS 天赋 calls + * them 法术普通伤害 — a normal attack's damage); a 溅射伤害 splash cannot. `onEach(b, e, u)` runs on every unit hit (the target + * after its damage). + */ +function splashAttack({ scale = 1, meleeScale = 1, unblocked = true, tiles = 0, radius = 0, noAir = false, dodge = false, fxKind = 'splash', onEach = null } = {}) { + return { + before(c, b, e, a) { a.splash = !unblocked || !e.blockedBy; e.profile.atkScale = a.splash ? scale : meleeScale; }, + hitOut(c, b, e, a) { + const t = c.target; + if (!a.splash || !c.dmg.isAttack || !t) return; + b.fx('explode', { x: t.x, y: t.y, r: tiles > 0 ? tiles + 0.5 : radius, kind: fxKind, ...(tiles > 0 ? { tiles: 'box' } : {}) }); + const area = tiles > 0 ? areaAlliesInTiles(b, e, t.tileR, t.tileC, 'box', tiles) : areaAllies(b, e, t.x, t.y, radius); + for (const u of area) { + if (u === t || (noAir && u.isFlying)) continue; + hurt(b, e, u, e.s.atk * scale, c.dmg.type, { tags: ['splash'], canDodge: dodge }); + if (onEach && u.alive) onEach(b, e, u); + } + }, + dealt(c, b, e, a) { if (a.splash && onEach && c.target.alive) onEach(b, e, c.target); }, + }; +} +/** + * Tile keys of an enemy's path up to its next checkpoint, its own tile included (PRTS “复仇者” 冲锋 "位于自身下个检查点前的后续 + * 路径上(包含自身当前所在地块)"): the tiles its current move leg crosses — the straight segments through the leg's smoothed + * waypoints (ai.js planLeg; the last one is the leg's checkpoint), sampled every quarter tile. + */ +function pathKeysAhead(e) { + const keys = new Set([Math.round(e.y) * COLS + Math.round(e.x)]); + const R = e.route; + if (!R || !Array.isArray(R.pts)) return keys; + let x = e.x, y = e.y; + for (let i = R.ptIdx ?? 0; i < R.pts.length; i++) { + const p = R.pts[i], n = Math.max(1, Math.ceil(Math.hypot(p.x - x, p.y - y) * 4)); + for (let k = 1; k <= n; k++) keys.add(Math.round(y + ((p.y - y) * k) / n) * COLS + Math.round(x + ((p.x - x) * k) / n)); + x = p.x; y = p.y; + } + return keys; +} +/** "不会攻击飞行单位" (an engine candidate filter, ai.js enemyAttack: the 炎佑 dragon is a flying ally). */ +const noAirTargets = () => ({ spawn(b, e) { e.profile.canTarget = (u) => !u.isFlying; } }); /** * 抵抗 (ba.buffres, the engine `resist` status: control durations halved, 麻痹 loses 1 stack every 5 s) + optional * immunities. Value from the talent's `Buff.one_minus_status_resistance` when present (−0.5 ⇒ half). @@ -1120,10 +1195,11 @@ function pollution(b, src, x, y, r, life, low, high) { * its area selection takes (areaAllies: no unblocking 隐匿, untargetable or sleeping ally; no airborne 起飞 one for a ground * enemy — PRTS 集团军重型火炮 【燃烧区域】 "碰撞不受迷彩制约,不可对空": 迷彩 only). A sourceless zone (`src` null: 假想敌:蚀裂's * 毒雾, an enemy's 死亡爆炸 with no 无视可选性 note on PRTS) selects as an effect with no selecting enemy: 隐匿 kept out, an - * airborne 起飞 ally reached (§21.22; until 0.1.2 it took everyone inside). + * airborne 起飞 ally reached (§21.22; until 0.1.2 it took everyone inside). `noAir`: "不可对空" — a flying ally (the 炎佑 + * dragon) is skipped too (集团军重型火炮's 【燃烧区域】, since 0.1.3). */ -function dmgZone(b, src, x, y, r, life, iv, amount, type = 'arts', kind = 'zone', el = null, elAmount = 0) { - zone(b, { x, y, r, life, iv, kind, pick: (zx, zy, zr) => areaAllies(b, src, zx, zy, zr), tick(units) { +function dmgZone(b, src, x, y, r, life, iv, amount, type = 'arts', kind = 'zone', el = null, elAmount = 0, { noAir = false } = {}) { + zone(b, { x, y, r, life, iv, kind, pick: (zx, zy, zr) => areaAllies(b, src, zx, zy, zr).filter((u) => !(noAir && u.isFlying)), tick(units) { for (const u of units) { hurt(b, src, u, amount, type, { tags: [kind] }); if (el && elAmount > 0) elem(b, src, u, el, elAmount, { tags: [kind] }); @@ -1425,7 +1501,9 @@ function kitInvisShield(ab, e) { const s = ab.sk.InvisibleShield; const r = (e.def.raw && e.def.raw.stats && e.def.raw.stats.rawRangeRadius) || 2; return [...kitDeathSpawn()(ab), skill(s, (b, e2) => { - const dur = s.bb.duration ?? T(ab, 'InvisibleShield.duration') ?? 0; + // 辉光照耀 "使【范围隐匿】生效3s" (PRTS 清明 / 堂皇) = the talent's InvisibleShield.duration (3 for both); 清明's skill + // blackboard says 5 — until 0.1.3 that 5 was used + const dur = T(ab, 'InvisibleShield.duration') ?? s.bb.duration ?? 0; b.fx('telegraph', { x: e2.x, y: e2.y, r, kind: 'invisShield', id: e2.id }); // "获得隐匿(解除阻挡0秒后恢复)" (PRTS 清明 / 堂皇 天赋): the veil's 隐匿 is back as soon as a block ends for (const o of b.enemiesInRadius(e2.x, e2.y, r)) if (o !== e2 && dur > 0) b.addBuff(o, { key: 'ab:veiled', duration: dur, flags: { stealth: true }, visible: true, data: { stealthRestore: 0 } }); @@ -1435,16 +1513,29 @@ function kitInvisShield(ab, e) { function kitCrossbow(ab) { const s = ab.sk.CrossAttack; const aligned = (b, e) => b.allies().filter((u) => canTargetAlly(e, u, true) && (Math.abs(u.y - e.y) < 0.5 || Math.abs(u.x - e.x) < 0.5) && Math.hypot(u.x - e.x, u.y - e.y) <= CROSS_REACH); + const nearest = (b, e, l) => l.sort((p, q) => Math.hypot(p.x - e.x, p.y - e.y) - Math.hypot(q.x - e.x, q.y - e.y))[0]; + // PRTS 重弩突袭者: 天赋 "隐匿(被阻挡,主动攻击期间均可解除)"; 直击 "蓄力1.4s后向目标方向发射1支弩箭,对击中的首个目标造成攻击力100% + // 的法术伤害与5s晕眩 ※技能持续2.5s": revealed for the whole skill, the bolt leaves CROSS_CHARGE s in — at the nearest unit still + // in line in the aimed direction; it stands meanwhile [ASSUMED], and a stun / silence or its death before the release + // cancels the shot [ASSUMED]. The blackboard's duration (2) is not the skill's length PRTS gives. Until 0.1.3: revealed + // 2 s, the bolt at once. return [stealth(), skill(s, (b, e) => { - const t = aligned(b, e).sort((p, q) => Math.hypot(p.x - e.x, p.y - e.y) - Math.hypot(q.x - e.x, q.y - e.y))[0]; + const t = nearest(b, e, aligned(b, e)); if (!t) return; const bb = s.bb; - if (bb.duration > 0) b.addBuff(e, { key: 'ab:revealed', duration: bb.duration, flags: { reveal: true } }); - b.addProjectile({ from: e, target: t, speed: 12, visual: 'enemy', source: e, onHit: (c) => { - if (!c.target || !c.target.alive) return; - hurt(b, e, c.target, e.s.atk * (bb.atk_scale ?? 1), 'arts'); - if (bb.stun > 0 && c.target.alive) b.applyStatus(c.target, 'stun', { duration: bb.stun, source: e }); - } }); + const dx = Math.sign(Math.round(t.x - e.x)), dy = Math.sign(Math.round(t.y - e.y)); + b.addBuff(e, { key: 'ab:revealed', duration: CROSS_SKILL, flags: { reveal: true } }); + b.addBuff(e, { key: 'ab:aim', duration: CROSS_SKILL, flags: { noMove: true, disarm: true } }); + b.after(CROSS_CHARGE, () => { + if (!e.alive || e.hidden || e.s.flags.stun || e.s.flags.silence) return; + const tt = nearest(b, e, aligned(b, e).filter((u) => Math.sign(Math.round(u.x - e.x)) === dx && Math.sign(Math.round(u.y - e.y)) === dy)); + if (!tt) return; + b.addProjectile({ from: e, target: tt, speed: 12, visual: 'enemy', source: e, onHit: (c) => { + if (!c.target || !c.target.alive) return; + hurt(b, e, c.target, e.s.atk * (bb.atk_scale ?? 1), 'arts'); + if (bb.stun > 0 && c.target.alive) b.applyStatus(c.target, 'stun', { duration: bb.stun, source: e }); + } }); + }, { owner: e }); }, { sil: true, cond: (b, e) => aligned(b, e).length > 0 })]; } @@ -1876,24 +1967,28 @@ function kitDekght(ab) { return partnerRage; } +/** + * 烹泉 / 沏虹 (PRTS 天赋): every normal attack hits its target and every unit within TEA_SPLASH_RADIUS of it ("法术普通伤害 + * (无视迷彩,不可对空)" — splashAttack, blocked or not); 死亡爆炸 "(爆炸半径1.25,造成攻击力100%法术溅射伤害并施加15s【烹泉减益】, + * 无视迷彩,不可对空)", 【烹泉减益】 "攻击速度-40,且固定每3秒额外-0(可被抵抗,可叠加,每层持续时间和效果独立计算)" — DeadBoom. + * attack_speed / duration; one layer per blast, each its own buff. Until 0.1.3: no attack splash; the blast took the attack + * radius (2) and left a 15 s steam zone re-applying the ASPD cut to whoever stood in it [ASSUMED]. The attack splash, a 法术普通 + * 伤害, can be dodged like the attack itself; the blast (法术溅射伤害) cannot. + */ function kitTeapot(ab) { - return kitDeathSpawn([{ + return kitDeathSpawn([splashAttack({ unblocked: false, radius: TEA_SPLASH_RADIUS, noAir: true, dodge: true, fxKind: 'artsSplash' }), { sil: true, death(c, b, e) { if (c.reason !== 'killed') return; - const aspd = T(ab, 'DeadBoom.attack_speed') ?? 0, dur = T(ab, 'DeadBoom.duration') ?? 0, iv = T(ab, 'DeadBoom.interval') ?? 1; - const r = e.base.rangeRadius || 2, atk = e.s.atk, x = e.x, y = e.y; - // an area selection (PRTS 烹泉 "死亡爆炸(…无视迷彩,不可对空)", no 无视无法选择): an airborne 起飞 ally and an - // unblocking 隐匿 one are skipped, the steam's ticks too (the blast's debuff) - const pick = (zx, zy, zr) => areaAllies(b, e, zx, zy, zr); - // 被击倒后爆炸造成范围法术伤害 (one blast of ATK ×1 [ASSUMED scale]) … + const aspd = T(ab, 'DeadBoom.attack_speed') ?? 0, dur = T(ab, 'DeadBoom.duration') ?? 0; + const r = TEA_BOOM_RADIUS, atk = e.s.atk, x = e.x, y = e.y; + // an area selection (no 无视无法选择 note): an airborne 起飞 ally and an unblocking 隐匿 one are skipped, flyers too b.fx('explode', { x, y, r, kind: 'teaBoom', id: e.id }); - for (const u of pick(x, y, r)) hurt(b, null, u, atk, 'arts', { tags: ['teaBoom'] }); - // … 使我方攻击速度大幅降低(可被抵抗): the steam lingers `duration` s; allies inside keep the ASPD cut (re-applied - // every `interval` s) - const slow = (units) => { for (const u of units) b.addBuff(u, { key: 'ab:teaBoom', duration: iv + 0.1, refresh: 'extend', mods: { aspd }, visible: true }); }; - slow(pick(x, y, r)); - if (dur > 0) zone(b, { x, y, r, life: dur, iv, kind: 'teaSteam', tick: slow, pick }); + for (const u of areaAllies(b, e, x, y, r)) { + if (u.isFlying) continue; + hurt(b, null, u, atk, 'arts', { tags: ['teaBoom'] }); + if (u.alive && dur > 0 && aspd) b.addBuff(u, { key: `ab:teaBoom:${e.id}`, duration: dur, refresh: 'replace', mods: { aspd }, visible: true }); + } }, }])(ab); } @@ -1918,25 +2013,32 @@ function kitLeaderMisc(key, ab, e) { onTick: ({ battle, unit }) => battle.dealDamage(e2, unit, { amount: dot.dmg, type: 'arts', canDodge: false, ignoreSelect: true, tags: ['enemyAbility'] }) }); }, }, { - iv: T(ab, 'rangedamage.interval') ?? 10, - tick(b, e2) { - if (!e2.blockedBy) return; // 被阻挡时会进行自爆造成高额伤害 - b.fx('explode', { x: e2.x, y: e2.y, r: 1, kind: 'selfBlast' }); - for (const u of areaAllies(b, e2, e2.x, e2.y, 1)) hurt(b, e2, u, T(ab, 'rangedamage.attack@damage') ?? 0, 'arts'); + // 被阻挡时会进行自爆 — PRTS “庞贝” 天赋: "被阻挡时,每10秒(受晕眩/无法行动/沉睡/冻结/浮空影响时暂停计时,解除阻挡时重置计时)对 + // 半径1.4范围内的所有我方单位造成1000预计算的无途径法术溅射伤害(不可对空)": the clock runs only while it is blocked and can + // act, and restarts at 0 on every new block (until 0.1.3: a fixed 10 s clock from its spawn, radius 1, flyers hit) + iv: 0, + tick(b, e2, a, dt) { + if (!e2.blockedBy) { a.held = 0; return; } + if (!e2.canAct) return; + a.held = (a.held || 0) + dt; + if (a.held + 1e-9 < (T(ab, 'rangedamage.interval') ?? 10)) return; + a.held = 0; + b.fx('explode', { x: e2.x, y: e2.y, r: POMPEII_BLAST_RADIUS, kind: 'selfBlast' }); + for (const u of areaAllies(b, e2, e2.x, e2.y, POMPEII_BLAST_RADIUS)) if (!u.isFlying) hurt(b, e2, u, T(ab, 'rangedamage.attack@damage') ?? 0, 'arts', { tags: ['splash'] }); }, }]; } - case 'enemy_1500_skulsr': - return [lowHpBuff(T(ab, 'atkup.hp_ratio') ?? 0.5, { atkPct: T(ab, 'atkup.atk') ?? 0 }), { - dealt(c, b, e2) { - if (e2.blockedBy) return; // 未被阻挡时会发射榴弹 - // the grenade's splash: an area selection — a 隐匿 ally next to the target is spared (GitHub issue #32 item 6) - for (const u of targetAndArea(c.target, areaAllies(b, e2, c.target.x, c.target.y, 1))) { - if (u !== c.target) hurt(b, e2, u, e2.s.atk, 'phys'); - b.applyStatus(u, 'defDown', { duration: SKULSR_DEFDOWN_DUR, source: e2, value: -(T(ab, 'defdown.def') ?? 0) }); - } - }, - }]; + case 'enemy_1500_skulsr': { + // PRTS 碎骨 天赋: "不会攻击飞行单位"; below half HP ATK +50 %; "未被阻挡时发射榴弹对目标及其周围八格的我方单位造成相当于攻击力 + // 26%的物理伤害,并令其在5秒内防御力下降50%" ("榴弹对主目标造成物理普通伤害,对溅射目标造成物理溅射伤害,可溅射飞行单位") — the + // 26 % / 5 s / 3×3 are PRTS's, the −50 % is the blackboard's defdown.def. A blocked attack is its plain melee hit. The + // splash is an area selection: a 隐匿 operator next to the target that does not block it is spared (GitHub issue #32 + // item 6, DESIGN §22.12). Community report #14 (0.1.2): the grenade used to be a full attack + 100 % on 4 neighbours. + const dd = -(T(ab, 'defdown.def') ?? 0); + return [lowHpBuff(T(ab, 'atkup.hp_ratio') ?? 0.5, { atkPct: T(ab, 'atkup.atk') ?? 0 }), noAirTargets(), + splashAttack({ scale: SKULSR_GRENADE_SCALE, tiles: 1, fxKind: 'grenade', + onEach: (b, e2, u) => { if (dd) b.applyStatus(u, 'defDown', { duration: SKULSR_DEFDOWN_DUR, source: e2, value: dd }); } })]; + } case 'enemy_1502_crowns': { const s = ab.sk.blink; return [skill(s, (b, e2) => blinkForward(b, e2, s.bb.dist ?? 1.5), { sil: true, cond: (b, e2) => !!e2.blockedBy })]; @@ -2029,9 +2131,17 @@ function kitLeaderMisc(key, ab, e) { } case 'enemy_1539_reid': { // 生命值降至一半以下时攻击力大幅度提升; 首次被击倒后重生 (Reborn.duration s), 恢复一半生命值; - // 【冲锋】 a unit within range_radius ahead ⇒ move speed ×(1+move_speed) for `duration` s + // 【冲锋】 (PRTS 技能 "仅自身未被阻挡且存在符合条件的可选目标时可触发:选择3.0半径内位于自身下个检查点前的后续路径上(包含自身当前 + // 所在地块)的距离自身最近的我方单位,将其所在地块中心设置为自身下一检查点;技能使用成功后,自身移动速度+200%,持续4.5s"): a target + // it may select (targetsNear: no 隐匿 / 迷彩 / 起飞 / untargetable operator) within RUSH_RADIUS standing on its own path to + // the next checkpoint (its tile included) ⇒ move speed ×(1+move_speed) for `duration` s. It walks that path anyway, so + // the re-aim at the target's tile is not a separate move. Until 0.1.3: any unit within the blackboard's 1.5, 隐匿 / 迷彩 + // ones included, off its path too. const rush = ab.sk.Rush; - const prey = (b, e2) => targetsNear(b, e2, rush.bb.range_radius ?? 1.5, { ranged: false }).filter((u) => u.ground); + const prey = (b, e2) => { + const keys = pathKeysAhead(e2); + return targetsNear(b, e2, RUSH_RADIUS).filter((u) => u.ground && keys.has(u.tileR * COLS + u.tileC)); + }; return [lowHpBuff(T(ab, 'atkup.hp_ratio') ?? 0.5, { atkPct: T(ab, 'AtkUp.atk', 'atkup.atk') ?? 0 }), reborn({ dur: T(ab, 'Reborn.duration') ?? 0, hpRatio: T(ab, 'Reborn.hp_ratio') ?? 0.5, invincible: T(ab, 'Reborn.invincible') ?? 0 }), skill(rush, (b, e2) => { @@ -2082,7 +2192,10 @@ function kitLeaderMisc(key, ab, e) { // 部署费用回复速度减半,再部署时间加倍; 周期性地添加正比于自身最大生命值的伤害防护屏障. The halving is a map effect // (PRTS “墓碑” "※文字描述中的削弱实际属于地图效果,不属于敌人本身的能力"): every ally, 隐匿 / airborne ones included const s = ab.sk.refreshshield; - return [{ + // its attack — PRTS “墓碑” 天赋: "自身造成的远程途径伤害的攻击倍率降低至40%" (the blackboard's atkdown.atk_scale), "未被阻挡时会 + // 进行远程攻击,对目标及其周围八格内的所有我方单位造成物理伤害,不会攻击飞行单位" ("可溅射飞行单位"); blocked, a melee hit at + // 100 %. Until 0.1.3: a single-target hit at 100 % either way. + return [noAirTargets(), splashAttack({ scale: T(ab, 'atkdown.atk_scale') ?? 1, tiles: 1 }), { iv: 0.5, tick(b) { for (const u of b.allies()) { @@ -2184,15 +2297,23 @@ function kitUglyThing(ab) { const run = { spd: T(ab, 'bird_run.move_speed') ?? 0, def: T(ab, 'bird_run.def'), res: T(ab, 'bird_run.magic_resistance'), dmg: T(ab, 'bird_run.damage') ?? 0 }; const P = { pilot: false }; return [ - { hitOut(c, b, e) { if (!P.pilot && c.dmg.isAttack && c.target === e.blockedBy) c.dmg.amount *= rage; } }, // 近战攻击造成更高伤害 + // 远程攻击造成溅射伤害,近战攻击造成更高伤害 — PRTS 天赋: unblocked, a ranged attack on 1 non-flying unit within 2.5 hitting it + // and its 8 surrounding tiles for 100 % ATK ("可溅射飞行单位"); blocked, a melee hit at mcmstr_rage_attack.atk_scale + { spawn(b, e) { e.base.rangeRadius = Math.max(e.base.rangeRadius || 0, MCM_RANGE); e.profile.canTarget = (u) => !u.isFlying; } }, + splashAttack({ scale: 1, meleeScale: rage, tiles: 1 }), reborn({ dur: T(ab, 'reborn.duration') ?? 0, - onKo(b, e) { // 生命值降至0后自爆,造成大范围晕眩 (an area selection) - b.fx('explode', { x: e.x, y: e.y, r: MCM_BOMB_RADIUS, kind: 'mechBomb', id: e.id }); - for (const u of areaAllies(b, e, e.x, e.y, MCM_BOMB_RADIUS)) { - hurt(b, e, u, e.s.atk * (bomb.atk_scale ?? 0), 'phys'); - if (u.alive && bomb.stun > 0) b.applyStatus(u, 'stun', { duration: bomb.stun, source: e }); - } + onKo(b, e) { // 生命值降至0后自爆,造成大范围晕眩 (an area selection), 2.17 s into the 重生 + const x = e.x, y = e.y; + b.fx('telegraph', { x, y, r: MCM_BOMB_RADIUS, dur: MCM_BOMB_DELAY, kind: 'mechBomb', id: e.id }); + b.after(MCM_BOMB_DELAY, () => { + if (!e.alive || e.removed) return; + b.fx('explode', { x, y, r: MCM_BOMB_RADIUS, kind: 'mechBomb', id: e.id }); + for (const u of areaAllies(b, e, x, y, MCM_BOMB_RADIUS)) { + hurt(b, e, u, e.s.atk * (bomb.atk_scale ?? 0), 'phys'); + if (u.alive && bomb.stun > 0) b.applyStatus(u, 'stun', { duration: bomb.stun, source: e }); + } + }, { owner: e }); }, onReborn(b, e) { // 大祭司形态: 不进行攻击,无法被阻挡,每秒受到真实伤害 P.pilot = true; @@ -2342,38 +2463,68 @@ function kitMace(ab) { })]; } -/** 扎罗,“狼之主” · first form: damage taken −30 %, not stunnable, 【溶血骇惧】 (3 units: ASPD −70, accelerating HP loss, - * cured once 扎罗 loses hp_ratio of its HP); KO ⇒ 【远古威慑】 rebirth (Reborn.duration s: ASPD down around it while its HP - * refills) ⇒ second form: ranged double hits, ATK/ASPD up, invincible at first. 血债账款 / 【狂暴怒嗥】 absent. */ +/** 扎罗,“狼之主” · first form: damage taken −30 %, not stunnable, 【溶血骇惧】; KO ⇒ a Reborn.duration s 重生 (its HP refills, + * 【远古威慑】 slows the units around it) ⇒ second form: ranged double hits within WOLF_RANGE, ATK/ASPD up, invincible at first, + * 【远古威慑】 still on. 【溶血骇惧】 (PRTS 技能 "使场上最多3名我方单位攻击速度-70,获得无法撤退,逐渐流失生命(从0/秒开始线性递增,在 + * 40秒后达到最大流失速度30%最大生命值/秒),持续时间无限 … 场上存在被此技能影响的单位时狼之主获得静默 … 释放此技能时狼之主记录自身当前 + * 生命值,累计损失20%生命值后解除场上全部我方单位的技能效果"): the FearCage blackboard's attack_speed, hp_ratio (the loss rate + * reached at duration_bleed s), hp_ratio_offset (the cure); its SP (the cast's cooldown, spCost s at +1/s [ASSUMED]) stands + * still while a unit is caught (静默), each ended effect gives back sp, and the last one starts duration_wait s more of 静默 + * ("每结束1个技能效果狼之主回复5SP,场上技能效果全部结束时狼之主获得7s静默"). Until 0.1.3 the loss peaked at 5 % [ASSUMED], the buff + * ended after 40 s, hp_ratio (30 %) was read as the cure and the cast came every spCost s whatever was caught. Not modelled: + * 血债账款 / 【狂暴怒嗥】. */ function kitWolfLord(ab) { const fc = ab.sk.FearCage; - const P = { form2: false }; + const P = { form2: false, caught: 0, waitUntil: -Infinity, cast: null }; + const caught = (b) => b.allies().some((u) => u.findBuff('ab:fearCage')); const cure = (b) => { for (const u of b.allies()) { const d = u.findBuff('ab:fearCage'); if (d) b.removeBuff(u, d); } }; + // 【远古威慑】 (a buff aura, auraAllies): no 隐匿 operator that does not block it, an airborne 起飞 one still — as 深池伙友卫队's + const awe = (b, e) => { for (const u of auraAllies(b, e, e.x, e.y, WOLF_AWE_RADIUS)) auraBuff(b, u, 'ab:ancientAwe', 0.2, { aspd: WOLF_AWE_ASPD }, null, true); }; return [{ spawn(b, e, a, ab2) { ab2.immune = new Set([...(ab2.immune || []), 'stun']); // 第一形态: 不可被晕眩 b.addBuff(e, { key: 'ab:wolfGuard', persist: true, visible: true, mods: { dmgTakenMul: 1 - (T(ab, 'Passive.damage_resistance') ?? 0) } }); }, - iv: 0.25, - tick(b, e) { // afflicted units recover once 扎罗 lost hp_ratio since the cast + iv: 0.1, + tick(b, e, a, dt) { + if (P.form2) { awe(b, e); return; } // 第二形态: 【远古威慑】生效 if (!fc) return; + // afflicted units recover once 扎罗 lost hp_ratio_offset of its HP since the cast + const cut = Math.abs(fc.bb.hp_ratio_offset ?? 0.2); + let n = 0; for (const u of b.allies()) { const d = u.findBuff('ab:fearCage'); - if (d && d.data.src === e && e.hpRatio <= d.data.hp0 - (fc.bb.hp_ratio ?? 0) + 1e-9) b.removeBuff(u, d); + if (!d || d.data.src !== e) continue; + if (e.hpRatio <= d.data.hp0 - cut + 1e-9) b.removeBuff(u, d); else n++; } + if (P.cast) { + if (n < P.caught) { // ended effects (cured, knocked out): +sp each + P.cast.left -= (fc.bb.sp ?? 0) * (P.caught - n); + if (n === 0) P.waitUntil = b.time + (fc.bb.duration_wait ?? 0); + } + if (n > 0 || b.time < P.waitUntil) P.cast.left += dt; // 静默: its SP stands still + } + P.caught = n; }, }, - // 【溶血骇惧】 every spCost s (enemy SP +1/s [ASSUMED]), first form only - skill(fc, (b, e) => { + // 【溶血骇惧】 every spCost s (enemy SP +1/s [ASSUMED]), first form only, never during its 静默 + P.cast = skill(fc, (b, e) => { const l = b.rng.shuffle(allTargets(b, e).filter((u) => u.kind === 'op' && !u.findBuff('ab:fearCage'))).slice(0, fc.bb.max_target ?? 3); - const dur = fc.bb.duration_bleed ?? 0; + const ramp = Math.max(1, fc.bb.duration_bleed ?? 40), peak = fc.bb.hp_ratio ?? 0; for (const u of l) { b.fx('beam', { x: e.x, y: e.y, from: e.id, to: u.id, kind: 'fearCage' }); - b.addBuff(u, { key: 'ab:fearCage', duration: dur, refresh: 'replace', visible: true, interval: 1, data: { src: e, hp0: e.hpRatio, t0: b.time }, + b.addBuff(u, { key: 'ab:fearCage', refresh: 'replace', visible: true, interval: 1, data: { src: e, hp0: e.hpRatio, t0: b.time }, mods: { aspd: fc.bb.attack_speed ?? 0 }, - onTick: ({ battle, unit, buff }) => { const k = Math.min(1, (battle.time - buff.data.t0) / Math.max(1, dur)); battle.loseHp(unit, unit.s.maxHp * WOLF_BLEED_PEAK * k, { source: e }); } }); + onTick: ({ battle, unit, buff }) => { + // its 扎罗 gone from the field without a 重生 (leaked, removed): the effect ends, no loss this tick [ASSUMED — no + // official text; the 重生's onKo cure covers its knock-out]. Until 0.1.3 a leak left it draining for good. + const src = buff.data.src; + if (!src || !src.alive || src.removed) { battle.removeBuff(unit, buff); return; } + const k = Math.min(1, (battle.time - buff.data.t0) / ramp); + battle.loseHp(unit, unit.s.maxHp * peak * k, { source: e }); + } }); } - }, { cd: fc ? fc.sp || fc.cd : null, icd: fc ? fc.sp || fc.icd : null, cond: (b, e) => !P.form2 && allTargets(b, e).some((u) => u.kind === 'op') }), + }, { cd: fc ? fc.sp || fc.cd : null, icd: fc ? fc.sp || fc.icd : null, cond: (b, e) => !P.form2 && !caught(b) && b.time >= P.waitUntil && allTargets(b, e).some((u) => u.kind === 'op') }), doubleHit(() => P.form2), // 攻击变为远程二连击 reborn({ dur: T(ab, 'Reborn.duration') ?? 0, invincible: T(ab, 'Passive2.invincible_time') ?? 0, @@ -2382,11 +2533,10 @@ function kitWolfLord(ab) { b.removeBuff(e, 'ab:wolfGuard'); if (e.mem.ab.immune) e.mem.ab.immune.delete('stun'); }, - during(b, e, a, t) { // 【远古威慑】: nearby operators slowed; its HP refills slowly + during(b, e, a, t) { // 重生期间【远古威慑】生效; its HP refills slowly const dur = T(ab, 'Reborn.duration') ?? 1; e.hp = Math.max(1, e.s.maxHp * Math.min(1, t / Math.max(TICK, dur))); - // a buff aura (auraAllies): no 隐匿 operator that does not block it, an airborne 起飞 one still — as 深池伙友卫队's - for (const u of auraAllies(b, e, e.x, e.y, WOLF_AWE_RADIUS)) auraBuff(b, u, 'ab:ancientAwe', 0.2, { aspd: WOLF_AWE_ASPD }, null, true); + awe(b, e); }, onReborn(b, e) { P.form2 = true; @@ -2606,9 +2756,13 @@ export const KITS = Object.freeze({ else cloud(e.x, e.y); }, }], - enemy_10122_uacann_2: (ab) => [{ // 集团军重型火炮 · each hit leaves a burning zone + // 集团军重型火炮 · PRTS 天赋: "不会攻击飞行单位"; the shell hits every unit within 1.0 of its target for 100 % ATK ("此弹道会强制 + // 击中主目标,碰撞无视迷彩,不可对空"; no splash until 0.1.3) and each hit on the main target leaves a 3 s 【燃烧区域】 of radius + // projectile_range × UACANN_ZONE_SCALE + enemy_10122_uacann_2: (ab) => [noAirTargets(), splashAttack({ unblocked: false, radius: SHELL_SPLASH_RADIUS, noAir: true, fxKind: 'shell' }), { dealt(c, b, e) { - dmgZone(b, e, c.target.x, c.target.y, T(ab, 'ProjectileBoomRange.attack@projectile_range') ?? 1, T(ab, 'ProjectileBoomRange.attack@projectile_life_time') ?? 0, 1, T(ab, 'ProjectileBoomRange.attack@value') ?? 0, 'arts', 'burning'); + // 【燃烧区域】 "不可对空": a flying ally (the 炎佑 dragon) standing in it takes nothing (until 0.1.3 it burned) + dmgZone(b, e, c.target.x, c.target.y, (T(ab, 'ProjectileBoomRange.attack@projectile_range') ?? 1) * UACANN_ZONE_SCALE, T(ab, 'ProjectileBoomRange.attack@projectile_life_time') ?? 0, 1, T(ab, 'ProjectileBoomRange.attack@value') ?? 0, 'arts', 'burning', null, 0, { noAir: true }); }, }], enemy_10054_cjhot: (ab, e) => [immuneTo('sluggish'), { // 鼎沸 · pulses arts + burn around itself; immune 停顿 diff --git a/server/sim/content/kits/tier3.js b/server/sim/content/kits/tier3.js index e786827f..25a6c6ea 100644 --- a/server/sim/content/kits/tier3.js +++ b/server/sim/content/kits/tier3.js @@ -399,8 +399,9 @@ const KITS = { // ---- 3_04 琳琅诗怀雅 · 行商 — S2 “见面礼” (passive): each attack spends a coin to drop a champagne bomb in range; // 大买家: coin at skill start + coin & ATK stack per trait payment; 破财消灾: DP-paid revive (cost doubles) - // S1 仗义疏财 (passive, 2 coins): an attack spends a coin to heal the most injured ally (< 70 % HP) of the 8 - // surrounding tiles for attack@heal_scale × ATK. S3 千金一掷 (持续时间无限): attacks hit twice, kills give a coin; + // S1 仗义疏财 (passive, 2 coins): a coin heals the most injured ally (< 70 % HP) of the 8 surrounding tiles for + // attack@heal_scale × ATK — on her attack, or with no enemy to attack on her own attack timer (owner's decision + // 2026-10-04, against the official 「下一次攻击会为…」; installS1). S3 千金一掷 (持续时间无限): attacks hit twice, kills give a coin; // closing it spends every coin on random ground enemies of range 2-4 in front and those she blocks (atk_scale phys + // a small push, radial despite the text's 向前 — PRTS 备注 "推开效果为径向推动"; client charpack char_1033_swire2: // the RandomGold ability (Skill_3_End) carries swire2_s_3[knockback] of template knockback[relative]; 地面敌方单位, @@ -448,10 +449,19 @@ const KITS = { }, }); const healRatio = textNum(d.skill?.description, /血量不足(\d+)%/, 70) / 100; - const installS1 = (battle, unit) => { // 仗义疏财 + /** + * 仗义疏财 — official text 「消耗一枚金币,下一次攻击会为周围八格内血量不足70%的一名友方单位恢复相当于攻击力40%的生命」: the heal rides on an + * attack, so with no enemy around she never healed (community report #5 「琳琅诗怀雅1技能不会主动奶身边受伤的干员」). Owner's + * decision 2026-10-04 (a deliberate deviation, like §21.29's 重装 casts): she heals an injured ally beside her whether + * she attacks or not. Same target (the lowest HP ratio below 70 % of the 8 surrounding tiles, no 禁疗 / 孤立 unit, no + * device), coin and heal_scale × ATK. Cadence [ASSUMED]: at most one heal per attack cycle (her attack interval, ASPD + * included) — while she attacks, on the attack as before; when her last attack attempt found no target, on her own + * timer (the 'tick' hook runs after the attacks, so an attack due in the same tick takes it). + */ + const installS1 = (battle, unit) => { const hs = num(bb['attack@heal_scale'], num(bb.heal_scale, 0)); - battle.on('attack', (ctx) => { - if (ctx.attacker !== unit || !alive(unit) || (unit.mem.coins ?? 0) < coinCost || !(hs > 0)) return; + let nextAt = -Infinity; + const healTarget = () => { let best = null; for (const a of battle.allyUnits) { if (a === unit || !alive(a) || a.hidden || a.kind === 'device' || a.hpRatio >= healRatio) continue; @@ -459,11 +469,19 @@ const KITS = { if (Math.max(Math.abs(a.tileR - unit.tileR), Math.abs(a.tileC - unit.tileC)) !== 1) continue; // 周围八格 if (!best || a.hpRatio < best.hpRatio || (a.hpRatio === best.hpRatio && a.deploySeq < best.deploySeq)) best = a; } + return best; + }; + const tryHeal = () => { + if (!alive(unit) || (unit.mem.coins ?? 0) < coinCost || !(hs > 0) || battle.time < nextAt - 1e-9) return; + const best = healTarget(); if (!best) return; unit.mem.coins -= coinCost; + nextAt = battle.time + unit.s.interval; battle.heal(unit, best, unit.s.atk * hs); fx(battle, 'coin', unit, { n: unit.mem.coins, heal: best.id, skill: 'swire2_1' }); - }, { owner: unit, priority: -10 }); + }; + battle.on('attack', (ctx) => { if (ctx.attacker === unit) tryHeal(); }, { owner: unit, priority: -10 }); + battle.on('tick', () => { if (unit.deployed && unit.canAct && !unit.trait?.hadTarget) tryHeal(); }, { owner: unit }); }; const installS3 = (battle, unit) => { // 千金一掷: "击倒敌人时获得一枚金币" battle.on('kill', (ctx) => { diff --git a/server/sim/content/kits/tier6.js b/server/sim/content/kits/tier6.js index 7a406772..eddbd1c3 100644 --- a/server/sim/content/kits/tier6.js +++ b/server/sim/content/kits/tier6.js @@ -73,8 +73,9 @@ // 6_01 S2 the aim (disarmed meanwhile) spends its bullet when it begins; target = a wanted enemy (lowest DEF first). // 6_02 S1 casts with an enemy in her range (data SEARCH read as "search in range" [ASSUMED]; the engine SEARCH = // 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. +// S2 "目标点变为冻结状态" = the frozen 保护目标 token (icetgt, one at a time) on a protection point (goal tile) reaching +// max snow, deploy attributes ignored, whose snow is used up (PRTS 备注; until 0.1.3 any standable tile froze, never the +// gate); 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, 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 新生代 @@ -612,16 +613,20 @@ function sbell2(bb, chess, def) { /** * S2 霜涛覆岭 while it runs (`unit.mem.sbellS2`): a layer landing on a tile already at max snow spreads one layer to * a neighbouring ground tile (at most max_cast_tile_count spreads per activation); ground enemies on snow take - * s2_magic_scale × ATK arts per second; an enemy leaving snow gets `cold` s of 寒冷; a tile reaching max snow turns - * into the frozen 保护目标 (token icetgt: blocks 3, one at a time) and its snow is used up. + * s2_magic_scale × ATK arts per second; an enemy leaving snow gets `cold` s of 寒冷; "积雪在目标点积累至5层时,使目标点变为 + * 冻结状态": a protection point (the blue gate) reaching max snow turns into the frozen 保护目标 (freezeTile). */ const addSnow = (battle, unit, k) => { const snow = unit.mem.snow; if (!snow) return false; const r = (k / COLS) | 0, c = k % COLS; if (!battle.grid.inRect(r, c) || !battle.grid.groundPassable(r, c, true)) return false; + // PRTS 备注 "存在自身的该召唤物的地块不会积雪" + if (battle.allyUnits.some((t) => isTok(t, iceId, unit) && t.alive && t.tileR === r && t.tileC === c)) return false; const S2 = unit.mem.sbellS2; const cur = snow.get(k) ?? 0; + // a protection point already at max snow (its freeze waited for her other token to go) freezes on its next layer + if (S2 && cur >= maxL && freezeTile(battle, unit, k)) return true; if (cur >= maxL) { if (!S2 || S2.spreadLeft <= 0) return false; // "积雪超过5层时会向周围扩散一层": the thinnest neighbouring ground tile gets the layer @@ -640,14 +645,24 @@ function sbell2(bb, chess, def) { if (S2 && cur + 1 >= maxL) freezeTile(battle, unit, k); return true; }; + /** + * "使目标点变为冻结状态" — PRTS 圣聆初雪 S2 备注: "目标点冻结的实际效果为令圣聆初雪在该地块上召唤一个保护目标(冻结状态)(无视部署 + * 属性),并去除相应地块上的积雪", "可以被'变为冻结状态'的目标点包括常规的保护目标点与促融共竞的保护目标点"; the token's page: + * "技能发动后于保护目标叠加5层积雪". So only a protection point (a goal tile, glyph E) freezes, whatever its deploy attributes + * (nobody may stand on a gate): her token icetgt (blocks 3; one at a time, maxDeployCount 1) appears on it and blocks the + * enemies walking in, and that tile's snow is used up. Until 0.1.3 (community report #32): any free tile a melee + * operator could stand on froze at max snow, and the gate never did. Returns true when the token appeared. + */ const freezeTile = (battle, unit, k) => { const r = (k / COLS) | 0, c = k % COLS; - if (battle.allyUnits.some((t) => isTok(t, iceId, unit) && t.alive)) return; - if (battle.isReservedTile(r, c) || !battle.grid.canStand(r, c, { ranged: false })) return; + if (battle.grid.tile(r, c).special !== 'end') return false; + if (battle.allyUnits.some((t) => isTok(t, iceId, unit) && t.alive)) return false; + if (battle.isReservedTile(r, c)) return false; const ice = battle.spawnToken(unit, iceId, r, c); - if (!ice) return; + if (!ice) return false; unit.mem.snow.delete(k); battle.fx('summon', { x: c, y: r, id: ice.id, src: unit.id }); + return true; }; const talents = [ { install(battle, unit) { // 无垠的雪景 (+ S2 snow rules) diff --git a/test/content/enemies_bosses.test.js b/test/content/enemies_bosses.test.js index c50ce1ad..5f8494de 100644 --- a/test/content/enemies_bosses.test.js +++ b/test/content/enemies_bosses.test.js @@ -10,6 +10,7 @@ import fs from 'node:fs'; import { makeBattle, chessRec, checkInvariants } from '../helpers/battleHarness.js'; import * as enemiesMod from '../../server/sim/content/enemies.js'; import * as bossesMod from '../../server/sim/content/bosses.js'; +import { spawnYanyou } from '../../server/sim/content/tokens.js'; const E = JSON.parse(fs.readFileSync(new URL('../../data/enemies.json', import.meta.url), 'utf8')); const W = JSON.parse(fs.readFileSync(new URL('../../data/waves.json', import.meta.url), 'utf8')); @@ -116,16 +117,33 @@ for (const key of ['enemy_1299_ymkilr', 'enemy_1299_ymkilr_2']) { }); } -test(`${nm('enemy_1404_msnip')}: 直击 — a unit in line is shot (arts ATK×atk_scale) and stunned; the shooter reveals itself`, () => { +// PRTS 重弩突袭者 直击: "蓄力1.4s后向目标方向发射1支弩箭,对击中的首个目标造成攻击力100%的法术伤害与5s晕眩 ※技能持续2.5s"; 天赋 +// "隐匿(被阻挡,主动攻击期间均可解除)" (0.1.3: the bolt left at once and the reveal lasted the blackboard's 2 s) +test(`${nm('enemy_1404_msnip')}: 直击 — 1.4 s after the cast a unit in line is shot (arts ATK×atk_scale) and stunned; revealed and standing for the skill's 2.5 s`, () => { const h = arena({ units: [{ chessId: 't_wall', row: 9, col: 4 }], hooks: ['statusApplied'] }); h.step(); const e = put(h, 'enemy_1404_msnip', [9, 8]); const s = skb('enemy_1404_msnip', 'CrossAttack'); - h.run(s.initCooldown + 1.5); const w = h.unit('t_wall'); + h.run(s.initCooldown + 0.1); + assert.ok(e.findBuff('ab:revealed') && e.s.flags.noMove, 'cast: revealed, standing'); + h.run(1.2); // 1.3 s into the skill: still charging + assert.equal(w.stats.taken, 0, 'no bolt before the 1.4 s charge'); + h.run(0.8); // 2.1 s in: the bolt (4 tiles at 12/s) has landed assert.equal(statuses(h, w.id, 'stun').length, 1); + approx(statuses(h, w.id, 'stun')[0].duration, s.bb.stun); approx(w.stats.taken, e.s.atk * s.bb.atk_scale * (1 - 0 / 100)); - assert.ok(e.findBuff('ab:revealed') || h.b.time > s.initCooldown + s.bb.duration); + assert.ok(e.findBuff('ab:revealed'), 'still revealed at 2.1 s'); + h.run(0.5); // 2.6 s in: the skill is over + assert.ok(!e.findBuff('ab:revealed') && !e.s.flags.noMove, 'skill over after 2.5 s'); + // stunned during the charge: no bolt + const h2 = arena({ units: [{ chessId: 't_wall', row: 9, col: 4 }] }); + h2.step(); + const e2 = put(h2, 'enemy_1404_msnip', [9, 8]); + h2.run(s.initCooldown + 0.5); + h2.b.applyStatus(e2, 'stun', { duration: 1.5 }); + h2.run(2); + assert.equal(h2.unit('t_wall').stats.taken, 0, 'a stun during the charge cancels the shot'); }); test(`${nm('enemy_10034_cnvsax')}: never attacks while stealthed; once blocked it counter-attacks and burns its locked target`, () => { @@ -280,30 +298,55 @@ for (const key of ['enemy_1207_sfji', 'enemy_1207_sfji_2']) { } for (const key of ['enemy_1209_sfden', 'enemy_1209_sfden_2']) { - test(`${nm(key)}: InvisibleShield veils nearby enemies (stealth) for the skill duration`, () => { + // PRTS 清明 / 堂皇 辉光照耀 "使【范围隐匿】生效3s" = the talent's InvisibleShield.duration (清明's skill blackboard says 5 — used + // until 0.1.3) + test(`${nm(key)}: InvisibleShield veils nearby enemies (stealth) for the talent's ${tb(key, 'InvisibleShield.duration')} s`, () => { const h = arena(); h.step(); put(h, key, [10, 7]); const o = put(h, 'enemy_1007_slime', [10, 6]); const s = skb(key, 'InvisibleShield'); + assert.equal(tb(key, 'InvisibleShield.duration'), 3); h.run(s.initCooldown + 0.1); assert.ok(o.s.flags.stealth); - h.run(s.bb.duration + 0.2); - assert.ok(!o.s.flags.stealth); + h.run(3 - 0.3); + assert.ok(o.s.flags.stealth, 'still veiled at 2.8 s'); + h.run(0.4); + assert.ok(!o.s.flags.stealth, 'gone after 3 s'); }); } for (const key of ['enemy_1203_sfhu', 'enemy_1203_sfhu_2']) { - test(`${nm(key)}: death spawns 4 茶器 and leaves an ASPD-down arts blast zone`, () => { - const h = arena({ units: [{ chessId: 't_gun', row: 10, col: 6 }] }); + // PRTS 烹泉 / 沏虹 天赋: "死亡爆炸(爆炸半径1.25,造成攻击力100%法术溅射伤害并施加15s【烹泉减益】…不可对空)", 【烹泉减益】 "可叠加,每层 + // 持续时间和效果独立计算"; "普通攻击对目标对及目标周围半径1.0范围内的所有我方单位造成法术普通伤害" (0.1.3: the blast took the attack + // radius 2 and left a steam zone; the attack hit its target only) + test(`${nm(key)}: death spawns 4 茶器; the blast (r 1.25, ATK arts) gives each unit hit one ${tb(key, 'DeadBoom.duration')} s layer of ASPD ${tb(key, 'DeadBoom.attack_speed')}; its attacks splash r 1.0`, () => { + const h = arena({ units: [{ chessId: 't_gun', row: 10, col: 6 }, { chessId: 't_wall', row: 11, col: 8 }, { chessId: 't_wall2', row: 10, col: 5 }], hooks: ['damaged'], captureNoisy: true }); h.step(); const e = put(h, key, [10, 7]); + h.runUntil(() => h.hooksOf('damaged').some((c) => c.source === e && c.dmg.isAttack), 10); + h.run(0.6); + const tgt = h.hooksOf('damaged').find((c) => c.source === e && c.dmg.isAttack).target; + const near = h.allies().filter((u) => u !== tgt && Math.hypot(u.x - tgt.x, u.y - tgt.y) <= 1 + 1e-6); + const splashed = h.hooksOf('damaged').filter((c) => c.source === e && !c.dmg.isAttack && c.dmg.tags.includes('splash')).map((c) => c.target); + assert.ok(near.length >= 1, 'a unit within 1.0 of the target'); + for (const u of near) assert.ok(splashed.includes(u), `${u.defId} splashed`); + for (const u of splashed) assert.ok(Math.hypot(u.x - tgt.x, u.y - tgt.y) <= 1 + 1e-6, 'only within 1.0'); + approx(h.hooksOf('damaged').find((c) => c.source === e && !c.dmg.isAttack).amount, e.s.atk, 1e-6, 'arts, 100 % (RES 0)'); + const atk = e.s.atk; + const taken0 = new Map(h.allies().map((u) => [u, u.stats.taken])); killed(h, e, null); assert.equal(alive(h, E[key].talents.bbStr['DeadSpawn.enemy_key']).length, tb(key, 'DeadSpawn.cnt')); - h.run(tb(key, 'DeadBoom.interval') + 0.1); - const g = h.unit('t_gun'); - assert.ok(g.stats.taken > 0); + const g = h.unit('t_gun'), w = h.unit('t_wall'), w2 = h.unit('t_wall2'); + approx(g.stats.taken - taken0.get(g), atk, 1e-6, '1.0 away: blasted'); + assert.equal(w2.stats.taken - taken0.get(w2), 0, '2.0 away: outside 1.25'); + assert.equal(w.stats.taken - taken0.get(w), 0, '√2 away: outside 1.25'); approx(g.s.aspd, 100 + tb(key, 'DeadBoom.attack_speed')); + assert.equal(w.s.aspd, 100); + h.run(tb(key, 'DeadBoom.duration') - 0.5); + approx(g.s.aspd, 100 + tb(key, 'DeadBoom.attack_speed'), 1e-6, 'one layer for 15 s'); + h.run(1); + assert.equal(g.s.aspd, 100); }); } @@ -1325,15 +1368,47 @@ test(`${nm('enemy_1050_lslime')}: 4 targets, burning DoT on hit, ASPD up below h assert.ok(h.eventsOf('fx').some((f) => f[1] === 'explode' && f[4].kind === 'selfBlast')); }); -test(`${nm('enemy_1500_skulsr')}: unblocked grenades splash and lower DEF; ATK up below half`, () => { - const h = arena({ units: [{ chessId: 't_wall', row: 10, col: 5 }, { chessId: 't_wall2', row: 11, col: 5 }], hooks: ['statusApplied'] }); +// PRTS 碎骨 天赋: "未被阻挡时发射榴弹对目标及其周围八格的我方单位造成相当于攻击力26%的物理伤害,并令其在5秒内防御力下降50%" ("榴弹对主 +// 目标造成物理普通伤害,对溅射目标造成物理溅射伤害") — community report #14: until 0.1.3 the target took a full attack and the 4 +// orthogonal neighbours 100 % ATK (no diagonal), DEF down for 3 s +test(`${nm('enemy_1500_skulsr')}: an unblocked attack is a grenade — 26 % ATK on the target and on its 8 surrounding tiles, DEF −50 % for 5 s; blocked a plain hit; ATK up below half`, () => { + const h = arena({ units: [{ chessId: 't_wall', row: 11, col: 3 }, { chessId: 't_wall2', row: 12, col: 6 }, { chessId: 't_wall3', row: 11, col: 5 }], captureNoisy: true, hooks: ['statusApplied', 'damaged'] }); h.step(); const e = put(h, 'enemy_1500_skulsr', [10, 7]); h.runUntil(() => e.stats.attacks >= 1, 10); - h.run(0.5); - assert.ok(statuses(h, h.unit('t_wall2').id, 'defDown').length >= 1); + h.run(1); + const by = (u) => h.hooksOf('damaged').filter((c) => c.source === e && c.target === u); + const main = h.unit('t_wall3'), diag = h.unit('t_wall2'), far = h.unit('t_wall'); + assert.equal(by(main).length, 1); + assert.ok(by(main)[0].dmg.isAttack, 'the target: the attack itself (普通伤害)'); + approx(by(main)[0].amount, 0.26 * e.s.atk); + assert.equal(by(diag).length, 1, 'a diagonal neighbour is splashed (周围八格)'); + assert.ok(!by(diag)[0].dmg.isAttack && by(diag)[0].dmg.tags.includes('splash')); + approx(by(diag)[0].amount, 0.26 * e.s.atk); + assert.equal(by(far).length, 0, 'two tiles away: outside the 3×3'); + for (const u of [main, diag]) { + const d = statuses(h, u.id, 'defDown'); + assert.equal(d.length, 1); + approx(d[0].duration, 5); + approx(d[0].value, -tb('enemy_1500_skulsr', 'defdown.def')); + } + assert.equal(statuses(h, far.id, 'defDown').length, 0); h.b.dealDamage(null, e, { amount: e.s.maxHp * 0.6, type: 'true' }); approx(e.s.atk, E.enemy_1500_skulsr.stats.atk * (1 + tb('enemy_1500_skulsr', 'atkup.atk'))); + // blocked: its plain melee hit, no splash, no DEF down + const hb = arena({ units: [{ chessId: 't_wall', row: 10, col: 7 }, { chessId: 't_wall2', row: 11, col: 7 }], captureNoisy: true, hooks: ['statusApplied', 'damaged'] }); + hb.step(); + const eb = put(hb, 'enemy_1500_skulsr', [10, 7]); + hb.step(); + eb.atkCd = 0; // (its first tick, before the block, threw a grenade) + const n0 = hb.hooksOf('damaged').length, s0 = hb.hooksOf('statusApplied').length; + hb.runUntil(() => hb.hooksOf('damaged').slice(n0).some((c) => c.source === eb && c.dmg.isAttack), 10); + hb.run(0.5); + assert.equal(eb.blockedBy, hb.unit('t_wall')); + const hits = hb.hooksOf('damaged').slice(n0).filter((c) => c.source === eb); + assert.deepEqual(hits.map((c) => [c.target.defId, c.dmg.isAttack]), [['t_wall', true]]); + approx(hits[0].amount, eb.s.atk); + assert.equal(hb.hooksOf('statusApplied').slice(s0).filter((c) => c.status === 'defDown').length, 0); }); test(`${nm('enemy_1502_crowns')}: blinks past its blocker`, () => { @@ -1432,14 +1507,24 @@ test(`${nm('enemy_1539_reid')}: ATK up below half; first KO ⇒ ${tb('enemy_1539 approx(e.s.atk, E.enemy_1539_reid.stats.atk * (1 + tb('enemy_1539_reid', 'AtkUp.atk'))); killed(h, e, null); assert.ok(!e.alive); - // 【冲锋】 - const h2 = arena({ units: [{ chessId: 't_wall', row: 10, col: 6 }] }); - h2.step(); - const r = put(h2, 'enemy_1539_reid', [10, 7], { move: true }); - h2.step(2); + // 【冲锋】 (PRTS: "仅自身未被阻挡且存在符合条件的可选目标时可触发:选择3.0半径内位于自身下个检查点前的后续路径上(包含自身当前所在地块) + // 的距离自身最近的我方单位"; 0.1.3: any unit within the blackboard's 1.5, 隐匿 / 迷彩 ones too, off its path too) const rush = skb('enemy_1539_reid', 'Rush'); - assert.ok(r.findBuff('ab:rush'), 'a unit within range_radius'); + const charge = (units, status = null) => { + const hh = arena({ units }); + hh.step(); + if (status) for (const u of hh.allies()) hh.b.applyStatus(u, status, { duration: 99, source: u }); + const r = put(hh, 'enemy_1539_reid', [9, 8], { move: true }); // route 0 walks row 9 towards (9, 2) + hh.step(2); + return r; + }; + const r = charge([{ chessId: 't_wall', row: 9, col: 5 }]); // on its path, 3 tiles ahead + assert.ok(r.findBuff('ab:rush'), 'a unit on its path within 3.0'); approx(r.s.moveSpeed, E.enemy_1539_reid.stats.moveSpeed * (1 + rush.bb.move_speed)); + assert.ok(!charge([{ chessId: 't_wall', row: 10, col: 7 }]).findBuff('ab:rush'), 'a unit next to it but off its path: no charge'); + assert.ok(!charge([{ chessId: 't_wall', row: 9, col: 4 }]).findBuff('ab:rush'), '4 tiles ahead: beyond 3.0'); + assert.ok(!charge([{ chessId: 't_wall', row: 9, col: 6 }], 'stealth').findBuff('ab:rush'), 'a 隐匿 unit is no selectable target'); + assert.ok(!charge([{ chessId: 't_wall', row: 9, col: 6 }], 'camou').findBuff('ab:rush'), 'nor a 迷彩 one'); }); test(`${nm('enemy_2003_rockman')}: boulder stuns a non-stunned unit for ${skb('enemy_2003_rockman', 'StunAttack').bb.stun} s`, () => { @@ -1733,15 +1818,24 @@ test(`${nm('enemy_1512_mcmstr')}: melee ×${tb('enemy_1512_mcmstr', 'combat.atta h.step(); const e = put(h, 'enemy_1512_mcmstr', [9, 5]); e.bounty = { coins: 4, ownerPlayerId: 'p1' }; - h.runUntil(() => e.stats.attacks >= 1, 10); - approx(h.hooksOf('damaged').find((c) => c.source === e).amount, e.s.atk * tb('enemy_1512_mcmstr', 'combat.attack@mcmstr_rage_attack.atk_scale')); + h.step(); + e.atkCd = 0; // (its very first tick, before the block, was a ranged attack) + const n0 = h.hooksOf('damaged').length; + h.runUntil(() => h.hooksOf('damaged').slice(n0).some((c) => c.source === e && c.dmg.isAttack), 10); + assert.equal(e.blockedBy, h.unit('t_wall')); + approx(h.hooksOf('damaged').slice(n0).find((c) => c.source === e && c.dmg.isAttack).amount, e.s.atk * tb('enemy_1512_mcmstr', 'combat.attack@mcmstr_rage_attack.atk_scale')); killed(h, e, h.unit('t_wall')); assert.ok(e.alive); assert.equal(h.result().perPlayer.p1.coins, 0, 'no bounty for the first form'); + // PRTS: "重生开始的2.17s后进行自爆,对半径3.0范围内所有我方单位造成攻击力150%的物理伤害和16s晕眩" (0.1.3: at once, radius 2.5) + assert.equal(statuses(h, h.unit('t_wall').id, 'stun').length, 0, 'not yet: 2.17 s into the 重生'); + h.run(2.1); + assert.equal(statuses(h, h.unit('t_wall').id, 'stun').length, 0); + h.run(0.2); const st = statuses(h, h.unit('t_wall').id, 'stun'); assert.equal(st.length, 1); approx(st[0].duration, skb('enemy_1512_mcmstr', 'bomb[reborning]').bb.stun); - h.run(tb('enemy_1512_mcmstr', 'reborn.duration') + 0.1); + h.run(tb('enemy_1512_mcmstr', 'reborn.duration') - 2.3 + 0.1); assert.ok(e.profile.noAttack && e.s.flags.unblockable); assert.equal(e.s.def, tb('enemy_1512_mcmstr', 'bird_run.def')); assert.equal(e.s.res, tb('enemy_1512_mcmstr', 'bird_run.magic_resistance')); @@ -1864,10 +1958,13 @@ test(`${nm('enemy_1525_blkswb')}: 抵抗; ignores ${tb('enemy_1525_blkswb', 'Def approx(statuses(h2, cs.id, 'disarm')[0].duration, tb('enemy_1525_blkswb', 'ClearSp.duration')); }); -test(`${nm('enemy_1535_wlfmster')}: −${tb('enemy_1535_wlfmster', 'Passive.damage_resistance') * 100} % damage and not stunnable; 溶血骇惧 (3 units, ASPD ${skb('enemy_1535_wlfmster', 'FearCage').bb.attack_speed}, rising HP loss, cured after it loses ${skb('enemy_1535_wlfmster', 'FearCage').bb.hp_ratio * 100} %); KO ⇒ ${tb('enemy_1535_wlfmster', 'Reborn.duration')} s 远古威慑 ⇒ ranged double hits`, () => { +test(`${nm('enemy_1535_wlfmster')}: −${tb('enemy_1535_wlfmster', 'Passive.damage_resistance') * 100} % damage and not stunnable; 溶血骇惧 (3 units, ASPD ${skb('enemy_1535_wlfmster', 'FearCage').bb.attack_speed}, HP loss rising to ${skb('enemy_1535_wlfmster', 'FearCage').bb.hp_ratio * 100} %/s over ${skb('enemy_1535_wlfmster', 'FearCage').bb.duration_bleed} s, no end, cured after it loses ${-skb('enemy_1535_wlfmster', 'FearCage').bb.hp_ratio_offset * 100} %); KO ⇒ ${tb('enemy_1535_wlfmster', 'Reborn.duration')} s 远古威慑 (r 1.5, −50) ⇒ ranged double hits within 1.25, 远古威慑 still on`, () => { + // PRTS 扎罗,“狼之主”: 天赋 "【远古威慑】:自身1.5半径范围内的我方单位攻击速度-50(指定状况下生效)", "重生期间【远古威慑】生效", second form + // "【远古威慑】生效 … 攻击范围半径1.25"; 溶血骇惧 "逐渐流失生命(从0/秒开始线性递增,在40秒后达到最大流失速度30%最大生命值/秒),持续时间 + // 无限 … 累计损失20%生命值后解除" (0.1.3: 2.5 / −30 only while reborning, range 2.5, a 5 % peak for 40 s, cured at 30 %) const h = arena({ units: [{ chessId: 't_wall', row: 10, col: 5 }, { chessId: 't_wall2', row: 11, col: 5 }, { chessId: 't_wall3', row: 12, col: 5 }, { chessId: 't_wall4', row: 9, col: 5 }], hooks: ['statusApplied'] }); h.step(); - const e = put(h, 'enemy_1535_wlfmster', [10, 7]); + const e = put(h, 'enemy_1535_wlfmster', [10, 6]); e.profile.noAttack = true; approx(e.s.dmgTakenMul, 1 - tb('enemy_1535_wlfmster', 'Passive.damage_resistance')); assert.equal(h.b.applyStatus(e, 'stun', { duration: 2 }), false); @@ -1876,18 +1973,35 @@ test(`${nm('enemy_1535_wlfmster')}: −${tb('enemy_1535_wlfmster', 'Passive.dama const caged = h.allies().filter((u) => u.findBuff('ab:fearCage')); assert.equal(caged.length, fc.bb.max_target); assert.equal(caged[0].s.aspd, 100 + fc.bb.attack_speed); - const hp0 = caged[0].hp; + const hp0 = caged[0].hp, mh = caged[0].s.maxHp; h.run(10); - assert.ok(caged[0].hp < hp0, 'losing HP'); - h.b.loseHp(e, e.s.maxHp * (fc.bb.hp_ratio + 0.01)); + // 1 tick per second at maxHp × hp_ratio × t / duration_bleed: 10 ticks ≈ 30 % × (0+1+…+10)/40 of max HP + approx((hp0 - caged[0].hp) / mh, fc.bb.hp_ratio * 55 / fc.bb.duration_bleed, 0.03, 'linear loss rate'); + assert.equal(caged[0].findBuff('ab:fearCage').duration, Infinity, '"持续时间无限"'); + e.mem.ab.list.find((a) => a.fire).left = 0; // its cooldown over: still no cast while units are caught + h.step(3); + assert.equal(h.allies().filter((u) => u.findBuff('ab:fearCage')).length, fc.bb.max_target, 'no new cast while units are caught (静默)'); + h.b.loseHp(e, e.s.maxHp * (-fc.bb.hp_ratio_offset - 0.02)); h.run(0.3); - assert.equal(h.allies().filter((u) => u.findBuff('ab:fearCage')).length, 0, 'cured'); + assert.equal(h.allies().filter((u) => u.findBuff('ab:fearCage')).length, fc.bb.max_target, 'not yet cured at 18 %'); + h.b.loseHp(e, e.s.maxHp * 0.03); + h.run(0.3); + assert.equal(h.allies().filter((u) => u.findBuff('ab:fearCage')).length, 0, 'cured at 20 %'); + h.run(fc.bb.duration_wait - 0.7); + assert.equal(h.allies().filter((u) => u.findBuff('ab:fearCage')).length, 0, '"场上技能效果全部结束时狼之主获得7s静默"'); + h.run(0.6); + assert.equal(h.allies().filter((u) => u.findBuff('ab:fearCage')).length, fc.bb.max_target, 'cast again once the 静默 is over'); killed(h, e, null); + assert.equal(h.allies().filter((u) => u.findBuff('ab:fearCage')).length, 0, '重生: every 溶血骇惧 ends'); assert.ok(e.alive && e.s.flags.untargetable); h.run(tb('enemy_1535_wlfmster', 'Reborn.duration') / 2); approx(e.hpRatio, 0.5, 0.05, 'HP refills during 远古威慑'); - assert.equal(h.unit('t_wall').s.aspd, 100 - 30, 'aura slows the units around it'); + assert.equal(h.unit('t_wall').s.aspd, 100 - 50, 'aura slows the units within 1.5'); + assert.equal(h.unit('t_wall4').s.aspd, 100 - 50, '√2 away: inside'); + assert.equal(h.unit('t_wall3').s.aspd, 100, '√5 away: outside'); h.run(tb('enemy_1535_wlfmster', 'Reborn.duration') / 2 + 0.1); + assert.equal(h.unit('t_wall').s.aspd, 100 - 50, 'second form: 远古威慑 still on'); + assert.equal(e.base.rangeRadius, 1.25, 'second-form attack radius'); approx(e.hpRatio, 1); assert.equal(e.s.dmgTakenMul, 1, 'no damage reduction any more'); assert.equal(e.profile.melee, false); @@ -2584,3 +2698,164 @@ test('determinism: the same boss round with the same seed produces the same outc assert.deepEqual(a, b); assert.equal(a[3], 0); }); + +// --------------------------------------------------------------------------------------------------------------- +// enemy numbers checked against PRTS in 0.1.3 (community report #14 and the 0.1.2 review list, DESIGN §22.12 "Found") + +/** The first normal attack of `e` that landed, its target and the other units its splash took. */ +function splashOf(h, e) { + const all = h.hooksOf('damaged').filter((c) => c.source === e); + const main = all.find((c) => c.dmg.isAttack); + return { main, target: main && main.target, splash: all.filter((c) => !c.dmg.isAttack && (c.dmg.tags || []).includes('splash')) }; +} + +test(`${nm('enemy_1050_lslime')}: the self-blast while blocked — radius 1.4, every 10 s of block (the clock restarts on a new block, stands still while stunned)`, () => { + // PRTS “庞贝” 天赋: "被阻挡时,每10秒(受晕眩/无法行动/沉睡/冻结/浮空影响时暂停计时,解除阻挡时重置计时)对半径1.4范围内的所有我方单位造成 + // 1000预计算的无途径法术溅射伤害(不可对空)" (0.1.3: radius 1, a fixed 10 s clock from its spawn) + const h = arena({ units: [{ chessId: 't_wall', row: 9, col: 5 }, { chessId: 't_wall2', row: 10, col: 5 }, { chessId: 't_wall3', row: 10, col: 6 }], hooks: ['damaged'], captureNoisy: true }); + h.step(); + const e = put(h, 'enemy_1050_lslime', [9, 5], { mods: { atkMul: 0 } }); + const blasts = () => h.hooksOf('damaged').filter((c) => c.source === e && !c.dmg.isAttack && c.dmg.tags.includes('splash')); + h.run(5); + h.b.applyStatus(e, 'stun', { duration: 2 }); // 2 s paused + h.run(6.5); // 11.5 s, 9.5 s of it counting + assert.equal(blasts().length, 0, 'the stun paused the clock'); + h.run(0.7); + const hit = blasts().map((c) => c.target.defId).sort(); + assert.deepEqual(hit, ['t_wall', 't_wall2'], 'its blocker and the unit 1.0 away; √2 is outside 1.4'); + approx(blasts()[0].amount, tb('enemy_1050_lslime', 'rangedamage.attack@damage')); + // unblocked for a moment: the clock restarts + const n = blasts().length; + h.run(4); + h.b._unblock(e); + h.unit('t_wall').s.flags.noBlock = true; + h.step(); + delete h.unit('t_wall').s.flags.noBlock; + h.run(8); + assert.equal(blasts().length, n, 'a new block: 10 s again from its start'); + h.run(2.5); + assert.ok(blasts().length > n); +}); + +test(`${nm('enemy_10122_uacann_2')}: the shell hits every unit within 1.0 of its target (100 % ATK, not flyers); the burning zone has radius 1.5`, () => { + // PRTS 集团军重型火炮 天赋: "普通攻击向目标发射一枚炮弹,对目标半径1.0范围内的所有我方单位造成攻击力100%的物理伤害(此弹道会强制击中主目标, + // 碰撞无视迷彩,不可对空)", 【燃烧区域】 "<1.5倍可变半径>碰撞半径1.5" (0.1.3: no splash, zone radius 1) + const h = arena({ units: [{ chessId: 't_wall', row: 10, col: 3 }, { chessId: 't_wall2', row: 11, col: 3 }, { chessId: 't_wall3', row: 10, col: 4 }, { chessId: 't_wall4', row: 12, col: 3 }], hooks: ['damaged'], captureNoisy: true }); + h.step(); + const e = put(h, 'enemy_10122_uacann_2', [10, 9]); + h.runUntil(() => e.stats.attacks >= 1, 10); + h.run(2); + const { target, splash } = splashOf(h, e); + assert.ok(target); + const near = h.allies().filter((u) => u !== target && Math.hypot(u.x - target.x, u.y - target.y) <= 1 + 1e-6); + assert.ok(near.length >= 1); + assert.deepEqual(splash.map((c) => c.target).filter((u, i, l) => l.indexOf(u) === i).sort((a, b) => a.id - b.id), near.sort((a, b) => a.id - b.id)); + approx(splash[0].amount, e.s.atk); + const zone = h.eventsOf('fx').find((f) => f[1] === 'zone' && f[4].kind === 'burning'); + approx(zone[4].r, tb('enemy_10122_uacann_2', 'ProjectileBoomRange.attack@projectile_range') * 1.5); +}); + +test(`${nm('enemy_2008_flking')}: unblocked, a ranged attack at ${tb('enemy_2008_flking', 'atkdown.atk_scale') * 100} % ATK on the target and its 8 surrounding tiles; blocked, a melee hit at 100 %`, () => { + // PRTS “墓碑” 天赋: "自身造成的远程途径伤害的攻击倍率降低至40%", "未被阻挡时会进行远程攻击,对目标及其周围八格内的所有我方单位造成物理伤害, + // 不会攻击飞行单位" (0.1.3: a single-target hit at 100 %) + const h = arena({ units: [{ chessId: 't_wall', row: 10, col: 5 }, { chessId: 't_wall2', row: 11, col: 6 }, { chessId: 't_wall3', row: 12, col: 6 }], hooks: ['damaged'], captureNoisy: true }); + h.step(); + const e = put(h, 'enemy_2008_flking', [10, 7]); + h.runUntil(() => e.stats.attacks >= 1, 10); + h.run(1); + const { main, target, splash } = splashOf(h, e); + const scale = tb('enemy_2008_flking', 'atkdown.atk_scale'); + approx(main.amount, e.s.atk * scale); // (the walls' DEF is 0, halved by its own aura) + const box = h.allies().filter((u) => u !== target && Math.max(Math.abs(u.tileR - target.tileR), Math.abs(u.tileC - target.tileC)) <= 1); + assert.ok(box.length >= 1); + for (const u of box) assert.ok(splash.some((c) => c.target === u), `${u.defId} in the 3×3`); + assert.ok(splash.every((c) => box.includes(c.target))); + approx(splash[0].amount, e.s.atk * scale); + const hb = arena({ units: [{ chessId: 't_wall', row: 10, col: 7 }, { chessId: 't_wall2', row: 11, col: 7 }], hooks: ['damaged'], captureNoisy: true }); + hb.step(); + const eb = put(hb, 'enemy_2008_flking', [10, 7]); + hb.step(); + eb.atkCd = 0; + const n0 = hb.hooksOf('damaged').length; + hb.runUntil(() => hb.hooksOf('damaged').slice(n0).some((c) => c.source === eb && c.dmg.isAttack), 10); + hb.run(1); + const hits = hb.hooksOf('damaged').slice(n0).filter((c) => c.source === eb); + assert.deepEqual(hits.map((c) => [c.target.defId, c.dmg.isAttack]), [['t_wall', true]], 'blocked: its blocker only'); + approx(hits[0].amount, eb.s.atk); +}); + +test(`${nm('enemy_1512_mcmstr')}: unblocked, a ranged attack (radius 2.5, ground units) on the target and its 8 surrounding tiles at 100 %; the self-destruct reaches 3.0`, () => { + // PRTS “巨大的丑东西” 天赋: "未被阻挡时,可对半径2.5范围内的1名非飞行的我方单位进行远程攻击,对目标及其周围8格内的所有我方单位造成攻击力100%的 + // 物理伤害", "重生开始的2.17s后进行自爆,对半径3.0范围内所有我方单位造成攻击力150%的物理伤害和16s晕眩" (0.1.3: no ranged attack, radius 2.5) + const h = arena({ units: [{ chessId: 't_wall', row: 11, col: 5 }, { chessId: 't_wall2', row: 12, col: 6 }, { chessId: 't_wall3', row: 9, col: 4 }], hooks: ['damaged', 'statusApplied'], captureNoisy: true }); + h.step(); + const e = put(h, 'enemy_1512_mcmstr', [10, 7]); + h.runUntil(() => e.stats.attacks >= 1, 10); + h.run(1); + const { main, target, splash } = splashOf(h, e); + approx(main.amount, e.s.atk); + assert.ok(Math.hypot(target.x - e.x, target.y - e.y) <= 2.5 + 0.25 + 1e-6); + const box = h.allies().filter((u) => u !== target && Math.max(Math.abs(u.tileR - target.tileR), Math.abs(u.tileC - target.tileC)) <= 1); + for (const u of box) assert.ok(splash.some((c) => c.target === u), `${u.defId} in the 3×3`); + assert.ok(splash.every((c) => box.includes(c.target))); + killed(h, e, null); + h.run(2.3); + const st = h.hooksOf('statusApplied').filter((c) => c.status === 'stun').map((c) => c.target.defId).sort(); + // t_wall (11,5): √5 ≈ 2.24, t_wall2 (12,6): √5, t_wall3 (9,4): √10 ≈ 3.16 — outside 3.0 + assert.deepEqual(st, ['t_wall', 't_wall2']); +}); + +test(`${nm('enemy_1535_wlfmster')}: leaking in its first form ends every 溶血骇惧 it cast — no drain afterwards`, () => { + // [ASSUMED] (no official text): the buff ends once its 扎罗 is off the field without a 重生 (a leak, a removal); until + // 0.1.3 the drain went on ramping after a leak and knocked the units out (Grok review of fb3-enemies-kits) + const h = arena({ units: [{ chessId: 't_wall', row: 10, col: 5 }, { chessId: 't_wall2', row: 11, col: 5 }, { chessId: 't_wall3', row: 12, col: 5 }] }); + h.step(); + const e = put(h, 'enemy_1535_wlfmster', [10, 7]); + e.profile.noAttack = true; + const fc = skb('enemy_1535_wlfmster', 'FearCage'); + h.run(fc.spCost + 3.1); + const caged = h.allies().filter((u) => u.findBuff('ab:fearCage')); + assert.equal(caged.length, fc.bb.max_target); + h.b.leak(e); + assert.ok(!e.alive); + const hp = caged.map((u) => u.hp); + h.run(3); + assert.equal(h.allies().filter((u) => u.findBuff('ab:fearCage')).length, 0, 'every 溶血骇惧 ended'); + assert.deepEqual(caged.map((u) => u.hp), hp, 'no HP lost after the leak'); +}); + +test(`${nm('enemy_1203_sfhu')}: its attack splash (法术普通伤害) can be dodged; its death blast (法术溅射伤害) cannot`, () => { + // PRTS 烹泉 天赋: "普通攻击对目标对及目标周围半径1.0范围内的所有我方单位造成法术普通伤害", "死亡爆炸(…造成攻击力100%法术溅射伤害…)" + const h = arena({ units: [{ chessId: 't_gun', row: 10, col: 6 }, { chessId: 't_wall', row: 11, col: 6 }, { chessId: 't_wall2', row: 10, col: 5 }], hooks: ['damaged', 'dodge'], captureNoisy: true }); + h.step(); + const g = h.unit('t_gun'), w = h.unit('t_wall'), dodger = h.unit('t_wall2'); + h.b.addBuff(dodger, { key: 'test:dodge', persist: true, mods: { dodgeArts: 1 } }); + const e = put(h, 'enemy_1203_sfhu', [10, 7]); + h.runUntil(() => h.hooksOf('damaged').some((c) => c.source === e && c.dmg.isAttack && c.target === g), 10); + h.run(0.3); + approx(w.stats.taken, e.s.atk, 1e-6, 'the plain neighbour: splashed'); + assert.equal(dodger.stats.taken, 0, 'the arts-dodge neighbour dodges the splash'); + assert.ok(h.hooksOf('dodge').some((c) => c.target === dodger && c.source === e)); + h.b.addBuff(g, { key: 'test:dodge', persist: true, mods: { dodgeArts: 1 } }); + const t0 = g.stats.taken, atk = e.s.atk; + killed(h, e, null); + approx(g.stats.taken - t0, atk, 1e-6, 'the death blast is not dodged'); +}); + +test(`${nm('enemy_10122_uacann_2')}: its 燃烧区域 (不可对空) spares a flying ally standing in it`, () => { + // PRTS 集团军重型火炮: 【燃烧区域】 "…(可叠加,碰撞不受迷彩制约,不可对空)" — until 0.1.3 the 炎佑 dragon in it burned + const h = makeBattle({ seed: 7, autoFinish: false, timeLimit: 120, defs: { chess: { t_wall: WALL('t_wall') } }, kits: { t_wall: NOATK }, + units: [{ chessId: 't_wall', row: 10, col: 2 }], hooks: ['damaged'], captureNoisy: true }); + h.step(); + const [y] = spawnYanyou(h.b, 'p1', { atk: 0, hp: 50000 }); + assert.ok(y && y.isFlying, 'a flying ally'); + const e = h.spawn('enemy_10122_uacann_2', { pos: [10, 9], routeIndex: 0, mods: { speedMul: 0 } }); + const w = h.unit('t_wall'); + assert.ok(Math.hypot(y.x - w.x, y.y - w.y) <= 1.5, 'the dragon stands inside the zone around the wall'); + h.runUntil(() => e.stats.attacks >= 1, 15); + h.run(3.5); + const burns = h.hooksOf('damaged').filter((c) => c.source === e && (c.dmg.tags || []).includes('burning')); + assert.ok(burns.some((c) => c.target === w), 'the wall burns'); + assert.ok(!burns.some((c) => c.target === y), 'the dragon does not'); + assert.ok(!h.hooksOf('damaged').some((c) => c.source === e && c.target === y), 'nor is it shot or splashed'); +}); diff --git a/test/content/kits_alt_t3.test.js b/test/content/kits_alt_t3.test.js index 2fea0b59..6d4fb48f 100644 --- a/test/content/kits_alt_t3.test.js +++ b/test/content/kits_alt_t3.test.js @@ -226,6 +226,50 @@ test('3_04 琳琅诗怀雅 S1 仗义疏财: 2 coins; an attack spends one to hea } }); +// Owner's decision 2026-10-04 (community report #5 「琳琅诗怀雅1技能不会主动奶身边受伤的干员」), a deliberate deviation from the official +// 「下一次攻击会为…」: S1 heals an injured ally beside her without an attack or an enemy — at most once per attack cycle +// (her attack interval, ASPD included) [ASSUMED cadence]; with an enemy the heal stays on her attack. +test('3_04 琳琅诗怀雅 S1 仗义疏财 (owner\'s decision): an injured ally beside her is healed with no enemy on the field, once per attack cycle; with an enemy, once per attack', () => { + const id = 'chess_char_3_04_a'; + const b = SB(id, 'skchr_swire2_1'); + const mk = (enemies = []) => makeBattle({ defs: { enemies: { enemy_d: dummy('enemy_d') } }, timeLimit: 60, autoFinish: false, hooks: ['heal', 'attack'], captureNoisy: true, + units: [U(id, 'skchr_swire2_1', 9, 5), { chessId: 'chess_char_3_16_a', row: 10, col: 5 }], enemies }); + // no enemy: an ally at 80 % is left alone, one at 50 % is healed and a coin is spent + let h = mk(); + let u = h.unit(id), ally = h.unit('chess_char_3_16_a'); + h.step(); + u.mem.coins = 2; + ally.hp = ally.s.maxHp * 0.8; + h.run(3); + assert.equal(h.hooksOf('heal').filter((c) => c.source === u).length, 0, '80 %: not below 70 %'); + assert.equal(u.mem.coins, 2, 'the coin is kept'); + ally.hp = ally.s.maxHp * 0.5; + h.step(); + const heals = h.hooksOf('heal').filter((c) => c.source === u); + assert.equal(heals.length, 1, 'healed at once, no enemy needed'); + assert.equal(heals[0].target, ally); + approx(heals[0].amount, u.s.atk * b['attack@heal_scale']); + assert.equal(u.mem.coins, 1, 'one coin spent'); + assert.equal(h.hooksOf('attack').filter((c) => c.attacker === u).length, 0, 'without an attack'); + // kept injured with coins to spare: one heal per attack interval, never more + const n0 = h.hooksOf('heal').length, t0 = h.b.time; + for (let i = 0; i < 300; i++) { ally.hp = ally.s.maxHp * 0.3; u.mem.coins = 2; h.step(); } + const per = h.hooksOf('heal').slice(n0).filter((c) => c.source === u).length; + assert.equal(per, Math.floor((h.b.time - t0) / u.s.interval + 1e-6), `one heal per ${u.s.interval} s`); + done(h); + // with an enemy in her range: the heal rides on each attack, never a second one in the cycle + h = mk([{ key: 'enemy_d', pos: [9, 6] }]); + u = h.unit(id); ally = h.unit('chess_char_3_16_a'); + h.step(); + const a0 = h.hooksOf('attack').length, h0 = h.hooksOf('heal').length; // (its first attack, at deployment, found the ally unhurt) + for (let i = 0; i < 300; i++) { ally.hp = ally.s.maxHp * 0.3; u.mem.coins = 2; h.step(); } + const atk = h.hooksOf('attack').slice(a0).filter((c) => c.attacker === u).length; + const hl = h.hooksOf('heal').slice(h0).filter((c) => c.source === u).length; + assert.ok(atk >= 5, `attacks (${atk})`); + assert.equal(hl, atk, 'one heal per attack'); + done(h); +}); + test('3_04 琳琅诗怀雅 S3 千金一掷: 二连击, kills pay a coin, a full purse is cashed out on the front range (hits + push)', () => { for (const id of BOTH('chess_char_3_04_a')) { const b = SB(id, 'skchr_swire2_3'), t0 = TB(id, 0); diff --git a/test/content/kits_alt_t6.test.js b/test/content/kits_alt_t6.test.js index af52643b..196d082a 100644 --- a/test/content/kits_alt_t6.test.js +++ b/test/content/kits_alt_t6.test.js @@ -194,12 +194,16 @@ test('6_02 圣聆初雪 S1 铃音吹雪: 2 charges (cast with enemies in range); } }); -test('6_02 圣聆初雪 S2 霜涛覆岭: toggle; group attacks at atk_scale_s2 × ATK; ground enemies on snow take 20 %/s, leaving snow chills, 5 layers freeze the tile (保护目标)', () => { +// PRTS 圣聆初雪 S2 霜涛覆岭 "积雪在目标点积累至5层时,使目标点变为冻结状态", 备注 "目标点冻结的实际效果为令圣聆初雪在该地块上召唤一个保护目标 +// (冻结状态)(无视部署属性),并去除相应地块上的积雪(且存在自身的该召唤物的地块不会积雪)", "可以被'变为冻结状态'的目标点包括常规的保护目标点…"; +// the token's page: "技能发动后于保护目标叠加5层积雪". Community report #32: until 0.1.3 any free standable tile froze and the blue +// gate never did (nobody can stand on it). +test('6_02 圣聆初雪 S2 霜涛覆岭: toggle; group attacks at atk_scale_s2 × ATK; ground enemies on snow take 20 %/s, leaving snow chills, 5 layers on the protection point freeze it (保护目标(冻结状态)), no other tile', () => { for (const id of both('chess_char_6_02')) { const sid = 'skchr_sbell2_2', bb = bbOf(id, sid); const h = run({ defs: { enemies: { e: dummy('e'), w: enemyRec({ key: 'w', hp: 1e7, speed: 1 }) } }, - units: [U(id, sid, 10, 4, { carryState: READY })], enemies: [{ key: 'e', pos: [10, 5] }, { key: 'w', route: 0, time: 1 }], + units: [U(id, sid, 10, 3, { carryState: READY })], enemies: [{ key: 'e', pos: [10, 5] }, { key: 'w', route: 0, time: 1 }, { key: 'w', route: 0, time: 40 }], }); const u = h.unit(id); usesSkill(u, sid); @@ -218,13 +222,30 @@ test('6_02 圣聆初雪 S2 霜涛覆岭: toggle; group attacks at atk_scale_s2 approx(dot[0].amount, u.s.atk * bb['talent@s2_magic_scale'] * u.s.dmgDealtMul, '20 % ATK per second'); assert.ok(h.runUntil(() => statuses(h, 'cold', (c) => c.source === u && isKey(c.target, 'w')).length > 0, 20), 'leaving snow ⇒ cold'); approx(statuses(h, 'cold', (c) => c.source === u && isKey(c.target, 'w'))[0].duration, bb['talent@cold'], 'cold duration'); - // a free ground tile of her range at 4 layers: the next layer freezes it + const iceOf = () => h.b.allyUnits.find((t) => t.defId === 'token_10058_sbell2_icetgt' && t.alive); + // a free ground tile of her range reaching 5 layers: no freeze (until 0.1.3 it turned into the token) const fk = 11 * COLS + 4; + assert.ok(u.rangeKeys.includes(fk)); u.mem.snow.set(fk, 4); - assert.ok(h.runUntil(() => h.b.allyUnits.some((t) => t.defId === 'token_10058_sbell2_icetgt' && t.alive), 7), '保护目标(冻结状态)'); - const ice = h.b.allyUnits.find((t) => t.defId === 'token_10058_sbell2_icetgt' && t.alive); - assert.deepEqual([ice.tileR, ice.tileC], [11, 4]); + h.run(7); + assert.equal(u.mem.snow.get(fk), 5); + assert.equal(iceOf(), undefined, 'only a protection point freezes'); + // the blue gate (9,2) — build NONE, nobody may stand there — at 4 layers: the next layer freezes it + const gk = 9 * COLS + 2; + assert.equal(h.b.grid.tile(9, 2).special, 'end'); + assert.ok(u.rangeKeys.includes(gk)); + u.mem.snow.set(gk, 4); + assert.ok(h.runUntil(() => !!iceOf(), 7), '保护目标(冻结状态)'); + const ice = iceOf(); + assert.deepEqual([ice.tileR, ice.tileC], [9, 2]); assert.equal(ice.s.blockCnt, 3); + assert.equal(u.mem.snow.get(gk), undefined, 'its snow is used up'); + h.run(7); + assert.equal(u.mem.snow.get(gk), undefined, 'no snow gathers under her token'); + // a walker reaching the gate is held there by it, not leaked + const leaks0 = h.result().perPlayer.p1.leaked.length; + assert.ok(h.runUntil(() => h.b.enemies.some((x) => x.alive && isKey(x, 'w') && x.blockedBy === ice), 60), 'blocked by the frozen gate'); + assert.equal(h.result().perPlayer.p1.leaked.length, leaks0); done(h); } }); diff --git a/test/render/unitview.test.js b/test/render/unitview.test.js index 09db64ba..f48ec0cd 100644 --- a/test/render/unitview.test.js +++ b/test/render/unitview.test.js @@ -86,6 +86,17 @@ describe('fallback portraits (avatar diamonds)', () => { assert.notEqual(v.fallback.texture, fake.P.Texture.EMPTY); }); + test('圣聆初雪 S2: the frozen gate (保护目标(冻结状态), no art in the data) is an ice diamond, not the plain placeholder', async () => { + const before = diamonds().length; + const v = view({ kind: 'token', defId: 'token_10058_sbell2_icetgt' }, {}, store()); // (an owner avatar would load) + await tick(); await tick(); + for (let i = 0; i < 3; i++) v.update(1 / 60, cam(), i / 60); + assert.equal(v._frameColor(), 0x9fe6ff, 'ice frame'); + assert.equal(diamonds().length - before, 1); + assert.notEqual(v.fallback.texture, T.diamondTexture('token_10058_sbell2_icetgt', null, 0x9fe6ff), 'its own (ice) glyph, not the procedural one'); + assert.equal(v.fallback.texture, T.diamondTexture('token_10058_sbell2_icetgt', null, 0x9fe6ff, { ice: true })); + }); + test('a slow avatar shows the placeholder meanwhile, then the picture', async () => { const before = diamonds().length; let release; diff --git a/test/sim/feedback2-stealth-splash.test.js b/test/sim/feedback2-stealth-splash.test.js index 82b662a0..79f7908f 100644 --- a/test/sim/feedback2-stealth-splash.test.js +++ b/test/sim/feedback2-stealth-splash.test.js @@ -95,7 +95,7 @@ test('areaSelectable: no 隐匿 ally unless it blocks the source, no untargetabl done(h); }); -/** 碎骨 walks lane row 9 and grenades 角峰 on (10,6); 古米 stands next to it (distance 1 = the splash radius). */ +/** 碎骨 walks lane row 9 and grenades 角峰 on (10,6); 古米 stands next to it (inside the grenade's 3×3 — 0.1.3, PRTS 碎骨). */ function grenades({ gm = [10, 7], items = [], status = null, seconds = 25 } = {}) { const h = makeBattle({ seed: 5, timeLimit: 120, autoFinish: false,