feedback1b WH: Vina Victoria S3 lions on every free tile; Mlynar S3 hits air units
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.
This commit is contained in:
1 parent
7f134737ce
commit
bcb2786a55
6 files changed
+301
-40
No files matched your search
+5
-2
@@ -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`,
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user