From bcb2786a5508605604a52492609effafbb15dcd1 Mon Sep 17 00:00:00 2001 From: sganggs Date: Sat, 3 Oct 2026 02:03:02 +0800 Subject: [PATCH 1/4] feedback1b WH: Vina Victoria S3 lions on every free tile; Mlynar S3 hits air units MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Player reports after 0.1.0 (second batch), both confirmed on the real match path: - B3 维娜·维多利亚 S3 俱以我之名 placed one 黄金盟誓. Official text "立即在天赋一生效范围内 可部署地面召唤“黄金盟誓”", EN client "Summons Golden Vows on deployable tiles within Talent 1's range": one on every free deployable melee tile of her talent-1 area (fences included). The token's maxDeployCount 1 is a hand count: tokens.js SKILL_SUMMON_UNCAPPED keeps the per-owner deploy limit from withdrawing them; the generic fallback places 8. - B4 玛恩纳 S3 未照耀的荣光 (CUSTOM_RANGE, counts flyers) fired on flying waves and hit nothing for 26 s: PRTS 备注 "※可对空" — the skill targeting now hits air units. Same note: the trait bonus drops by 10 % (absolute) per enemy knocked out by his own attack or the damage it carries, settled after the attack, floor +0 % (was −10 % of the x2 multiplier per kill, applied mid-attack, floored at the plain ramp, reflect kills counted). S2's "trait not reset" kill uses the same own-attack rule. Tests: test/content/feedback1b_kits.test.js (real Match → PlayerState → Match._normalOpts spec on 战场#05 with the R8 / flying R2 waves), kits_t6 adapted. --- docs/SIM.md | 7 +- server/sim/content/kits/tier5.js | 59 ++++++-- server/sim/content/kits/tier6.js | 26 ++-- server/sim/content/tokens.js | 21 ++- test/content/feedback1b_kits.test.js | 215 +++++++++++++++++++++++++++ test/content/kits_t6.test.js | 13 +- 6 files changed, 301 insertions(+), 40 deletions(-) create mode 100644 test/content/feedback1b_kits.test.js diff --git a/docs/SIM.md b/docs/SIM.md index 3894bb29..4d2bf878 100644 --- a/docs/SIM.md +++ b/docs/SIM.md @@ -818,7 +818,9 @@ chess_char_2_02_a: (bb) => ({ (the placed piece deploys once with the board — `SKILL_SUMMON_START_DEPLOY` — and then waits on its tile for the skill; the drone's stats come from `tokens.json variants[chess_char_2_02_a]`; it is untargetable, uses the heal profile and its token kit withdraws it after 10 s. A kit whose summon is not a hand piece spawns it itself: -`battle.spawnToken(unit, tokenId, r, c, { duration })`.) +`battle.spawnToken(unit, tokenId, r, c, { duration })`. A summon past its data `deployLimit` per owner withdraws the +oldest one, except the skill summons of `tokens.js SKILL_SUMMON_UNCAPPED`, whose data limit is a hand count the skill +ignores: 维娜 S3 places a 黄金盟誓 on every free deployable melee tile of her talent-1 area, fences included.) **6. AUTO heal skill — 古米 `chess_char_1_10_a` 备用军粮** (自动触发, bb `heal_scale 1.15`; PRTS 备注: cast once a healable ally of the skill range is injured — no enemy needed — then her next normal attack heals that ally): @@ -884,7 +886,8 @@ talent effects that pick their own victims follow the skill's text and PRTS 备 PRTS 选择器, is not in the data): "地面敌人" / "不可对空" ⇒ `!e.isFlying` (e.g. 隐德来希 S2 血镰, 归溟幽灵鲨 拥抱自我, 琳琅诗怀雅 S3 cash-out, 乌尔比安 S1 捕网 [ASSUMED like 雪雉's]); "可对空" or no note ⇒ air units too (no note = [ASSUMED]: 风丸 折纸生花, 乌尔比安 S3, 缄默德克萨斯 S2, 余 S2, 见行者 S2, 耀骑士临光 不畏苦暗 and the token appear bursts — “耀阳”, -沙之碑, 迷迭香的战术装备's stun, 纸偶). A stun / freeze / sleep from any of them drops a hovering 掠海漂移体 for good. Ranged attacks fly as projectiles +沙之碑, 迷迭香的战术装备's stun, 纸偶). A melee skill whose attacks PRTS marks "可对空" sets `targeting.canHitFly` (玛恩纳 +S3 未照耀的荣光 — its CUSTOM_RANGE trigger also counts flyers). A stun / freeze / sleep from any of them drops a hovering 掠海漂移体 for good. Ranged attacks fly as projectiles (`constants.js PROJECTILE_SPEEDS`: arrow 14, bolt 11, bomb/lob 8, orb 10, drone 16, enemy 10 tiles/s; boomerang 15 out, 3.75 back = `BOOMERANG_RETURN_SPEED`, PRTS 跃跃); melee/`none` hits are instant. Kit-settable profile flags beyond the table: `hitSleep` (targets and damages sleeping enemies — "可以攻击沉睡的敌人"), `onEachHit(b, u, victim, hctx)`, `dmgMul`, diff --git a/server/sim/content/kits/tier5.js b/server/sim/content/kits/tier5.js index d766208e..fe8dfdbb 100644 --- a/server/sim/content/kits/tier5.js +++ b/server/sim/content/kits/tier5.js @@ -2167,21 +2167,42 @@ const KITS = { }, // --------------------------------------------------------------------------------------------------------------- - // 玛恩纳 — librator (ramp to +200 % ATK while idle). S3 未照耀的荣光 (26 s, CUSTOM_RANGE): skill range, trait ×2 (−10 % - // per kill), 5 targets at 125/150 % ATK phys; enemies in range take +10/11 % of his ATK true damage from every Kazimierz - // attack. T1 游侠: ×1.1 ATK on attacks (×1.15 and −15 % damage taken with ≥3 enemies around). T2 无动于衷: taunt +1, - // Kazimierz ops reflect 15 % of his ATK as true damage when attacked. + // 玛恩纳 — librator (ramp to +200 % ATK while idle). S3 未照耀的荣光 (26 s, CUSTOM_RANGE): skill range, trait ×2, 5 + // targets at 125/150 % ATK phys, air units too (PRTS 备注 "※可对空"; player report B4 after 0.1.0: on a flying wave the + // cast hit nothing for 26 s); enemies in range take +10/11 % of his ATK true damage from every Kazimierz attack. Per + // PRTS 备注 the trait bonus drops by 10 % (per_kill_reduce, absolute: +400 % → +390 %) for each enemy knocked out by + // his own attack (or the damage it carries — not 无动于衷's reflection, not a mark another operator's attack set off), + // settled after that attack, never below +0 %. T1 游侠: ×1.1 ATK on attacks (×1.15 and −15 % damage taken with ≥3 + // enemies around). T2 无动于衷: taunt +1, Kazimierz ops reflect 15 % of his ATK as true damage when attacked. // S1 未声张的怒火 (duration, SEARCH): attacks attack@atk_scale × ATK, DEF +. S2 未宽解的悲哀 (duration): skill range, - // BAT +0.3 s, attacks attack@atk_scale × ATK twice; a kill during the skill keeps the trait ramp when it ends. + // BAT +0.3 s, attacks attack@atk_scale × ATK twice; a kill of his own attacks during the skill keeps the trait ramp + // when it ends. S1 / S2 have no 对空 note: ground only, like his trait. chess_char_5_19_a: (bb, chess, def) => { const t0 = talent(chess, 0), t1 = talent(chess, 1); const sid = selectedId(chess, def); const up = num(bb.trait_up, 1), perKill = num(bb.per_kill_reduce); + // S3: the trait bonus = ramp × trait_up + per_kill_reduce × kills, ≥ 0 — the trait's own ramp buff stays, the + // difference goes into `mlynar:traitUp` (negative once the kills take the bonus below the ramp) const applyUp = (battle, unit) => { - const extra = num(unit.trait.ramp) * Math.max(0, num(unit.mem.mlyMult, 1) - 1); - if (extra > 0) battle.addBuff(unit, { key: 'mlynar:traitUp', mods: { atkPct: extra } }); + const ramp = num(unit.trait.ramp); + const extra = unit.mem.mlyUp ? Math.max(0, ramp * up + perKill * num(unit.mem.mlyKills)) - ramp : 0; + if (extra) battle.addBuff(unit, { key: 'mlynar:traitUp', mods: { atkPct: extra } }); else battle.removeBuff(unit, 'mlynar:traitUp'); }; + /** + * "仅自身普通攻击(与该次攻击附带的伤害)击倒非角色类单位" (PRTS S2 / S3 备注): `onKill(victim)` for each enemy whose + * last damage from him came from his attack or the mark that attack set off (tagged 'mlynarOwn') while his skill + * runs; `onAttack()` after each of his attacks ("加成降低于当次攻击后统一结算"). + */ + const ownKills = (battle, unit, onKill, onAttack = null) => { + let last = null; + battle.on('damaged', (c) => { + if (c.source !== unit) return; + last = c.dmg?.isAttack || (c.dmg?.tags || []).includes('mlynarOwn') ? c.target : null; + }, { owner: unit, priority: 1000 }); + battle.on('kill', (c) => { if (c.killer === unit && c.victim === last && c.victim.side === 'enemy' && unit.skill?.active) onKill(c.victim); }, { owner: unit }); + battle.on('attack', (c) => { if (c.attacker !== unit) return; last = null; if (onAttack) onAttack(); }, { owner: unit }); + }; return { skills: lazySkills({ skchr_mlynar_1: () => ({ kind: 'duration', mods: mods({ defPct: num(bb.def) }), attack: { atkScale: num(bb['attack@atk_scale'], 1) } }), @@ -2193,10 +2214,14 @@ const KITS = { }), skill: { kind: 'duration', - targeting: skillGrid(chess, def) ? { rangeGrid: skillGrid(chess, def) } : undefined, + targeting: { ...(skillGrid(chess, def) ? { rangeGrid: skillGrid(chess, def) } : {}), canHitFly: true }, attack: { atkScale: num(bb['attack@atk_scale'], 1), maxTargets: Math.max(1, num(bb['attack@max_target'], 1)) }, - onStart({ battle, unit }) { unit.mem.mlyMult = up; applyUp(battle, unit); battle.fx('aoe', { x: unit.x, y: unit.y, id: unit.id, r: 2, skill: 'mlynar' }); }, - onEnd({ battle, unit }) { unit.mem.mlyMult = 1; battle.removeBuff(unit, 'mlynar:traitUp'); }, + onStart({ battle, unit }) { + unit.mem.mlyUp = true; unit.mem.mlyKills = 0; unit.mem.mlyPending = 0; + applyUp(battle, unit); + battle.fx('aoe', { x: unit.x, y: unit.y, id: unit.id, r: 2, skill: 'mlynar' }); + }, + onEnd({ battle, unit }) { unit.mem.mlyUp = false; unit.mem.mlyPending = 0; battle.removeBuff(unit, 'mlynar:traitUp'); }, }, trait: { dmgMul: (b, u) => (num(u.mem.mlyNear) >= num(t0.cnt, 3) ? num(t0.atk_scale_up, 1) : num(t0.atk_scale_base, 1)) }, talents: [ @@ -2224,7 +2249,7 @@ const KITS = { // 技能期间若击倒敌人,技能结束时特性效果不重置: the librator trait resets the ramp on skillEnd (priority 0) — // remember it before and put it back after battle.on('skillStart', (c) => { if (c.unit === unit) unit.mem.mlyKeep = false; }, { owner: unit }); - battle.on('kill', (c) => { if (c.killer === unit && c.victim.side === 'enemy' && unit.skill?.active) unit.mem.mlyKeep = true; }, { owner: unit }); + ownKills(battle, unit, () => { unit.mem.mlyKeep = true; }); battle.on('skillEnd', (c) => { if (c.unit === unit) unit.mem.mlyRamp = unit.mem.mlyKeep && c.reason !== 'death' ? num(unit.trait.ramp) : null; }, { owner: unit, priority: 100 }); @@ -2236,18 +2261,20 @@ const KITS = { }, { owner: unit, priority: -100 }); } if (sid && sid !== 'skchr_mlynar_3') return; - battle.on('kill', (c) => { - if (c.killer !== unit || c.victim.side !== 'enemy' || !unit.skill?.active) return; - unit.mem.mlyMult = Math.max(1, num(unit.mem.mlyMult, 1) + perKill); + ownKills(battle, unit, () => { unit.mem.mlyPending = num(unit.mem.mlyPending) + 1; }, () => { + if (!unit.mem.mlyPending || !unit.mem.mlyUp) return; + unit.mem.mlyKills = num(unit.mem.mlyKills) + unit.mem.mlyPending; + unit.mem.mlyPending = 0; applyUp(battle, unit); - }, { owner: unit }); + }); const extra = num(bb.atk_scale); if (!(extra > 0)) return; battle.on('damaged', (c) => { const src = c.source, e = c.target; if (!src || src.side !== 'ally' || !isOp(src) || !isKazimierz(src) || !c.dmg?.isAttack || c.type === 'element') return; if (e.side !== 'enemy' || !e.alive || !unit.skill?.active || !on(unit) || !inRange(unit, e)) return; - battle.dealDamage(unit, e, { amount: unit.s.atk * extra, type: 'true', canDodge: false, isSkill: true, tags: ['skill', 'mlynarMark'] }); + const tags = src === unit ? ['skill', 'mlynarMark', 'mlynarOwn'] : ['skill', 'mlynarMark']; + battle.dealDamage(unit, e, { amount: unit.s.atk * extra, type: 'true', canDodge: false, isSkill: true, tags }); }, { owner: unit }); }, }; diff --git a/server/sim/content/kits/tier6.js b/server/sim/content/kits/tier6.js index 95650c71..300ed18c 100644 --- a/server/sim/content/kits/tier6.js +++ b/server/sim/content/kits/tier6.js @@ -30,7 +30,8 @@ // the storm stops when she leaves the field. // 6_06 佩佩 splash stun via `damaged` (isSplash) hook; module ×1.15 when ≥3 enemies in the splash area. // 6_07 维娜 "attack enemies blocked by allies in talent range" = those enemies' tiles are added to her range; -// 黄金盟誓 lasts the skill duration on the tile nearest to an enemy. +// S3 puts a 黄金盟誓 on every free deployable melee tile of her talent-1 area (fences too), each for +// the skill duration (the token's maxDeployCount 1 is its hand limit — tokens.js). // 6_08 焰影苇草 灼痕 = marker (ATK −20 %) + the 法术脆弱 status (同名效果取最高); applied during S3 it lasts until the // skill ends (duration = remaining skill time). // 6_09 塑心 cannot normal attack; each skill charge is one attack; 精神逆构 multiplies every apoptosis gauge fill on @@ -1336,21 +1337,24 @@ function siege2(bb, chess, def) { mods: { atkPct: num(bb.atk), batPct: batOf(bb.base_attack_time, def) }, targeting: { maxTargets: Math.max(1, Math.floor(num(bb['attack@max_target'], 1))) }, attack: { dmgType: 'true' }, + // "立即在天赋一生效范围内可部署地面召唤“黄金盟誓”" (EN client "Summons Golden Vows on deployable tiles within + // Talent 1's range"): one on EVERY free tile of the talent-1 area a melee piece could be deployed on — fences + // (low, deployable, not walkable) included [ASSUMED: the EN wiki's "open low ground tiles"]; player report B3 onStart({ battle, unit, skill }) { - const tile = bestTile(battle, freeTiles(battle, unit, tGrid)); - if (tile) { + const lions = []; + for (const tile of freeTiles(battle, unit, tGrid, { ground: false })) { const lion = battle.spawnToken(unit, tokId, tile[0], tile[1], { duration: skill.timeLeft }); - unit.mem.vlion = lion; - if (lion) { - if (!lion.kit?.fromTokens && lion.profile) lion.profile.dmgType = 'true'; // "攻击造成真实伤害" without a token kit - battle.fx('summon', { x: lion.x, y: lion.y, id: lion.id, src: unit.id }); - } + if (!lion) continue; + lions.push(lion); + if (!lion.kit?.fromTokens && lion.profile) lion.profile.dmgType = 'true'; // "攻击造成真实伤害" without a token kit + battle.fx('summon', { x: lion.x, y: lion.y, id: lion.id, src: unit.id }); } + unit.mem.vlions = lions; }, onEnd({ battle, unit }) { - const lion = unit.mem.vlion; - unit.mem.vlion = null; - if (lion && lion.alive) battle.retreat(lion, { reason: 'expired', permanent: true }); + const lions = unit.mem.vlions || []; + unit.mem.vlions = null; + for (const lion of lions) if (lion.alive) battle.retreat(lion, { reason: 'expired', permanent: true }); battle.setExtraRange(unit, null); }, }, diff --git a/server/sim/content/tokens.js b/server/sim/content/tokens.js index 7dd8b2d6..77fa8bb0 100644 --- a/server/sim/content/tokens.js +++ b/server/sim/content/tokens.js @@ -31,7 +31,8 @@ // after duration_switch s on the field) and sluggish; the bomb is used up ('expired', never a knock-out; // its blast fx carries `consumed: true` so clients play the explosion, not a death sound) // 从不混淆的方向 untargetable marker; when the owner's skill ends it vanishes and the owner returns to its tile -// 黄金盟誓 attacks deal true damage (trait); lasts while the owner's skill runs +// 黄金盟誓 attacks deal true damage (trait); lasts while the owner's skill runs; 维娜 S3 places one on every +// free deployable tile around her (kits/tier6.js) — no per-owner deploy limit (SKILL_SUMMON_UNCAPPED) // 防护单元 untargetable, invulnerable device placed by the player (a hand piece, user playtest #6): shield = // 凯瑟琳 max HP × max_shield_ratio on the operator in its range (range 1-1: the tile it faces; effects do // not stack — `cathy:shield`, read by 凯瑟琳 S1 岁月锻打), in full whenever it takes a new operator, @@ -41,7 +42,8 @@ // 炎佑 (enemy_9012_acloon) flying ally: flies after the highest-aggro enemy of the field and hovers over it, stays // put when there is none; 3-target arts + burn on every hit, 元素脆弱 aura, 祛恶之焰 channel (yanyouKit) // 预备干员-医疗 / Touch (band map characters) generic kit / 恳切福音 kit -// Common rules: deploy limit per owner for tokens running these kits (data `deployLimit`; the oldest is withdrawn), +// Common rules: deploy limit per owner for tokens running these kits (data `deployLimit`; the oldest is withdrawn — +// except SKILL_SUMMON_UNCAPPED, whose data limit is the hand count the skill does not obey), // `summonKill` hook ({ token, owner, victim }) whenever a token kills, lifetimes never outlive a shorter // `opts.duration`; summon tiles skip `battle.isReservedTile` (home tiles of pieces not deployed yet). // Managed mode: when the summoner runs a hand-authored kit (content/kits), the owner-coupled parts of its summon @@ -1395,8 +1397,17 @@ const SKILL_SUMMONS = Object.freeze({ [TOKEN_IDS.radiantSword]: 'plus', [TOKEN_IDS.rosmonGear]: 'melee', }); -/** Pieces per cast of the skill summons placed several at a time (skill description; default 1). */ -const SKILL_SUMMON_PER_CAST = Object.freeze({ [TOKEN_IDS.rosmonGear]: 2 }); +/** + * Pieces per cast of the skill summons placed several at a time (skill description; default 1). 黄金盟誓: "立即在天赋 + * 一生效范围内可部署地面召唤" — every free tile of the 8 around 维娜 (EN client "Summons Golden Vows on deployable tiles + * within Talent 1's range"; player report B3 after 0.1.0). + */ +const SKILL_SUMMON_PER_CAST = Object.freeze({ [TOKEN_IDS.rosmonGear]: 2, [TOKEN_IDS.goldenOath]: 8 }); +/** + * Skill summons the per-owner deploy limit does not apply to: the data `deployLimit` (character_table maxDeployCount 1) + * of 黄金盟誓 is a hand count, while 维娜 S3 summons one on every free deployable tile of her talent-1 area at once. + */ +const SKILL_SUMMON_UNCAPPED = new Set([TOKEN_IDS.goldenOath]); /** Tactician talent tokens that replace the engine's generic 援军. */ const TACTICIAN_TOKENS = new Set([TOKEN_IDS.wolfPack, TOKEN_IDS.manifold]); @@ -1462,7 +1473,7 @@ export function install(battle) { // deploy limit per owner (data deployLimit): a new summon withdraws the oldest one of the same kind battle.on('deploy', (ctx) => { const u = ctx.unit; - if (!u || u.kind !== 'token' || !u.ownerUnit || u.mem.isClone || !u.kit?.fromTokens) return; + if (!u || u.kind !== 'token' || !u.ownerUnit || u.mem.isClone || !u.kit?.fromTokens || SKILL_SUMMON_UNCAPPED.has(u.defId)) return; const lim = deployLimitOf(u); if (!(lim >= 1) || !Number.isFinite(lim)) return; const same = battle.allyUnits.filter((t) => t.alive && t.kind === 'token' && t.defId === u.defId && t.ownerUnit === u.ownerUnit && !t.mem.isClone && t.kit?.fromTokens); diff --git a/test/content/feedback1b_kits.test.js b/test/content/feedback1b_kits.test.js new file mode 100644 index 00000000..482054c8 --- /dev/null +++ b/test/content/feedback1b_kits.test.js @@ -0,0 +1,215 @@ +// Player reports after the public 0.1.0 release, second batch — operator kits (workstream WH): +// B3 "干员维娜维多利亚三技能错误的只能召唤出一只黄金盟誓" — her default S3 俱以我之名 placed one 黄金盟誓. Official text: +// "立即在天赋一生效范围内可部署地面召唤“黄金盟誓”" (skill_table skchr_siege2_3), the English client "Summons Golden +// Vows on deployable tiles within Talent 1's range" (arknights.wiki.gg: "only be spawned on open low ground +// tiles"): one on every free deployable melee tile of her talent-1 area (the 8 tiles around her). The token's +// maxDeployCount 1 is its hand limit and does not cap the skill. +// B4 "干员玛恩纳开技能没伤害" — his default S3 未照耀的荣光 (CUSTOM_RANGE: any enemy on the skill range, flyers too) +// fired on a flying wave and hit nothing for 26 s: the kit never let it hit air units, although PRTS 备注 says +// "※可对空" (DESIGN §19.1 lists it). The same note fixes the per-kill rule: "每击倒一名符合条件的敌人,特性提供的 +// 攻击力加成-10%,特性最低降低至+0%", only kills by "自身普通攻击(与该次攻击附带的伤害)", "加成降低于当次攻击后统一 +// 结算". +// The player scenarios run the real product path: a real solo Match, the player's board in PlayerState, the normal +// field's BattleSpec (Match._normalOpts — what the browser and the server both build their Battle from) on 战场#05 +// (下半) with the real round waves. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { makeMatch, DATA } from '../match/harness.js'; +import { checkLoadout } from '../../shared/protocol.js'; +import { buildBattleSpec, createBattleFromSpec } from '../../server/sim/spec.js'; +import { COLS } from '../../server/sim/constants.js'; +import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.js'; + +const VINA = 'chess_char_6_07_a'; +const MLYNAR = 'chess_char_5_19_a'; +const LION = 'token_10040_siege2_vlion'; +const STAGE = 'act2autochess_m01'; +const chess = (id) => (Object.hasOwn(DATA.chess, id) ? DATA.chess[id] : null); +const approx = (a, b, eps = 1e-6, msg = '') => assert.ok(Math.abs(a - b) <= eps * Math.max(1, Math.abs(b)), `${msg} ${a} ≉ ${b}`); + +/** + * The normal field of a solo player at PREP of `round` (seed `seed`) with exactly `pieces` ([chessId, row, col, dir?]) + * on the board: the real Match, PlayerState, loadout and round wave, built like Match._ccField does. + */ +function realField({ pieces, round, seed, loadout = {}, stageId = STAGE, spawns = null }) { + const lo = checkLoadout(loadout, chess).loadout; + const h = makeMatch({ mode: 'solo', difficulty: 'NORMAL', seats: [{ seat: 0, playerId: 'p_0', name: 'P0', isBot: false, connected: true, loadout: lo }], seed }).start(); + const ps = h.ps('p_0'); + const ok = h.drive(() => { if (ps.lp > 0 && ps.lp < 300) ps.lp = 300; return h.m.phase === 'PREP' && h.m.round === round; }); + assert.ok(ok, `PREP R${round}`); + h.setStage(stageId); + for (const p of [...ps.board.values(), ...ps.hand.filter(Boolean)]) if (p.kind === 'chess') ps.returnCopies(p); + ps.board.clear(); + ps.hand.fill(null); + for (const [id, r, c, dir] of pieces) { const p = ps.newPiece('chess', id); if (dir) p.dir = dir; ps.board.set(`${r},${c}`, p); } + ps.recompute(); + const opts = h.m._normalOpts(ps); + if (spawns) opts.spawns = spawns(opts); + const spec = buildBattleSpec({ ...opts, battleId: 'feedback1b', fieldId: 'n:p_0', kind: 'normal', content: h.m.battleContent }); + const b = createBattleFromSpec(spec, h.m.ds, { quiet: true, recordEvents: false }); + return { h, m: h.m, ps, spec, b }; +} + +/** Free tiles of the 3×3 talent-1 area around `u` a melee piece could be deployed on (the official "可部署地面"). */ +function deployableAround(b, u) { + const out = []; + for (let dr = -1; dr <= 1; dr++) for (let dc = -1; dc <= 1; dc++) { + const r = u.tileR + dr, c = u.tileC + dc; + if ((!dr && !dc) || !b.grid.inRect(r, c) || !b.grid.canStand(r, c, { ranged: false })) continue; + if (b.unitAt(r, c) || b.isReservedTile(r, c)) continue; + out.push(r * COLS + c); + } + return out.sort((x, y) => x - y); +} + +// ------------------------------------------------------------------------------------------------------------------ +// B3 维娜·维多利亚 S3 俱以我之名 + +test('B3 维娜·维多利亚 S3 (default skill, real match): a 黄金盟誓 on every free deployable tile around her — not one', () => { + const ally = Object.values(DATA.chess).find((c) => c.tier === 1 && !c.isGolden && c.visible && c.position === 'MELEE'); + // (10,5) on 战场#05 (下半): melee tiles (11,5), (9,4), (9,5) around her; high ground and forbidden tiles take none. + // The second layout puts an operator on (9,5): the lions take the two tiles left. + for (const id of [VINA, chess(VINA).goldenId]) for (const extra of [[], [[ally.chessId, 9, 5]]]) { + const { b, m } = realField({ pieces: [[id, 10, 5], ...extra], round: 8, seed: 11 }); + const u = b.allyUnits.find((x) => x.defId === id); + assert.equal(u.def.skill.id, 'skchr_siege2_3', 'S3 is her default skill'); + let expected = null, lions = null, most = 0; + b.on('skillStart', (c) => { if (c.unit === u && !lions) lions = b.allyUnits.filter((t) => t.defId === LION && t.alive); }); + while (!u.skill.active && b.time < 120 && !b.finished) { expected = deployableAround(b, u); b.step(); } + assert.ok(u.skill.active, `${id}: S3 cast in the R8 wave (t=${b.time.toFixed(1)})`); + assert.equal(expected.length, 3 - extra.length, 'the scenario: free melee tiles of her area'); + assert.deepEqual(lions.map((t) => t.tileR * COLS + t.tileC).sort((x, y) => x - y), expected, `${id} ${extra.length ? 'with an ally' : 'alone'}: one per free deployable tile`); + if (extra.length) assert.ok(b.allyUnits.some((x) => x.defId === extra[0][0] && x.alive && x.tileR === 9 && x.tileC === 5), 'the operator keeps its tile'); + // they all stay for the skill (the token's maxDeployCount 1 caps hand deployments, not the skill) and count for + // 诸王的叹息 ("此范围内每个友方单位使维娜攻击力+5%") + b.step(); b.step(); + const t0 = u.def.talents[0].bb; + for (let i = 0; i < 12; i++) b.step(); + const kings = u.findBuff('siege2:kings'); + approx(kings.mods.atkPct, t0.atk * (expected.length + extra.length), 1e-9, '+5 % per ally around, lions included'); + while (u.skill.active && !b.finished) { b.step(); most = Math.max(most, b.allyUnits.filter((t) => t.defId === LION && t.alive).length); } + assert.ok(most <= expected.length); + b.step(); + assert.equal(b.allyUnits.filter((t) => t.defId === LION && t.alive).length, 0, 'every 黄金盟誓 leaves when the skill ends'); + m.dispose(); + } +}); + +test('B3 维娜·维多利亚 S3: fence tiles (low, deployable, not walkable) are deployable ground too; a re-cast summons a fresh set', () => { + // 战场#01 act1autochess_m01 (11,4): road (12,4), (12,5), (10,4) and fence (12,3), (11,3), (10,3) — (10,5) / (11,5) + // take no melee piece + const dummy = enemyRec({ key: 'enemy_dummy', hp: 1e9, speed: 0, atk: 0 }); + const h = makeBattle({ stageId: 'act1autochess_m01', defs: { enemies: { enemy_dummy: dummy } }, units: [{ chessId: VINA, row: 11, col: 4, carryState: { sp: 64 } }], enemies: [{ key: 'enemy_dummy', pos: [12, 5] }], autoFinish: false, timeLimit: 120 }); + h.step(); + const u = h.unit(VINA); + const expected = deployableAround(h.b, u); + assert.equal(expected.length, 6, 'the scenario: 6 deployable tiles around her'); + assert.equal(expected.filter((k) => !h.b.grid.groundPassable((k / COLS) | 0, k % COLS, true)).length, 3, '3 of them fences'); + assert.ok(h.runUntil(() => u.skill.active, 10)); + const tiles = () => h.b.allyUnits.filter((t) => t.defId === LION && t.alive).map((t) => t.tileR * COLS + t.tileC).sort((x, y) => x - y); + assert.deepEqual(tiles(), expected); + assert.ok(h.runUntil(() => !u.skill.active, 30)); + h.step(); + assert.deepEqual(tiles(), []); + u.skill.gainSp(1000); + assert.ok(h.runUntil(() => u.skill.active, 5)); + assert.deepEqual(tiles(), expected, 'the next cast: a full set again'); + checkInvariants(h.b); +}); + +// ------------------------------------------------------------------------------------------------------------------ +// B4 玛恩纳 S3 未照耀的荣光 + +/** A flying wave of the real match: the R2 wave of seed 21 is the FLY round; else the same flyers made explicit. */ +const flyers = (opts) => { + const fly = (s) => DATA.enemies[s.enemyKey]?.motion === 'FLY'; + if (opts.spawns.length && opts.spawns.every(fly)) return opts.spawns; + const route = opts.routes.findIndex((r) => r.motion === 'FLY'); + return [{ time: 3, enemyKey: 'enemy_1005_yokai', count: 4, interval: 7, routeIndex: route }, { time: 15, enemyKey: 'enemy_1005_yokai', count: 5, interval: 7, routeIndex: route }]; +}; + +test('B4 玛恩纳 S3 (default skill, real match, a flying wave): the cast hits the air units in its range (PRTS 备注 "※可对空")', () => { + for (const id of [MLYNAR, chess(MLYNAR).goldenId]) { + const { b, m } = realField({ pieces: [[id, 10, 5, 'RIGHT']], round: 2, seed: 21, spawns: flyers }); + const u = b.allyUnits.find((x) => x.defId === id); + assert.equal(u.def.skill.id, 'skchr_mlynar_3', 'S3 is his default skill'); + const bb = u.def.skill.bb, t0 = u.def.talents[0].bb; + const hits = []; + let casts = 0, castFlyers = 0; + b.on('skillStart', (c) => { + if (c.unit !== u) return; + casts++; + castFlyers = b.enemiesInKeys(u.rangeKeys, u, { canHitFly: true }).filter((e) => e.isFlying).length; + }); + b.on('hit', (c) => { if (c.source === u && c.dmg.isAttack) hits.push({ fly: c.target.isFlying, amount: c.dmg.amount, atk: u.s.atk, active: u.skill.active }); }, { priority: -999 }); + let killed = 0; + b.on('kill', (c) => { if (c.killer === u && c.victim.isFlying) killed++; }); + while (!b.finished && b.time < 400) b.step(); + assert.ok(casts >= 1 && castFlyers >= 1, `${id}: S3 cast on the flyers entering its range`); + const air = hits.filter((x) => x.fly && x.active); + assert.ok(air.length >= 5, `${id}: skill attacks on air units (${air.length})`); + for (const x of air) { + const scale = x.amount / (x.atk * bb['attack@atk_scale']); + assert.ok(Math.abs(scale - t0.atk_scale_base) < 1e-6 || Math.abs(scale - t0.atk_scale_up) < 1e-6, `125 / 150 % ATK × 游侠 (${scale})`); + } + assert.ok(killed >= 1, `${id}: flyers knocked out by his skill (${killed})`); + m.dispose(); + } +}); + +test('B4 玛恩纳 S1 / S2 have no 对空 note: their attacks stay ground-only (and they never fire on flyers alone)', () => { + for (const skill of [0, 1]) { + const { b, m } = realField({ pieces: [[MLYNAR, 10, 5, 'RIGHT']], round: 2, seed: 21, spawns: flyers, loadout: { [MLYNAR]: { skill } } }); + const u = b.allyUnits.find((x) => x.defId === MLYNAR); + let air = 0, casts = 0; + b.on('skillStart', (c) => { if (c.unit === u) casts++; }); + b.on('hit', (c) => { if (c.source === u && c.dmg.isAttack && c.target.isFlying) air++; }); + while (!b.finished && b.time < 400) b.step(); + assert.equal(air, 0, `S${skill + 1}`); + assert.equal(casts, 0, `S${skill + 1}: SEARCH needs a target of his own range`); + m.dispose(); + } +}); + +test('B4 玛恩纳 S3 per-kill rule (PRTS 备注): trait ATK bonus −10 % per kill of his own attacks, settled after the attack, floor +0 %', () => { + const weak = (key) => enemyRec({ key, hp: 1, def: 0, speed: 0, atk: 0 }); + const h = makeBattle({ + defs: { enemies: { enemy_weak: weak('enemy_weak'), enemy_foe: weak('enemy_foe') } }, + units: [{ chessId: MLYNAR, row: 10, col: 4, dir: 'RIGHT' }], hooks: ['damaged'], captureNoisy: true, autoFinish: false, timeLimit: 120, + }); + const u = h.unit(MLYNAR); + const bb = u.def.skill.bb; + h.run(10.5); + const ramp = u.trait.ramp; + approx(ramp, 0.5, 1e-9, 'ramp after 10 s (+200 % over 40 s)'); + const wave = () => { for (const p of [[10, 5], [10, 6], [10, 7], [11, 5], [9, 5]]) h.spawn('enemy_weak', { pos: p }); }; + wave(); + const bonus = () => u.s.atk / u.base.atk - 1; + let atCast = null; + h.b.on('skillStart', (c) => { if (c.unit === u) atCast = bonus(); }); + u.skill.gainSp(1000); + assert.ok(h.runUntil(() => u.skill.active, 2)); + approx(atCast, ramp * bb.trait_up, 1e-9, 'trait ×2 at the cast'); + const own = () => h.hooksOf('damaged').filter((c) => c.source === u && c.dmg.isAttack); + assert.ok(h.runUntil(() => own().length >= 5, 3)); + const vol = own().filter((c) => c.t === own()[0].t); + assert.equal(vol.length, 5, '5 targets'); + for (const c of vol) approx(c.amount, vol[0].amount, 1e-9, 'one attack, one ATK: the reduction is settled after the attack'); + approx(bonus(), ramp * bb.trait_up + 5 * bb.per_kill_reduce, 1e-9, '5 kills: +100 % → +50 %'); + // a kill by 无动于衷's reflection is not a kill of his attacks + const foe = h.spawn('enemy_foe', { pos: [12, 9] }); + h.b.dealDamage(foe, u, { amount: 1, type: 'phys', isAttack: true }); + assert.equal(foe.alive, false, 'reflected to death'); + approx(bonus(), ramp * bb.trait_up + 5 * bb.per_kill_reduce, 1e-9, 'reflect kill: no reduction'); + wave(); + assert.ok(h.runUntil(() => own().length >= 10, 3)); + h.step(); + approx(bonus(), 0, 1e-9, '10 kills: +0 %'); + wave(); + assert.ok(h.runUntil(() => own().length >= 15, 3)); + h.step(); + approx(bonus(), 0, 1e-9, 'never below +0 %'); + assert.ok(u.skill.active); + assert.deepEqual(h.b.errors.map((e) => `${e.label}: ${e.message}`), []); + checkInvariants(h.b); +}); diff --git a/test/content/kits_t6.test.js b/test/content/kits_t6.test.js index a077abcb..f2fbe812 100644 --- a/test/content/kits_t6.test.js +++ b/test/content/kits_t6.test.js @@ -402,7 +402,7 @@ test('佩佩: S3 ATK +150 %, main target stunned, stacks up to 4 with a growing checkInvariants(h.b); }); -test('维娜·维多利亚: first hit trembles, S3 summons 黄金盟誓, deals true damage to +1 target', () => { +test('维娜·维多利亚: first hit trembles, S3 summons 黄金盟誓 (one per free tile around her), deals true damage to +1 target', () => { const h = battle({ units: [{ chessId: 'chess_char_6_07_a', row: 9, col: 5, carryState: { sp: 64 } }], enemies: [{ key: 'enemy_armored', pos: [9, 6] }], @@ -411,17 +411,18 @@ test('维娜·维多利亚: first hit trembles, S3 summons 黄金盟誓, deals t h.step(); const e = enemyAt(h, 'enemy_armored'); assert.ok(h.runUntil(() => u.skill.active, 10)); - const lion = h.b.allyUnits.find((x) => x.defId === 'token_10040_siege2_vlion'); - assert.ok(lion && lion.alive, '黄金盟誓 summoned'); - assert.ok(Math.max(Math.abs(lion.tileR - u.tileR), Math.abs(lion.tileC - u.tileC)) <= 1); + const lions = h.b.allyUnits.filter((x) => x.defId === 'token_10040_siege2_vlion'); + assert.ok(lions.length >= 2 && lions.every((l) => l.alive), `黄金盟誓 summoned on the free tiles around her (${lions.length})`); + for (const l of lions) assert.ok(Math.max(Math.abs(l.tileR - u.tileR), Math.abs(l.tileC - u.tileC)) <= 1); + assert.equal(new Set(lions.map((l) => l.tileR * 100 + l.tileC)).size, lions.length, 'one per tile'); h.run(3); assert.ok(h.dmg.some((d) => d.src === u.id && d.attack && d.type === 'true'), 'true damage'); - assert.ok(h.dmg.some((d) => d.src === lion.id && d.type === 'true'), 'lion hits with true damage'); + assert.ok(h.dmg.some((d) => lions.some((l) => l.id === d.src) && d.type === 'true'), 'a lion hits with true damage'); assert.equal(u.profile.maxTargets + u.def.skill.bb['attack@max_target'] - 1 >= 2, true); assert.ok(h.hooksOf('statusApplied').some((c) => c.status === 'tremble' && c.target === e), '无拘的锋芒'); h.runUntil(() => !u.skill.active, 30); h.step(); - assert.equal(lion.alive, false, 'lion leaves with the skill'); + assert.ok(lions.every((l) => !l.alive), 'the lions leave with the skill'); checkInvariants(h.b); }); From 8ecaa46a552e8bffc34b3e4e3c1af1f3bc9d998c Mon Sep 17 00:00:00 2001 From: sganggs Date: Sat, 3 Oct 2026 02:15:05 +0800 Subject: [PATCH 2/4] =?UTF-8?q?feedback1b=20WH:=20=E8=AF=B8=E7=8E=8B?= =?UTF-8?q?=E7=9A=84=E5=8F=B9=E6=81=AF=20check=20counts=20the=20allies=20s?= =?UTF-8?q?tanding=20around=20her?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test/content/feedback1b_kits.test.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/test/content/feedback1b_kits.test.js b/test/content/feedback1b_kits.test.js index 482054c8..718efdd3 100644 --- a/test/content/feedback1b_kits.test.js +++ b/test/content/feedback1b_kits.test.js @@ -82,11 +82,11 @@ test('B3 维娜·维多利亚 S3 (default skill, real match): a 黄金盟誓 on if (extra.length) assert.ok(b.allyUnits.some((x) => x.defId === extra[0][0] && x.alive && x.tileR === 9 && x.tileC === 5), 'the operator keeps its tile'); // they all stay for the skill (the token's maxDeployCount 1 caps hand deployments, not the skill) and count for // 诸王的叹息 ("此范围内每个友方单位使维娜攻击力+5%") - b.step(); b.step(); const t0 = u.def.talents[0].bb; - for (let i = 0; i < 12; i++) b.step(); - const kings = u.findBuff('siege2:kings'); - approx(kings.mods.atkPct, t0.atk * (expected.length + extra.length), 1e-9, '+5 % per ally around, lions included'); + for (let i = 0; i < 14; i++) b.step(); + const around = b.allyUnits.filter((a) => a !== u && a.alive && Math.max(Math.abs(a.tileR - u.tileR), Math.abs(a.tileC - u.tileC)) <= 1); + assert.ok(around.filter((a) => a.defId === LION).length >= 1); + approx(u.findBuff('siege2:kings').mods.atkPct, t0.atk * around.length, 1e-9, '+5 % per ally around, lions included'); while (u.skill.active && !b.finished) { b.step(); most = Math.max(most, b.allyUnits.filter((t) => t.defId === LION && t.alive).length); } assert.ok(most <= expected.length); b.step(); From 20b65a5ff0b5dceea4266eafcaee4915a9e68a98 Mon Sep 17 00:00:00 2001 From: sganggs Date: Sat, 3 Oct 2026 02:42:14 +0800 Subject: [PATCH 3/4] feedback1b WH: Mlynar own-attack kills count the damage his attack carries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The S2 / S3 kill tracker kept only the last target of his attack, so the true damage carried by that attack (卡西米尔 bond, 天马之枪) overwrote it and a kill landed by that damage was not counted: S2 lost its "trait not reset" in the 卡西米尔 lineup or with the 天马 set, and S3 never lowered the trait bonus with the 天马 set. Track every enemy the current attack (or the mark it set off) hit, cleared after the attack; reflect kills and marks set off by other operators stay excluded (PRTS 备注). --- server/sim/content/kits/tier5.js | 17 ++++---- test/content/feedback1b_kits.test.js | 58 ++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 8 deletions(-) diff --git a/server/sim/content/kits/tier5.js b/server/sim/content/kits/tier5.js index fe8dfdbb..877a1982 100644 --- a/server/sim/content/kits/tier5.js +++ b/server/sim/content/kits/tier5.js @@ -2190,18 +2190,19 @@ const KITS = { else battle.removeBuff(unit, 'mlynar:traitUp'); }; /** - * "仅自身普通攻击(与该次攻击附带的伤害)击倒非角色类单位" (PRTS S2 / S3 备注): `onKill(victim)` for each enemy whose - * last damage from him came from his attack or the mark that attack set off (tagged 'mlynarOwn') while his skill - * runs; `onAttack()` after each of his attacks ("加成降低于当次攻击后统一结算"). + * "仅自身普通攻击(与该次攻击附带的伤害)击倒非角色类单位" (PRTS S2 / S3 备注): `onKill(victim)` for each enemy he + * knocks out, while his skill runs, that his current attack hit (or the mark it set off, tagged 'mlynarOwn') — so a + * kill by the damage that attack carries (天马之枪, the 卡西米尔 bond's true damage, the mark) counts too, in any + * hook order; `onAttack()` after each of his attacks ("加成降低于当次攻击后统一结算"), which also closes the set. + * 无动于衷's reflection and a mark another operator's attack set off happen outside his attack: never counted. */ const ownKills = (battle, unit, onKill, onAttack = null) => { - let last = null; + const hit = new Set(); battle.on('damaged', (c) => { - if (c.source !== unit) return; - last = c.dmg?.isAttack || (c.dmg?.tags || []).includes('mlynarOwn') ? c.target : null; + if (c.source === unit && (c.dmg?.isAttack || (c.dmg?.tags || []).includes('mlynarOwn'))) hit.add(c.target); }, { owner: unit, priority: 1000 }); - battle.on('kill', (c) => { if (c.killer === unit && c.victim === last && c.victim.side === 'enemy' && unit.skill?.active) onKill(c.victim); }, { owner: unit }); - battle.on('attack', (c) => { if (c.attacker !== unit) return; last = null; if (onAttack) onAttack(); }, { owner: unit }); + battle.on('kill', (c) => { if (c.killer === unit && hit.has(c.victim) && c.victim.side === 'enemy' && unit.skill?.active) onKill(c.victim); }, { owner: unit }); + battle.on('attack', (c) => { if (c.attacker !== unit) return; hit.clear(); if (onAttack) onAttack(); }, { owner: unit }); }; return { skills: lazySkills({ diff --git a/test/content/feedback1b_kits.test.js b/test/content/feedback1b_kits.test.js index 718efdd3..9a7fc2ea 100644 --- a/test/content/feedback1b_kits.test.js +++ b/test/content/feedback1b_kits.test.js @@ -213,3 +213,61 @@ test('B4 玛恩纳 S3 per-kill rule (PRTS 备注): trait ATK bonus −10 % per k assert.deepEqual(h.b.errors.map((e) => `${e.label}: ${e.message}`), []); checkInvariants(h.b); }); + +test('B4 玛恩纳: a kill by the damage his attack carries (卡西米尔 bond true damage, 天马之枪) is a kill of his own attack — S2 keeps the ramp, S3 lowers the bonus', () => { + const weak = (key) => enemyRec({ key, hp: 1, def: 0, speed: 0, atk: 0 }); + const KAZ6 = { kazimierzShip: { count: 6, active: true, tier: 2, layers: 0 } }; + const PEGASUS = ['chess_item_5_09_e_a', 'chess_item_6_01_e_a']; // 天马之盔 + 天马之枪: every damage instance + 30 % ATK true + for (const [label, items, bonds, carried] of [['卡西米尔 6', [], KAZ6, 'bond:kazimierz'], ['天马之盔 + 天马之枪', PEGASUS, {}, 'item:pegasus']]) { + const setup = (skillIndex) => { + const h = makeBattle({ + defs: { enemies: { enemy_weak: weak('enemy_weak') } }, + units: [{ chessId: MLYNAR, row: 10, col: 4, dir: 'RIGHT', items, skillIndex }], bonds, autoFinish: false, timeLimit: 200, + }); + const u = h.unit(MLYNAR); + // which damage instance of his knocked each victim out (the last one he dealt it before the kill) + const lastTags = new Map(), killedBy = []; + h.b.on('damaged', (c) => { if (c.source === u) lastTags.set(c.target, c.dmg?.tags || []); }); + h.b.on('kill', (c) => { if (c.killer === u) killedBy.push(lastTags.get(c.victim) || []); }); + return { h, u, killedBy }; + }; + // S2 未宽解的悲哀: "技能期间若击倒敌人,技能结束时特性效果不重置" + { + const { h, u, killedBy } = setup(1); + assert.equal(u.def.skill.id, 'skchr_mlynar_2'); + h.run(20.5); + const ramp = u.trait.ramp; + approx(ramp, 1, 1e-9, `${label}: ramp after 20 s`); + h.spawn('enemy_weak', { pos: [10, 5] }); + u.skill.gainSp(1000); + assert.ok(h.runUntil(() => u.skill.active, 2)); + assert.ok(h.runUntil(() => !u.skill.active, 40)); + h.step(); + assert.equal(killedBy.length, 1, `${label}: S2 knocked the enemy out`); + assert.ok(killedBy[0].includes(carried), `${label}: the carried damage landed the kill (${killedBy[0]})`); + approx(u.trait.ramp, ramp, 1e-9, `${label}: S2 kill of his attack keeps the ramp`); + } + // S3 未照耀的荣光: −10 % trait bonus per own kill + { + const { h, u, killedBy } = setup(2); + assert.equal(u.def.skill.id, 'skchr_mlynar_3'); + const bb = u.def.skill.bb; + h.run(10.5); + const ramp = u.trait.ramp; + for (const p of [[10, 5], [10, 6], [10, 7], [11, 5], [9, 5]]) h.spawn('enemy_weak', { pos: p }); + const bonus = () => u.s.atk / u.base.atk - 1; + let atCast = null; + h.b.on('skillStart', (c) => { if (c.unit === u) atCast = bonus(); }, { priority: -1000 }); + u.skill.gainSp(1000); + assert.ok(h.runUntil(() => u.skill.active, 2)); + assert.ok(h.runUntil(() => killedBy.length >= 5, 3), `${label}: S3 knocks the 5 enemies out`); + h.step(); + // the carried damage (or the S3 mark his attack set off, whichever hook runs first) landed the kills + assert.ok(killedBy.every((t) => t.includes(carried) || t.includes('mlynarOwn')), `${label}: carried damage landed the kills`); + assert.ok(killedBy.some((t) => t.includes(carried)) || carried === 'bond:kazimierz', `${label}: some kill by ${carried}`); + assert.equal(u.mem.mlyKills, 5, `${label}: 5 own kills`); + approx(bonus() - atCast, 5 * bb.per_kill_reduce, 1e-9, `${label}: 5 kills: −50 points (ramp ${ramp})`); + assert.deepEqual(h.b.errors.map((e) => `${e.label}: ${e.message}`), []); + } + } +}); From ff1b259c61aed44f252015b65988bab16a220d7f Mon Sep 17 00:00:00 2001 From: sganggs Date: Sat, 3 Oct 2026 03:14:00 +0800 Subject: [PATCH 4/4] feedback1b WH: tests pin the own-kill exclusions, every lion off a reserved tile, the summoned count MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The own-kill set closes after each of his attacks: a new test kills an enemy his S3 / S2 attack hit by 无动于衷's reflection and by a mark another Kazimierz operator set off, and checks neither counts (fails without the clear). The reserved-tile test checks every lion and the exact free tiles; the B3 test pins the summoned lion count again. --- server/sim/content/tokens.js | 2 +- test/content/feedback1b_kits.test.js | 75 +++++++++++++++++++++++++++- test/content/kits_t6.test.js | 20 ++++++-- 3 files changed, 90 insertions(+), 7 deletions(-) diff --git a/server/sim/content/tokens.js b/server/sim/content/tokens.js index 77fa8bb0..693d49d0 100644 --- a/server/sim/content/tokens.js +++ b/server/sim/content/tokens.js @@ -1404,7 +1404,7 @@ const SKILL_SUMMONS = Object.freeze({ */ const SKILL_SUMMON_PER_CAST = Object.freeze({ [TOKEN_IDS.rosmonGear]: 2, [TOKEN_IDS.goldenOath]: 8 }); /** - * Skill summons the per-owner deploy limit does not apply to: the data `deployLimit` (character_table maxDeployCount 1) + * Skill summons the per-owner deploy limit does not apply to: the data `deployLimit` (character_table phase maxDeployCount 1) * of 黄金盟誓 is a hand count, while 维娜 S3 summons one on every free deployable tile of her talent-1 area at once. */ const SKILL_SUMMON_UNCAPPED = new Set([TOKEN_IDS.goldenOath]); diff --git a/test/content/feedback1b_kits.test.js b/test/content/feedback1b_kits.test.js index 9a7fc2ea..f8a8ec18 100644 --- a/test/content/feedback1b_kits.test.js +++ b/test/content/feedback1b_kits.test.js @@ -18,7 +18,7 @@ import { makeMatch, DATA } from '../match/harness.js'; import { checkLoadout } from '../../shared/protocol.js'; import { buildBattleSpec, createBattleFromSpec } from '../../server/sim/spec.js'; import { COLS } from '../../server/sim/constants.js'; -import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.js'; +import { makeBattle, enemyRec, chessRec, checkInvariants } from '../helpers/battleHarness.js'; const VINA = 'chess_char_6_07_a'; const MLYNAR = 'chess_char_5_19_a'; @@ -85,7 +85,7 @@ test('B3 维娜·维多利亚 S3 (default skill, real match): a 黄金盟誓 on const t0 = u.def.talents[0].bb; for (let i = 0; i < 14; i++) b.step(); const around = b.allyUnits.filter((a) => a !== u && a.alive && Math.max(Math.abs(a.tileR - u.tileR), Math.abs(a.tileC - u.tileC)) <= 1); - assert.ok(around.filter((a) => a.defId === LION).length >= 1); + assert.equal(around.filter((a) => a.defId === LION).length, expected.length, 'every lion still around her'); approx(u.findBuff('siege2:kings').mods.atkPct, t0.atk * around.length, 1e-9, '+5 % per ally around, lions included'); while (u.skill.active && !b.finished) { b.step(); most = Math.max(most, b.allyUnits.filter((t) => t.defId === LION && t.alive).length); } assert.ok(most <= expected.length); @@ -271,3 +271,74 @@ test('B4 玛恩纳: a kill by the damage his attack carries (卡西米尔 bond t } } }); + +test('B4 玛恩纳: a kill outside his attack does not count, even of an enemy his attack hit — 无动于衷 reflection, a mark another operator set off (PRTS 备注 exclusions)', () => { + const tough = enemyRec({ key: 'enemy_tough', hp: 1e9, def: 0, speed: 0, atk: 0 }); + // a Kazimierz teammate without attacks of its own here: the reflection's target and the mark's trigger + const kaz = chessRec({ id: 'test_kaz_a', bonds: ['kazimierzShip'], skill: null, stats: { atk: 1 }, rangeGrid: [[0, 0]] }); + const setup = (skillIndex) => { + const h = makeBattle({ + defs: { enemies: { enemy_tough: tough }, chess: { test_kaz_a: kaz } }, + units: [{ chessId: MLYNAR, row: 10, col: 4, dir: 'RIGHT', skillIndex }, { chessId: 'test_kaz_a', row: 9, col: 4, dir: 'RIGHT' }], + autoFinish: false, timeLimit: 200, + }); + const u = h.unit(MLYNAR), mate = h.unit('test_kaz_a'); + let attacked = 0; + h.b.on('attack', (c) => { if (c.attacker === u) attacked++; }, { priority: -1000 }); + const kills = []; + h.b.on('kill', (c) => kills.push(c)); + // his attack has hit `foe` and is over (the hit set is closed): the moment between two of his attacks + const afterHisAttackOn = (foe) => { + let hitFoe = false; + const off = h.b.on('damaged', (c) => { if (c.source === u && c.target === foe && c.dmg?.isAttack) hitFoe = true; }); + for (let i = 0; i < 300 && !(hitFoe && attacked); i++) { if (!hitFoe) attacked = 0; h.step(); } + h.b.off(off); + assert.ok(hitFoe && attacked, 'his attack hit the enemy'); + }; + const nextAttack = () => { attacked = 0; assert.ok(h.runUntil(() => attacked > 0, 5), 'his next attack'); }; + return { h, u, mate, kills, afterHisAttackOn, nextAttack }; + }; + const killOutside = (h, u, mate, foe, how) => { + foe.hp = 1; + if (how === 'reflect') h.b.dealDamage(foe, mate, { amount: 1, type: 'phys', isAttack: true }); // 无动于衷 reflects onto it + else h.b.dealDamage(mate, foe, { amount: 0.01, type: 'true', isAttack: true }); // the mate's attack sets off his mark + }; + // S3: the bonus stays where it is + for (const how of ['reflect', 'mark']) { + const { h, u, mate, kills, afterHisAttackOn, nextAttack } = setup(2); + h.run(10.5); + const A = h.spawn('enemy_tough', { pos: [10, 5] }); + h.spawn('enemy_tough', { pos: [10, 7] }); // keeps him attacking + u.skill.gainSp(1000); + assert.ok(h.runUntil(() => u.skill.active, 2)); + afterHisAttackOn(A); + const before = u.s.atk / u.base.atk - 1; + killOutside(h, u, mate, A, how); + assert.equal(A.alive, false, `S3 ${how}: the enemy is knocked out`); + const k = kills.find((c) => c.victim === A); + assert.equal(k?.killer, u, `S3 ${how}: by 玛恩纳`); + nextAttack(); + h.step(); + assert.ok(u.skill.active); + assert.equal(u.mem.mlyKills, 0, `S3 ${how}: not a kill of his own attack`); + approx(u.s.atk / u.base.atk - 1, before, 1e-9, `S3 ${how}: trait bonus unchanged`); + assert.deepEqual(h.b.errors.map((e) => `${e.label}: ${e.message}`), []); + } + // S2: a reflection kill of an enemy he hit does not keep the ramp + { + const { h, u, mate, kills, afterHisAttackOn } = setup(1); + h.run(20.5); + approx(u.trait.ramp, 1, 1e-9, 'ramp after 20 s'); + const A = h.spawn('enemy_tough', { pos: [10, 5] }); + h.spawn('enemy_tough', { pos: [10, 6] }); + u.skill.gainSp(1000); + assert.ok(h.runUntil(() => u.skill.active, 2)); + afterHisAttackOn(A); + killOutside(h, u, mate, A, 'reflect'); + assert.equal(A.alive, false); + assert.equal(kills.find((c) => c.victim === A)?.killer, u); + assert.ok(h.runUntil(() => !u.skill.active, 40)); + assert.ok(u.trait.ramp < 0.05, `S2: the ramp resets at the skill end (${u.trait.ramp})`); + assert.deepEqual(h.b.errors.map((e) => `${e.label}: ${e.message}`), []); + } +}); diff --git a/test/content/kits_t6.test.js b/test/content/kits_t6.test.js index f2fbe812..b5ca4e51 100644 --- a/test/content/kits_t6.test.js +++ b/test/content/kits_t6.test.js @@ -885,10 +885,22 @@ test('summon tiles skip reserved home tiles: 黄金盟誓 never takes the tile o h.step(); h.b.dealDamage(null, g, { amount: 1e9, type: 'true' }); assert.equal(g.alive, false); - assert.ok(h.runUntil(() => u.skill.active, 5)); - const lion = h.b.allyUnits.find((x) => x.defId === 'token_10040_siege2_vlion' && x.alive); - assert.ok(lion, '黄金盟誓 summoned'); - assert.ok(!(lion.tileR === 10 && lion.tileC === 5), 'not on the dead guard\'s home tile (closest to the enemy)'); + // the free deployable melee tiles of her 3×3 area just before the cast — the dead guard's home tile among them + const HOME = 10 * 100 + 5; + let free = []; + for (let i = 0; i < 100 && !u.skill.active; i++) { + free = []; + for (let dr = -1; dr <= 1; dr++) for (let dc = -1; dc <= 1; dc++) { + const r = u.tileR + dr, c = u.tileC + dc; + if ((dr || dc) && h.b.grid.inRect(r, c) && h.b.grid.canStand(r, c, { ranged: false }) && !h.b.unitAt(r, c)) free.push(r * 100 + c); + } + h.step(); + } + assert.ok(u.skill.active, 'S3 cast'); + assert.ok(free.includes(HOME) && free.length >= 2, `the scenario: the guard's home tile is one of the free tiles (${free})`); + const lions = h.b.allyUnits.filter((x) => x.defId === 'token_10040_siege2_vlion' && x.alive); + assert.ok(lions.every((l) => !(l.tileR === 10 && l.tileC === 5)), 'no lion on the dead guard\'s home tile'); + assert.deepEqual(lions.map((l) => l.tileR * 100 + l.tileC).sort((a, b) => a - b), free.filter((k) => k !== HOME).sort((a, b) => a - b), 'a lion on every other free tile'); assert.ok(h.runUntil(() => g.alive, g.base.respawnTime + 2), 'the guard redeploys on its tile'); checkInvariants(h.b); });