feedback5: merge K1 — stand-in kits Sharp, 领主·Sharp, Misery (every skill on every form; PRTS-checked)

This commit is contained in:
sganggs committed 2026-10-05 18:25:17 +08:00
commit 5090ac1ed3
7 files changed
+878 -1

No files matched your search

+1 -1
View File
@@ -64,7 +64,7 @@ export const KIT_FILES = Object.freeze([
* A stand-in without a file fights with the generic kit (content/generic.js) plus its unconditional stat talents
* (genericTalents) — the eight 预备干员 need no file.
*/
export const STANDIN_KIT_FILES = Object.freeze([]);
export const STANDIN_KIT_FILES = Object.freeze(['standin-acguad.js', 'standin-sharp2.js', 'standin-acspec.js']);
async function loadKitFile(file) {
try {
@@ -0,0 +1,79 @@
// server/sim/content/kits/ops/standin-acguad.js — Sharp (char_609_acguad, 卫戍协议 6★ 近卫·无畏者) 补位 stand-in kit.
// Stands in for 忍冬 (tier 3, S3), 银灰 / 百炼嘉维尔 (tier 4, S2), 隐德来希 / 归溟幽灵鲨 (tier 5, S3 + DRE-X) and 佩佩
// (tier 6, S3 + DRE-X): data/backups.json `units.char_609_acguad.standsIn`. Kit contract: ../README.md "Stand-in kits".
// Numbers: the blackboards of data/backups.json (skill_table / character_table / battle_equip_table at E2 Lv1 skill
// level 4 and E2 Lv60 skill level 7); what the data does not carry: PRTS Sharp(卫戍协议), cited where used.
import { num, talentBb, traitBb, skillRec, statBuff, toggleBuff } from '../shared/tier1.js';
const S1 = 'skchr_acguad_1';
const S2 = 'skchr_acguad_2';
const S3 = 'skchr_acguad_3';
/** 力战不竭's ATK layers: one buff whose stacks are the layers. */
const STACK_KEY = 'acguad:s3stack';
export default {
// Sharp — talent 1 隐匿刀刃: ATK +atk, physical dodge prob (DRE-X levels 2 / 3 upgrade both). Talent 2 寸步不退: after
// `interval` s on the field (each deployment), ASPD +attack_speed.
// S1 快刀: ATK +atk; each attack becomes a double hit with attack@prob1 (data trigger: SKILL_RANGE on its 1-1 range).
// S2 亮剑: DEF to 0 (no blackboard key: the text "防御力降至0" — a final ×0, so no DEF bonus survives it), max HP
// +max_hp, every attack at attack@atk_scale.
// S3 力战不竭: ATK +atk; before each attack +atk_each_stack ATK (one layer, at most max_atk_stack_cnt), all layers lost
// when the target changes; HP never below 1 while it runs. PRTS 备注: "“每次攻击时获得增益”在每次成功攻击前判定,至少生效1层
// 增益,持续至技能结束" — the first attack on a target already has one layer, and the layers last until the skill ends.
// Module DRE-X (trait atk_scale): "攻击被阻挡的敌人时攻击力提升至115%" — an enemy blocked by anyone, as 斯卡蒂's DRE-X.
// Ground only, ground-targetable (data canHitFly false, no flag). S2 / S3 cast by the data trigger (DEFAULT).
char_609_acguad: (bb, chess) => {
const t0 = talentBb(chess, 0), t1 = talentBb(chess, 1);
const blockedScale = num(traitBb(chess).atk_scale, 0);
const b1 = skillRec(chess, S1)?.bb ?? {}, b2 = skillRec(chess, S2)?.bb ?? {}, b3 = skillRec(chess, S3)?.bb ?? {};
const doubleProb = num(b1['attack@prob1'], 0.2);
const layer = num(b3.atk_each_stack, 0), maxLayers = Math.max(1, Math.floor(num(b3.max_atk_stack_cnt, 8)));
return {
skills: {
[S1]: { kind: 'duration', mods: { atkPct: num(b1.atk) }, attack: { hitsFn: (battle) => (battle.rng.chance(doubleProb) ? 2 : 1) } },
[S2]: { kind: 'duration', mods: { defMul: 0, hpPct: num(b2.max_hp) }, attack: { atkScale: num(b2['attack@atk_scale'], 1) } },
[S3]: {
kind: 'duration',
mods: { atkPct: num(b3.atk) },
onStart({ battle, unit }) {
battle.removeBuff(unit, STACK_KEY);
unit.mem.acguadTarget = null;
if (unit.mem.acguadUndying) battle.off(unit.mem.acguadUndying);
// "技能持续期间内干员的生命值始终不会低于1": every HP loss, 流失 included (a kit saver, as 幽灵鲨 S2)
unit.mem.acguadUndying = battle.on('fatal', (c) => { if (c.unit === unit && unit.skill?.active) c.prevented = true; }, { owner: unit, priority: 10 });
},
onEnd({ battle, unit }) {
if (unit.mem.acguadUndying) battle.off(unit.mem.acguadUndying);
unit.mem.acguadUndying = null;
battle.removeBuff(unit, STACK_KEY);
unit.mem.acguadTarget = null;
},
},
},
talents: [
{ install(battle, unit) { statBuff(battle, unit, 'acguad:t1', { atkPct: num(t0.atk), dodgePhys: num(t0.prob) }); } },
{ install(battle, unit) {
const after = num(t1.interval, 30);
toggleBuff(battle, unit, 'acguad:t2', () => battle.time - unit.deployedAt >= after - 1e-9, { aspd: num(t1.attack_speed) });
} },
],
trait: blockedScale > 0 ? { dmgMul: (battle, unit, target) => (target && target.blockedBy ? blockedScale : 1) } : undefined,
install(battle, unit) {
if (unit.skill?.id !== S3 || !(layer > 0)) return;
// 力战不竭's layers: decided before every attack the skill makes (after the other beforeAttack handlers, on the
// attack's final main target); a new target clears them first, so its first attack has one layer
battle.on('beforeAttack', (c) => {
if (c.attacker !== unit || !unit.skill.active) return;
const target = (c.targets || []).find((t) => t && t.alive);
if (!target) return;
if (unit.mem.acguadTarget !== target) {
battle.removeBuff(unit, STACK_KEY);
unit.mem.acguadTarget = target;
}
battle.addBuff(unit, { key: STACK_KEY, mods: { atkPct: layer }, refresh: 'stack', maxStacks: maxLayers, tags: ['skill'] });
}, { owner: unit, priority: -100 });
},
};
},
};
@@ -0,0 +1,104 @@
// server/sim/content/kits/ops/standin-acspec.js — Misery (char_615_acspec, 卫戍协议 6★ 特种·处决者) 补位 stand-in kit.
// Stands in for 缄默德克萨斯 (tier 4, S2), 山 (tier 5, S3 + EXE-X) and 新约能天使 / 锏 (tier 6, S3 + EXE-X):
// data/backups.json `units.char_615_acspec.standsIn`. Kit contract: ../README.md "Stand-in kits".
// Numbers: the blackboards of data/backups.json (skill_table / character_table / battle_equip_table); what the data
// does not carry: PRTS Misery(卫戍协议), cited where used.
import { num, talentBb, traitBb, skillRec, statBuff, toggleBuff } from '../shared/tier1.js';
import { lonely } from '../shared/tier4.js';
import { absoluteRangeKeys } from '../../../targeting.js';
import { bodyInKeys } from '../../../body.js';
const S1 = 'skchr_acspec_1';
const S2 = 'skchr_acspec_2';
const S3 = 'skchr_acspec_3';
const S1_KEY = 'acspec:s1';
const PULL_TAG = 'acspec:pull';
/**
* 四维分离's "周围四格": her tile and the four tiles beside it [ASSUMED: her own tile counts — an enemy she blocks stands
* on it, and the talent is about her being alone with one enemy].
*/
const AROUND4 = Object.freeze([[0, 0], [-1, 0], [1, 0], [0, -1], [0, 1]]);
/** 空间的归依's area when the record has none: range x-4, her tile and the eight around it. */
const RING_X4 = Object.freeze([[1, -1], [1, 0], [1, 1], [0, -1], [0, 0], [0, 1], [-1, -1], [-1, 0], [-1, 1]]);
/** Living enemies whose body is on her tile or the four beside it, targetable or not (flyers too). */
function enemiesAround4(battle, unit) {
const keys = new Set(absoluteRangeKeys(AROUND4, unit.tileR, unit.tileC, unit.dir, 0));
let n = 0;
for (const e of battle.enemies) if (e.alive && e.deployed && !e.hidden && bodyInKeys(e, keys)) n++;
return n;
}
/**
* 空间的归依 at a deployment: atk_scale × ATK physical damage to every ground enemy around her (技能范围 x-4), then each
* enemy of that area nobody blocks is dragged to her front with 力度 `force` (0 = 小力: Battle.pullToFront, 力度 − 重量)
* and slowed (停顿) for `sluggish` s. PRTS 备注: "※不可对空" and "施加拖动与停顿效果的实际方式为:对目标进行一次0%攻击力
* 的近战攻击,此次附带了拖拽与停顿效果" — so the drag and the 停顿 ride a 0-damage physical hit, and an enemy that dodges it
* (or is out of reach of a hit: invulnerable, asleep) keeps its place. [ASSUMED] that hit is not a normal attack (no
* isAttack procs); the drag area is the damage area (the text names no other).
*/
function spaceBurst(battle, unit, rec) {
const sb = rec?.bb ?? {};
const grid = Array.isArray(rec?.rangeGrid) && rec.rangeGrid.length ? rec.rangeGrid : RING_X4;
const keys = absoluteRangeKeys(grid, unit.tileR, unit.tileC, unit.dir, 0);
const around = battle.enemiesInKeys(keys, unit, { canHitFly: false, groundOnly: true });
for (const e of around) battle.dealDamage(unit, e, { amount: unit.s.atk * num(sb.atk_scale, 0), type: 'phys', isSkill: true, tags: ['skill', 'acspec:burst'] });
const landed = new Set();
const h = battle.on('damaged', (c) => { if (c.source === unit && c.dmg?.tags?.includes(PULL_TAG)) landed.add(c.target); }, { owner: unit, priority: 1000 });
try {
for (const e of around) if (e.alive && !e.blockedBy) battle.dealDamage(unit, e, { amount: 0, type: 'phys', isSkill: true, tags: ['skill', PULL_TAG] });
} finally {
battle.off(h);
}
for (const e of around) {
if (!landed.has(e) || !e.alive) continue;
battle.pullToFront(e, unit, num(sb.force, 0));
if (e.alive) battle.applyStatus(e, 'sluggish', { duration: num(sb.sluggish, 2.5), source: unit });
}
}
export default {
// Misery — trait 再部署时间大幅度减少: the data's respawnTime (18 s). Talent 1 二象命末: each attack is a double hit with
// attack@prob (PRTS 备注: it is her normal attack's basic logic; EXE-X levels 2 / 3 make it +atk ATK and a higher
// chance). Talent 2 四维分离: ATK +atk while exactly `cnt` enemy stands on her tile or the four beside it.
// All three skills are 被动, ON_DEPLOY — they start at every deployment (the battle-start one included, when no enemy
// is on the field yet: S3 then hits nothing, as in the official mode; it matters on a redeploy):
// S1 物理的服从: for the skill's duration (10 s) the talent-1 chance is attack@prob and physical dodge prob.
// S2 战争的恭顺: for the skill's duration (10 s) ATK +atk, ASPD +attack_speed.
// S3 空间的归依: spaceBurst above (ground only), right after each deployment (install).
// Module EXE-X (trait atk): ATK +atk while no ally stands on the four tiles beside her (PRTS: unlike the usual EXE-X;
// shared/tier4.js lonely, as 缄默德克萨斯's module of the same text).
// [ASSUMED] PRTS's 存疑 note on talent 1 — a 1 s trigger cooldown that would make her wait 1 s after each attack — is not
// modelled: her attack interval is the data's (0.93 s base).
// Ground only, ground-targetable (data canHitFly false, no flag).
char_615_acspec: (bb, chess) => {
const t0 = talentBb(chess, 0), t1 = talentBb(chess, 1);
const moduleAtk = num(traitBb(chess).atk, 0);
const r1 = skillRec(chess, S1), r2 = skillRec(chess, S2), r3 = skillRec(chess, S3);
const b1 = r1?.bb ?? {}, b2 = r2?.bb ?? {};
const talentProb = num(t0['attack@prob'], 0), s1Prob = num(b1['attack@prob'], talentProb);
const cnt = Math.max(1, Math.floor(num(t1.cnt, 1)));
return {
skills: {
[S1]: { kind: 'passive', onStart({ battle, unit }) { battle.addBuff(unit, { key: S1_KEY, duration: num(r1?.duration, 10), mods: { dodgePhys: num(b1.prob) }, tags: ['skill'] }); } },
[S2]: { kind: 'passive', onStart({ battle, unit }) { battle.addBuff(unit, { key: 'acspec:s2', duration: num(r2?.duration, 10), mods: { atkPct: num(b2.atk), aspd: num(b2.attack_speed) }, tags: ['skill'] }); } },
[S3]: { kind: 'passive' },
},
trait: { hitsFn: (battle, unit) => (battle.rng.chance(unit.findBuff(S1_KEY) ? s1Prob : talentProb) ? 2 : 1) },
talents: [
{ install(battle, unit) { statBuff(battle, unit, 'acspec:t1', { atkPct: num(t0.atk) }); } },
{ install(battle, unit) { toggleBuff(battle, unit, 'acspec:t2', () => enemiesAround4(battle, unit) === cnt, { atkPct: num(t1.atk) }); } },
],
install(battle, unit) {
if (moduleAtk > 0) toggleBuff(battle, unit, 'acspec:module', () => lonely(battle, unit), { atkPct: moduleAtk });
// 空间的归依 "部署后立即": at the `deploy` of every deployment, after the deploy-time buffs (module / talent-2
// toggles, priority 0) are on — a passive's onStart runs earlier, inside skill.reset; a 【移动】 (move: true)
// restarts no passive, as everywhere in the engine
if (unit.skill?.id === S3) {
battle.on('deploy', (c) => { if (c.unit === unit && !c.move && unit.alive && unit.deployed) spaceBurst(battle, unit, r3); }, { owner: unit, priority: -10 });
}
},
};
},
};
@@ -0,0 +1,57 @@
// server/sim/content/kits/ops/standin-sharp2.js — 领主·Sharp (char_617_sharp2, 卫戍协议 6★ 近卫·领主) 补位 stand-in kit.
// Stands in for 水月 (tier 4), 玛恩纳 (tier 5, LOR-X) and 维娜·维多利亚 / 妮芙 (tier 6, LOR-X), always with S1 (her only
// skill): data/backups.json `units.char_617_sharp2.standsIn`. Kit contract: ../README.md "Stand-in kits".
// Numbers: the blackboards of data/backups.json (skill_table / character_table / battle_equip_table); what the data
// does not carry: PRTS 领主·Sharp, cited where used.
import { num, talentBb, moduleBb, skillRec, statBuff, toggleBuff } from '../shared/tier1.js';
import { hasHp, isHpLoss } from '../../../damage.js';
const S1 = 'skchr_sharp2_1';
export default {
// 领主·Sharp — trait (the lord profile, professions.js): ranged attacks at atk_scale (80 %) unless the target stands on
// her tile / the tile in front or she blocks it; range 3-12; hits air units (data canHitFly). Talent 1 无声之锋: ATK
// +atk, arts dodge prob (LOR-X levels 2 / 3 upgrade both). Talent 2 陷阵勇气: ASPD +attack_speed while at least `cnt`
// enemies she can target stand on her attack range [ASSUMED: targetable ones — a 隐匿 enemy she cannot select does not
// count].
// S1 沉默的爆发: ATK +atk, ASPD +attack_speed, attack@max_target targets, "远程攻击不再降低攻击力" (every attack at full
// ATK; the shots stay ranged — unlike 银灰 S3, which "视为近距离攻击"). Range unchanged, data trigger DEFAULT.
// Module LOR-X: a hidden talent `magic_atk_scale` (the trait part is display only). PRTS: "攻击附带10%攻击力的法术伤害
// ※于输出伤害时触发,造成的伤害为法术生命流失" — the popup: it triggers almost no event (输出伤害时 / 受到伤害时) and
// ignores 无敌, 屏障, 护盾, 减伤. So every damage instance she outputs makes its target lose magic_atk_scale × her ATK
// (Battle.loseHp: no DEF / RES, shield, invulnerability or damage multiplier; its own `damaged` is an hpLoss and sets
// off nothing). Unlike the LOR-X of 银灰 / 拉普兰德 / 断崖 / 仇白 (trait atk_scale_m, PRTS "于计算伤害时触发,造成预计算
// 的法术附加伤害": arts damage). [ASSUMED] the client shows the loss in the 流失 (true-damage) colour, it follows a
// fully shielded hit too (damage was output), and it stops when she leaves the field (a shot landing after that).
char_617_sharp2: (bb, chess) => {
const t0 = talentBb(chess, 0), t1 = talentBb(chess, 1);
const magic = num(moduleBb(chess).magic_atk_scale, 0);
const b1 = skillRec(chess, S1)?.bb ?? {};
return {
skills: {
[S1]: {
kind: 'duration',
mods: { atkPct: num(b1.atk), aspd: num(b1.attack_speed) },
targeting: { maxTargets: Math.max(1, Math.floor(num(b1['attack@max_target'], 2))) },
attack: { dmgMul: () => 1 },
},
},
talents: [
{ install(battle, unit) { statBuff(battle, unit, 'sharp2:t1', { atkPct: num(t0.atk), dodgeArts: num(t0.prob) }); } },
{ install(battle, unit) {
const cnt = Math.max(1, Math.floor(num(t1.cnt, 2)));
toggleBuff(battle, unit, 'sharp2:t2', () => battle.enemiesInKeys(unit.rangeKeys, unit, unit.profile).length >= cnt, { aspd: num(t1.attack_speed) });
} },
],
install(battle, unit) {
if (!(magic > 0)) return;
battle.on('damaged', (c) => {
const e = c.target;
if (c.source !== unit || !unit.alive || !unit.deployed || !e || e.side !== 'enemy' || isHpLoss(c.dmg) || !hasHp(e)) return;
battle.loseHp(e, unit.s.atk * magic, { source: unit, tags: ['module', 'sharp2:lorx'] });
}, { owner: unit });
},
};
},
};
+241
View File
@@ -0,0 +1,241 @@
// test/content/standin_acguad.test.js — the 补位 stand-in Sharp (char_609_acguad, 6★ 近卫·无畏者; kit
// server/sim/content/kits/ops/standin-acguad.js) on every chess it replaces: 忍冬 (tier 3, S3), 银灰 / 百炼嘉维尔 (tier 4,
// S2), 隐德来希 / 归溟幽灵鲨 (tier 5, S3 + DRE-X), 佩佩 (tier 6, S3 + DRE-X), normal (E2 Lv1, skill 4) and elite (E2
// Lv60, skill 7). Every number is read back from data/backups.json (the form of that chess's status).
// Run: node --test test/content/standin_acguad.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
import { unitForm } from '../../shared/standIn.js';
const load = (f) => JSON.parse(readFileSync(new URL(`../../data/${f}.json`, import.meta.url), 'utf8'));
const CHESS = load('chess');
const BACKUPS = load('backups');
const CHAR = 'char_609_acguad';
const UNIT = BACKUPS.units[CHAR];
const formOf = (id) => unitForm(BACKUPS, CHAR, CHESS[id].status);
const skillOf = (id, index) => formOf(id).skills.find((s) => s.index === index);
const approx = (a, b, msg, eps = 1e-6) => assert.ok(Math.abs(a - b) <= eps * Math.max(1, Math.abs(b)), `${msg}: ${a} vs ${b}`);
const dummy = (key, o = {}) => enemyRec({ key, hp: 1e9, speed: 0, mass: 0, ...o });
const ENEMIES = { enemy_dummy: dummy('enemy_dummy'), enemy_fly: dummy('enemy_fly', { motion: 'FLY' }) };
function battle(units, o = {}) {
return makeBattle({
defs: { enemies: ENEMIES }, units, timeLimit: o.timeLimit ?? 400, autoFinish: false, seed: o.seed ?? 5,
flags: { dpPerSec: 0 }, hooks: ['damaged', 'skillStart', 'skillEnd'], captureNoisy: true,
});
}
/** Damage instances of the unit's attacks (normal or skill). */
const atkHits = (h, u) => h.hooksOf('damaged').filter((c) => c.source === u && c.dmg?.isAttack);
/** Damage instances per attack (attackId), in order. */
function perAttack(hits) {
const m = new Map();
for (const c of hits) m.set(c.dmg.attackId, (m.get(c.dmg.attackId) ?? 0) + 1);
return [...m.values()];
}
function done(h) {
checkInvariants(h.b);
assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0]));
}
test('Sharp on every chess it replaces (normal + elite): its body, the chess\'s backup skill, this kit; DRE-X on the tier-5 / 6 elites only', () => {
const ids = UNIT.standsIn.flatMap((a) => [a, a.replace(/_a$/, '_b')]);
assert.equal(ids.length, 12);
const withModule = [];
for (const id of ids) {
const c = CHESS[id], form = formOf(id);
const h = battle([{ chessId: id, row: 10, col: 4, standIn: true }]);
h.step();
const u = h.unit(1);
const sk = form.skills.find((s) => s.index === c.backup.skillIndex);
assert.deepEqual([u.def.charId, u.def.standInFor, u.skill.id, !!u.kit.generic, u.kit.skillSource], [CHAR, c.charId, sk.skillId, false, 'skills'], id);
const mod = c.backup.uniEquipId && c.status.equipLevel > 0 ? form.modules.find((m) => m.uniEquipId === c.backup.uniEquipId) : null;
assert.equal(!!u.def.raw.module?.active, !!mod, `${id}: module`);
if (mod) withModule.push(`${id} L${mod.level}`);
assert.equal(u.base.atk, form.stats.atk + (mod?.attr.atk ?? 0), `${id}: ATK`);
assert.equal(u.base.maxHp, form.stats.maxHp + (mod?.attr.maxHp ?? 0), `${id}: max HP`);
assert.deepEqual([u.profile.attack, u.profile.canHitFly, u.s.blockCnt], ['melee', false, 1], `${id}: melee, ground only, blocks 1`);
assert.deepEqual(u.liveRangeGrid, form.rangeGrid, `${id}: range 1-1`);
assert.ok(!u.s.flags.liftoff && !u.s.flags.camou && !u.s.flags.stealth, `${id}: ground enemies can target her`);
done(h);
}
assert.deepEqual(withModule, ['chess_char_5_06_b L1', 'chess_char_5_13_b L1', 'chess_char_6_06_b L3']);
assert.deepEqual(ids.map((id) => CHESS[id].backup.skillIndex), [2, 2, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2]);
});
test('Sharp talents: 隐匿刀刃 ATK + / physical dodge (DRE-X level 3 on 佩佩 精锐: +20 % / 35 %); 寸步不退 ASPD +10 after 30 s on the field, from every deployment', () => {
const want = { chess_char_4_22_a: [0.15, 0.3], chess_char_5_06_b: [0.15, 0.3], chess_char_6_06_b: [0.2, 0.35] };
for (const [id, [atk, dodge]] of Object.entries(want)) {
const h = battle([{ chessId: id, row: 10, col: 4, standIn: true }]);
h.step();
const u = h.unit(1);
const t0 = u.def.raw.talents.find((t) => t.index === 0).bb, t1 = u.def.raw.talents.find((t) => t.index === 1).bb;
assert.deepEqual([t0.atk, t0.prob, t1.interval, t1.attack_speed], [atk, dodge, 30, 10], id);
approx(u.s.atk, u.base.atk * (1 + atk), `${id}: ATK`);
approx(u.s.dodgePhys, dodge, `${id}: physical dodge`);
assert.equal(u.s.dodgeArts, 0, `${id}: no arts dodge`);
h.run(29.8);
assert.equal(u.s.aspd, 100, `${id}: not before 30 s`);
h.run(0.3);
assert.equal(u.s.aspd, 110, `${id}: ASPD +10 after 30 s`);
h.b.retreat(u);
assert.ok(h.b.redeploy(u));
h.step();
assert.equal(u.s.aspd, 100, `${id}: a new deployment starts the count again`);
approx(u.s.dodgePhys, dodge, `${id}: talent 1 survives the redeploy`);
h.run(30.1);
assert.equal(u.s.aspd, 110, `${id}: and 30 s later`);
done(h);
}
});
test('Sharp S1 快刀 (no chess names it — a 自选 slot may): ATK +25 % / +40 % for 30 s, attack@prob1 (20 %) of the attacks are double hits; cast by its data rule once an enemy is on its 1-1 range', () => {
for (const id of ['chess_char_3_18_a', 'chess_char_3_18_b']) {
const sk = skillOf(id, 0);
const h = battle([{ chessId: id, row: 10, col: 4, standIn: { skillIndex: 0 } }]);
h.step();
const u = h.unit(1);
const t0 = u.def.raw.talents.find((t) => t.index === 0).bb;
assert.equal(u.skill.id, 'skchr_acguad_1');
assert.equal(u.skill.rule, String(u.def.skill.trigger.rule).toUpperCase(), `${id}: the data trigger`);
// the roll: one draw per attack with attack@prob1
const seen = [];
const hitsFn = u.kit.skill.attack.hitsFn;
assert.equal(hitsFn({ rng: { chance: (p) => { seen.push(p); return true; } } }, u), 2);
assert.equal(hitsFn({ rng: { chance: (p) => { seen.push(p); return false; } } }, u), 1);
assert.deepEqual(seen, [sk.bb['attack@prob1'], sk.bb['attack@prob1']]);
assert.equal(sk.bb['attack@prob1'], 0.2);
u.skill.gainSp(999);
h.run(2);
assert.equal(u.skill.activations, 0, `${id}: no enemy, no cast`);
h.spawn('enemy_dummy', { pos: [10, 5] }); // the front tile of her 1-1 range
assert.ok(h.runUntil(() => u.skill.active, 2), `${id}: cast with an enemy on its range`);
approx(u.s.atk, u.base.atk * (1 + t0.atk + sk.bb.atk), `${id}: ATK +${sk.bb.atk}`);
approx(u.skill.timeLeft, sk.duration, `${id}: ${sk.duration} s`, 0.01);
assert.deepEqual([sk.rangeId, u.liveRangeGrid], ['1-1', formOf(id).rangeGrid], `${id}: its 技能范围 is her own 1-1: the range stays`);
const n0 = atkHits(h, u).length;
u.skill.extend(270); // 300 s of 快刀 at 1.5 s per attack: 200 attacks
h.runUntil(() => !u.skill.active, 400);
const counts = perAttack(atkHits(h, u).slice(n0).filter((c) => c.dmg.isSkill));
const doubles = counts.filter((n) => n === 2).length;
assert.ok(counts.length >= 195 && counts.every((n) => n === 1 || n === 2), `${id}: ${counts.length} attacks, one or two hits each`);
assert.ok(doubles / counts.length > 0.12 && doubles / counts.length < 0.28, `${id}: ${doubles} / ${counts.length} double hits ≈ 20 %`);
const n1 = atkHits(h, u).length;
h.run(15);
const after = perAttack(atkHits(h, u).slice(n1));
assert.ok(after.length >= 5 && after.every((n) => n === 1), `${id}: single hits once it ends`);
approx(u.s.atk, u.base.atk * (1 + t0.atk), `${id}: ATK back`);
done(h);
}
});
test('Sharp S2 亮剑 (银灰 / 百炼嘉维尔): DEF to 0 — a DEF bonus too —, max HP +30 % / +40 %, every attack at 160 % / 180 % ATK for 30 s; then all back', () => {
for (const id of ['chess_char_4_22_a', 'chess_char_4_22_b', 'chess_char_4_23_a', 'chess_char_4_23_b']) {
const sk = skillOf(id, 1);
const h = battle([{ chessId: id, row: 10, col: 4, standIn: true }]);
h.step();
const u = h.unit(1);
assert.equal(u.skill.id, 'skchr_acguad_2');
assert.equal(u.skill.rule, 'DEFAULT');
h.b.addBuff(u, { key: 'test:def', mods: { defPct: 0.5, defFlat: 100 } });
const def0 = u.s.def;
approx(def0, (u.base.def + 100) * 1.5, `${id}: DEF with the test bonus`);
u.skill.gainSp(999);
h.spawn('enemy_dummy', { pos: [10, 4] }); // DEF 0, blocked by her
assert.ok(h.runUntil(() => u.skill.active, 3), `${id}: cast (DEFAULT)`);
const t = h.b.time;
assert.equal(u.s.def, 0, `${id}: DEF 降至0`);
approx(u.s.maxHp, u.base.maxHp * (1 + sk.bb.max_hp), `${id}: max HP +${sk.bb.max_hp}`);
h.run(4);
const hits = atkHits(h, u).filter((c) => c.dmg.isSkill);
assert.ok(hits.length >= 3, `${id}: attacks during the skill`);
for (const c of hits) approx(c.amount, u.s.atk * sk.bb['attack@atk_scale'], `${id}: ${sk.bb['attack@atk_scale'] * 100} % ATK (no module)`);
assert.ok(h.runUntil(() => !u.skill.active, 40));
approx(h.b.time - t, sk.duration, `${id}: ${sk.duration} s`, 0.01);
approx(u.s.def, def0, `${id}: DEF back`);
approx(u.s.maxHp, u.base.maxHp, `${id}: max HP back`);
const n = atkHits(h, u).length;
h.run(4);
for (const c of atkHits(h, u).slice(n)) approx(c.amount, u.s.atk, `${id}: 100 % after`);
done(h);
}
});
test('Sharp S3 力战不竭 (忍冬 / 佩佩 精锐): ATK +30 % / +45 %; one +16 % / +17 % layer before every attack — the first included — up to 8, all lost on a new target; HP never below 1 while it runs', () => {
for (const id of ['chess_char_3_18_a', 'chess_char_6_06_b']) {
const sk = skillOf(id, 2), bb = sk.bb;
assert.equal(bb.max_atk_stack_cnt, 8);
const h = battle([{ chessId: id, row: 10, col: 4, standIn: true }]);
h.step();
const u = h.unit(1);
assert.equal(u.skill.rule, 'DEFAULT');
const t0 = u.def.raw.talents.find((t) => t.index === 0).bb;
const dre = u.def.traitBb.atk_scale ?? 1; // 佩佩 精锐: DRE-X 115 % on the blocked enemy
assert.equal(dre, id.endsWith('_b') ? 1.15 : 1);
const first = h.spawn('enemy_dummy', { pos: [10, 4] }); // blocked by her
const second = h.spawn('enemy_dummy', { pos: [10, 5] }); // her front tile, nobody blocks it
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.active, 3));
const t = h.b.time;
assert.ok(h.runUntil(() => atkHits(h, u).length >= 9, 16));
const onFirst = atkHits(h, u);
assert.ok(onFirst.every((c) => c.target === first), `${id}: the blocked enemy first`);
onFirst.forEach((c, i) => approx(c.amount, u.base.atk * (1 + t0.atk + bb.atk + bb.atk_each_stack * Math.min(i + 1, 8)) * dre, `${id}: attack ${i + 1} has ${Math.min(i + 1, 8)} layer(s)`));
assert.equal(u.findBuff('acguad:s3stack').stacks, 8, `${id}: at most 8 layers`);
h.b.kill(first);
assert.ok(h.runUntil(() => atkHits(h, u).length >= 11, 4));
const onSecond = atkHits(h, u).slice(9);
assert.ok(onSecond.every((c) => c.target === second));
approx(onSecond[0].amount, u.base.atk * (1 + t0.atk + bb.atk + bb.atk_each_stack), `${id}: a new target — one layer again (and no DRE-X: nobody blocks it)`);
approx(onSecond[1].amount, u.base.atk * (1 + t0.atk + bb.atk + bb.atk_each_stack * 2), `${id}: two`);
// 不会低于1: a lethal hit and a lethal 流失 both leave 1 HP while it runs
h.b.dealDamage(null, u, { type: 'true', amount: 1e7 });
assert.deepEqual([u.alive, u.hp], [true, 1], `${id}: lethal damage`);
h.b.loseHp(u, 1e7);
assert.deepEqual([u.alive, u.hp], [true, 1], `${id}: lethal 流失`);
assert.ok(h.runUntil(() => !u.skill.active, 10));
approx(h.b.time - t, sk.duration, `${id}: ${sk.duration} s`, 0.01);
assert.equal(u.findBuff('acguad:s3stack'), null, `${id}: the layers end with the skill`);
approx(u.s.atk, u.base.atk * (1 + t0.atk), `${id}: ATK back`);
h.b.dealDamage(null, u, { type: 'true', amount: 1e7 });
assert.equal(u.alive, false, `${id}: mortal again`);
done(h);
}
});
test('Sharp DRE-X (隐德来希 精锐): attacks on an enemy blocked by anyone at 115 % ATK, on an unblocked one at 100 %; module \'none\' and the normal chess: 100 %', () => {
const run = (units, enemyAt, extra = null) => {
const h = battle(units);
h.step();
const u = h.unit(1);
if (extra) extra(h, u);
const e = h.spawn('enemy_dummy', { pos: enemyAt });
h.run(6); // before S3 can be cast (initSp 30 / cost 46)
const hits = atkHits(h, u);
assert.ok(hits.length >= 3 && hits.every((c) => c.target === e && !c.dmg.isSkill));
done(h);
return { u, e, ratio: hits[0].amount / u.s.atk };
};
const mine = run([{ chessId: 'chess_char_5_06_b', row: 10, col: 4, standIn: true }], [10, 4]);
assert.equal(mine.e.blockedBy, mine.u);
approx(mine.ratio, 1.15, 'blocked by her');
const ally = run([{ chessId: 'chess_char_5_06_b', row: 10, col: 4, standIn: true }, { chessId: 'chess_char_1_02_a', row: 10, col: 5 }], [10, 5]);
assert.equal(ally.e.blockedBy?.defId, 'chess_char_1_02_a');
approx(ally.ratio, 1.15, 'blocked by the operator in front of her');
approx(run([{ chessId: 'chess_char_5_06_b', row: 10, col: 4, standIn: true }], [10, 5]).ratio, 1, 'nobody blocks it');
approx(run([{ chessId: 'chess_char_5_06_b', row: 10, col: 4, standIn: { moduleId: 'none' } }], [10, 4]).ratio, 1, 'module none');
approx(run([{ chessId: 'chess_char_5_06_a', row: 10, col: 4, standIn: true }], [10, 4]).ratio, 1, 'normal chess: no module');
});
test('Sharp is ground only: an air unit on her tile is never attacked', () => {
const h = battle([{ chessId: 'chess_char_4_22_b', row: 10, col: 4, standIn: true }]);
h.step();
const u = h.unit(1);
h.spawn('enemy_fly', { pos: [10, 4], route: 2 });
h.run(8);
assert.equal(atkHits(h, u).length, 0);
assert.equal(u.skill.activations, 0, 'S2 (DEFAULT) has nothing to attack');
done(h);
});
+220
View File
@@ -0,0 +1,220 @@
// test/content/standin_acspec.test.js — the 补位 stand-in Misery (char_615_acspec, 6★ 特种·处决者; kit
// server/sim/content/kits/ops/standin-acspec.js) on every chess she replaces: 缄默德克萨斯 (tier 4, S2), 山 (tier 5, S3 +
// EXE-X), 新约能天使 / 锏 (tier 6, S3 + EXE-X), normal (E2 Lv1, skill 4) and elite (E2 Lv60, skill 7). Her three skills
// are ON_DEPLOY passives: they start at every deployment — at the battle start no enemy is on the field yet, so S3 hits
// nothing there and matters on a redeploy (再部署时间大幅度减少: 18 s). Numbers are read back from data/backups.json.
// Run: node --test test/content/standin_acspec.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
import { unitForm } from '../../shared/standIn.js';
import { PULL_STOP_RADIUS, TICK } from '../../server/sim/constants.js';
const load = (f) => JSON.parse(readFileSync(new URL(`../../data/${f}.json`, import.meta.url), 'utf8'));
const CHESS = load('chess');
const BACKUPS = load('backups');
const CHAR = 'char_615_acspec';
const UNIT = BACKUPS.units[CHAR];
const formOf = (id) => unitForm(BACKUPS, CHAR, CHESS[id].status);
const approx = (a, b, msg, eps = 1e-6) => assert.ok(Math.abs(a - b) <= eps * Math.max(1, Math.abs(b)), `${msg}: ${a} vs ${b}`);
const dummy = (key, o = {}) => enemyRec({ key, hp: 1e9, speed: 0, mass: 0, ...o });
const ENEMIES = { enemy_dummy: dummy('enemy_dummy'), enemy_fly: dummy('enemy_fly', { motion: 'FLY' }) };
const ALLY = 'chess_char_1_02_a'; // 角峰, a melee blocker
function battle(units, o = {}) {
return makeBattle({
defs: { enemies: ENEMIES }, units, timeLimit: o.timeLimit ?? 400, autoFinish: false, seed: o.seed ?? 5,
flags: { dpPerSec: 0 }, hooks: ['damaged', 'deploy', 'death'], captureNoisy: true,
});
}
const MISERY = (id, standIn = true) => ({ chessId: id, row: 10, col: 4, standIn });
const atkHits = (h, u) => h.hooksOf('damaged').filter((c) => c.source === u && c.dmg?.isAttack);
const tagged = (h, u, tag) => h.hooksOf('damaged').filter((c) => c.source === u && (c.dmg?.tags || []).includes(tag));
const talentBb = (u, i) => u.def.raw.talents.find((t) => t.index === i)?.bb ?? {};
function done(h) {
checkInvariants(h.b);
assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0]));
}
test('Misery on every chess she replaces (normal + elite): her body (再部署 18 s), the backup skill as a passive, this kit; EXE-X on the tier-5 / 6 elites only', () => {
const ids = UNIT.standsIn.flatMap((a) => [a, a.replace(/_a$/, '_b')]);
assert.equal(ids.length, 8);
const withModule = [];
for (const id of ids) {
const c = CHESS[id], form = formOf(id);
const h = battle([MISERY(id)]);
h.step();
const u = h.unit(1);
const sk = form.skills.find((s) => s.index === c.backup.skillIndex);
assert.deepEqual([u.def.charId, u.def.standInFor, u.skill.id, u.skill.kind, !!u.kit.generic, u.kit.skillSource], [CHAR, c.charId, sk.skillId, 'passive', false, 'skills'], id);
assert.deepEqual([sk.skillType, sk.spType], ['PASSIVE', 'ON_DEPLOY'], id);
const mod = c.backup.uniEquipId && c.status.equipLevel > 0 ? form.modules.find((m) => m.uniEquipId === c.backup.uniEquipId) : null;
assert.equal(!!u.def.raw.module?.active, !!mod, `${id}: module`);
if (mod) withModule.push(`${id} L${mod.level}`);
assert.equal(u.base.atk, form.stats.atk + (mod?.attr.atk ?? 0), `${id}: ATK`);
assert.equal(u.base.def, form.stats.def + (mod?.attr.def ?? 0), `${id}: DEF`);
assert.equal(u.base.respawnTime, 18, `${id}: 再部署时间大幅度减少`);
assert.deepEqual([u.profile.attack, u.profile.canHitFly, u.s.blockCnt], ['melee', false, 1], `${id}: melee, ground only, blocks 1`);
assert.ok(!u.s.flags.liftoff && !u.s.flags.camou && !u.s.flags.stealth, `${id}: ground enemies can target her`);
done(h);
}
assert.deepEqual(withModule, ['chess_char_5_17_b L1', 'chess_char_6_13_b L3', 'chess_char_6_19_b L3']);
assert.deepEqual(ids.map((id) => CHESS[id].backup.skillIndex), [1, 1, 2, 2, 2, 2, 2, 2]);
});
test('Misery 二象命末: each attack is a double hit with attack@prob (10 %; EXE-X level 3: 15 % and ATK +5 %)', () => {
for (const [id, prob, atk] of [['chess_char_5_17_a', 0.1, 0], ['chess_char_5_17_b', 0.1, 0], ['chess_char_6_19_b', 0.15, 0.05]]) {
const h = battle([MISERY(id)]);
h.step();
const u = h.unit(1);
const seen = [];
assert.equal(u.profile.hitsFn({ rng: { chance: (p) => { seen.push(p); return true; } } }, u), 2);
assert.equal(u.profile.hitsFn({ rng: { chance: (p) => { seen.push(p); return false; } } }, u), 1);
assert.deepEqual(seen, [prob, prob], `${id}: one draw per attack at ${prob}`);
assert.equal(u.findBuff('acspec:t1')?.mods.atkPct, atk || undefined, `${id}: talent ATK`);
h.spawn('enemy_dummy', { pos: [10, 4] });
h.run(300);
const per = new Map();
for (const c of atkHits(h, u)) per.set(c.dmg.attackId, (per.get(c.dmg.attackId) ?? 0) + 1);
const counts = [...per.values()], doubles = counts.filter((n) => n === 2).length;
assert.ok(counts.length > 250 && counts.every((n) => n === 1 || n === 2), `${id}: ${counts.length} attacks of one or two hits`);
assert.ok(Math.abs(doubles / counts.length - prob) < 0.06, `${id}: ${doubles} / ${counts.length} doubles ≈ ${prob}`);
done(h);
}
});
test('Misery 四维分离: ATK +10 % while exactly one enemy stands on her tile or the four beside it (a diagonal one does not count; an air unit does)', () => {
const h = battle([MISERY('chess_char_5_17_a')]);
h.step();
const u = h.unit(1);
const t1 = talentBb(u, 1);
assert.deepEqual([t1.atk, t1.cnt], [0.1, 1]);
const on = () => !!u.findBuff('acspec:t2');
const check = (want, msg) => {
h.step();
assert.equal(on(), want, msg);
approx(u.s.atk, u.base.atk * (1 + (want ? t1.atk : 0)), `${msg}: ATK`);
};
check(false, 'no enemy');
h.spawn('enemy_dummy', { pos: [10, 5] });
check(true, 'one in front');
h.spawn('enemy_dummy', { pos: [11, 5] });
check(true, 'plus a diagonal one');
const f = h.spawn('enemy_fly', { pos: [9, 4], route: 2 });
check(false, 'plus an air unit above her: two');
h.b.kill(f);
check(true, 'the air unit gone');
h.spawn('enemy_dummy', { pos: [10, 4] });
check(false, 'plus one on her tile: two');
done(h);
});
test('Misery EXE-X (山 精锐): ATK +10 % while no ally stands on the four tiles beside her (diagonal allies do not count); module \'none\' and the normal chess: never', () => {
const run = (standIn, allyAt, id = 'chess_char_5_17_b') => {
const units = [MISERY(id, standIn)];
if (allyAt) units.push({ chessId: ALLY, row: allyAt[0], col: allyAt[1] });
const h = battle(units);
h.run(0.5);
const u = h.unit(1);
const on = !!u.findBuff('acspec:module');
approx(u.s.atk, u.base.atk * (1 + (on ? 0.1 : 0)), `${id} ${allyAt}: ATK`);
done(h);
return on;
};
assert.equal(run(true, null), true, 'alone');
assert.equal(run(true, [10, 5]), false, 'an ally in front');
assert.equal(run(true, [11, 4]), false, 'an ally below');
assert.equal(run(true, [11, 5]), true, 'an ally on a diagonal');
assert.equal(run({ moduleId: 'none' }, null), false, 'module none');
assert.equal(run(true, null, 'chess_char_5_17_a'), false, 'normal chess');
});
test('Misery S1 物理的服从 (no chess names it — a 自选 slot may): for 10 s from every deployment, physical dodge 45 % / 60 % and the talent-1 chance 25 %', () => {
for (const id of ['chess_char_4_16_a', 'chess_char_4_16_b']) {
const sk = formOf(id).skills[0], bb = sk.bb;
const h = battle([MISERY(id, { skillIndex: 0 })]);
h.step();
const u = h.unit(1);
assert.deepEqual([u.skill.id, u.skill.kind, sk.duration], ['skchr_acspec_1', 'passive', 10]);
const prob = () => { let p = null; u.profile.hitsFn({ rng: { chance: (x) => { p = x; return false; } } }, u); return p; };
const on = () => { approx(u.s.dodgePhys, bb.prob, `${id}: dodge ${bb.prob}`); assert.equal(prob(), bb['attack@prob'], `${id}: chance ${bb['attack@prob']}`); };
const off = () => { assert.equal(u.s.dodgePhys, 0, `${id}: no dodge`); assert.equal(prob(), talentBb(u, 0)['attack@prob'], `${id}: the talent's chance`); };
on();
h.run(9.8);
on();
h.run(0.3);
off();
h.b.retreat(u);
assert.ok(h.b.redeploy(u));
h.step();
on();
h.run(10.1);
off();
done(h);
}
});
test('Misery S2 战争的恭顺 (缄默德克萨斯): for 10 s from every deployment ATK +30 % / +45 % and ASPD +15', () => {
for (const id of ['chess_char_4_16_a', 'chess_char_4_16_b']) {
const sk = formOf(id).skills[1], bb = sk.bb;
const h = battle([MISERY(id)]);
h.step();
const u = h.unit(1);
assert.deepEqual([u.skill.id, sk.duration], ['skchr_acspec_2', 10]);
const on = () => { approx(u.s.atk, u.base.atk * (1 + bb.atk), `${id}: ATK +${bb.atk}`); assert.equal(u.s.aspd, 100 + bb.attack_speed, `${id}: ASPD`); };
const off = () => { approx(u.s.atk, u.base.atk, `${id}: ATK back`); assert.equal(u.s.aspd, 100, `${id}: ASPD back`); };
on();
h.run(9.8);
on();
h.run(0.3);
off();
h.b.retreat(u);
assert.ok(h.b.redeploy(u));
h.step();
on();
h.run(10.1);
off();
done(h);
}
});
test('Misery S3 空间的归依 (山 / 锏): nothing at the battle start; knocked out, back after 18 s: 210 % / 240 % ATK to the ground enemies around her, then those nobody blocks dragged to her front with 2.5 s 停顿', () => {
for (const id of ['chess_char_5_17_a', 'chess_char_6_19_b']) {
const sk = formOf(id).skills[2], bb = sk.bb;
assert.deepEqual([sk.rangeId, bb.force, bb.sluggish], ['x-4', 0, 2.5]);
const h = battle([MISERY(id), { chessId: ALLY, row: 11, col: 4 }]);
h.step();
const u = h.unit(1);
assert.equal(tagged(h, u, 'acspec:burst').length, 0, `${id}: the battle-start deployment meets no enemy`);
const at = (pos, key = 'enemy_dummy') => h.spawn(key, { pos, ...(key === 'enemy_fly' ? { route: 2 } : {}) });
const diag = at([11, 5]), above = at([9, 4]), held = at([11, 4]), far = at([10, 6]), fly = at([9, 3], 'enemy_fly');
h.step();
assert.equal(held.blockedBy?.defId, ALLY, `${id}: one enemy held by the operator below her`);
const start = new Map([diag, above, held, far, fly].map((e) => [e, [e.x, e.y]]));
h.b.players[0].dp = 99;
h.b.kill(u);
const tk = h.b.time;
assert.ok(h.runUntil(() => u.alive && u.deployed, 30), `${id}: redeployed`);
assert.ok(h.b.time - tk >= 18 - 1e-9 && h.b.time - tk <= 18 + 2 * TICK, `${id}: back after 18 s (${h.b.time - tk})`);
// the burst: three ground enemies around her (the held one included); no air unit, nothing two tiles away
const burst = tagged(h, u, 'acspec:burst');
assert.deepEqual(new Set(burst.map((c) => c.target)), new Set([diag, above, held]), `${id}: the enemies around her`);
const atk = u.base.atk * (1 + (talentBb(u, 0).atk ?? 0)); // not alone (the operator below), two enemies beside her
for (const c of burst) { approx(c.amount, atk * bb.atk_scale, `${id}: ${bb.atk_scale * 100} % ATK`); assert.equal(c.type, 'phys'); assert.ok(c.dmg.isSkill); }
// the drag and the 停顿 ride a 0-damage hit on the ones nobody blocks
assert.deepEqual(new Set(tagged(h, u, 'acspec:pull').map((c) => c.target)), new Set([diag, above]));
for (const e of [diag, above]) {
approx(Math.hypot(e.x - u.x, e.y - u.y), PULL_STOP_RADIUS, `${id}: dragged to her front (weight 0, 小力)`, 1e-3);
assert.ok(Math.hypot(e.x - start.get(e)[0], e.y - start.get(e)[1]) > 0.2, `${id}: it moved`);
const s = e.findBuff('sluggish');
assert.ok(s && s.timeLeft > bb.sluggish - 0.1 && s.timeLeft <= bb.sluggish + 1e-9, `${id}: 停顿 ${bb.sluggish} s`);
}
for (const e of [held, far, fly]) {
assert.deepEqual([e.x, e.y], start.get(e), `${id}: ${e.defId} stays`);
assert.equal(e.findBuff('sluggish'), null, `${id}: ${e.defId} no 停顿`);
}
done(h);
}
});
+176
View File
@@ -0,0 +1,176 @@
// test/content/standin_sharp2.test.js — the 补位 stand-in 领主·Sharp (char_617_sharp2, 6★ 近卫·领主; kit
// server/sim/content/kits/ops/standin-sharp2.js) on every chess it replaces, always with S1 沉默的爆发: 水月 (tier 4),
// 玛恩纳 (tier 5, LOR-X), 维娜·维多利亚 / 妮芙 (tier 6, LOR-X), normal (E2 Lv1, skill 4) and elite (E2 Lv60, skill 7).
// Every number is read back from data/backups.json (the form of that chess's status).
// Run: node --test test/content/standin_sharp2.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
import { unitForm } from '../../shared/standIn.js';
import { isHpLoss } from '../../server/sim/damage.js';
const load = (f) => JSON.parse(readFileSync(new URL(`../../data/${f}.json`, import.meta.url), 'utf8'));
const CHESS = load('chess');
const BACKUPS = load('backups');
const CHAR = 'char_617_sharp2';
const UNIT = BACKUPS.units[CHAR];
const formOf = (id) => unitForm(BACKUPS, CHAR, CHESS[id].status);
const approx = (a, b, msg, eps = 1e-6) => assert.ok(Math.abs(a - b) <= eps * Math.max(1, Math.abs(b)), `${msg}: ${a} vs ${b}`);
const dummy = (key, o = {}) => enemyRec({ key, hp: 1e9, speed: 0, ...o });
const ENEMIES = {
enemy_dummy: dummy('enemy_dummy'), enemy_fly: dummy('enemy_fly', { motion: 'FLY' }), enemy_res: dummy('enemy_res', { res: 50 }),
};
function battle(units, o = {}) {
return makeBattle({
defs: { enemies: ENEMIES }, units, timeLimit: o.timeLimit ?? 200, autoFinish: false, seed: o.seed ?? 5,
flags: { dpPerSec: 0 }, hooks: ['damaged', 'skillStart', 'skillEnd'], captureNoisy: true,
});
}
const SHARP2 = (id, standIn = true) => ({ chessId: id, row: 10, col: 4, standIn });
/** Her normal / skill attack hits (no 流失). */
const atkHits = (h, u) => h.hooksOf('damaged').filter((c) => c.source === u && c.dmg?.isAttack);
/** The LOR-X 流失 she causes. */
const lorx = (h, u) => h.hooksOf('damaged').filter((c) => c.source === u && isHpLoss(c.dmg) && c.dmg.tags.includes('sharp2:lorx'));
function done(h) {
checkInvariants(h.b);
assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0]));
}
test('领主·Sharp on every chess she replaces (normal + elite): her body and range 3-12, S1, this kit; LOR-X on the tier-5 / 6 elites only', () => {
const ids = UNIT.standsIn.flatMap((a) => [a, a.replace(/_a$/, '_b')]);
assert.equal(ids.length, 8);
const withModule = [];
for (const id of ids) {
const c = CHESS[id], form = formOf(id);
const h = battle([SHARP2(id)]);
h.step();
const u = h.unit(1);
assert.deepEqual([u.def.charId, u.def.standInFor, u.skill.id, !!u.kit.generic, u.kit.skillSource], [CHAR, c.charId, 'skchr_sharp2_1', false, 'skills'], id);
const mod = c.backup.uniEquipId && c.status.equipLevel > 0 ? form.modules.find((m) => m.uniEquipId === c.backup.uniEquipId) : null;
assert.equal(!!u.def.raw.module?.active, !!mod, `${id}: module`);
if (mod) withModule.push(`${id} L${mod.level}`);
assert.equal(u.base.atk, form.stats.atk + (mod?.attr.atk ?? 0), `${id}: ATK`);
assert.deepEqual([u.profile.sub, u.profile.attack, u.profile.canHitFly, u.s.blockCnt, u.profile.rangedScale], ['lord', 'ranged', true, 2, 0.8], `${id}: lord, ranged, hits air units, blocks 2, trait 80 %`);
assert.deepEqual(u.rangeGrid, form.rangeGrid, `${id}: range`);
assert.equal(form.rangeId, '3-12');
assert.equal(u.skill.rule, 'DEFAULT', `${id}: S1 has no range of its own — the basic strategy`);
assert.ok(!u.s.flags.liftoff && !u.s.flags.camou && !u.s.flags.stealth, `${id}: ground enemies can target her`);
done(h);
}
assert.deepEqual(withModule, ['chess_char_5_19_b L1', 'chess_char_6_07_b L3', 'chess_char_6_10_b L3']);
});
test('领主·Sharp trait: ranged attacks at 80 % ATK — full ATK on her tile, the tile in front and an enemy she blocks; air units too; nothing beyond the 3-12 range', () => {
const ratio = (key, pos) => {
const h = battle([SHARP2('chess_char_4_09_a')]);
h.step();
const u = h.unit(1);
const e = h.spawn(key, { pos, ...(key === 'enemy_fly' ? { route: 2 } : {}) });
h.run(6); // S1 is not ready before 23 s
const hits = atkHits(h, u);
done(h);
if (!hits.length) return null;
assert.ok(hits.every((c) => c.target === e && !c.dmg.isSkill));
return hits[0].amount / u.s.atk;
};
approx(ratio('enemy_dummy', [10, 6]), 0.8, 'two tiles ahead: ranged');
approx(ratio('enemy_dummy', [10, 7]), 0.8, 'the far end of her line');
approx(ratio('enemy_dummy', [9, 5]), 0.8, 'the diagonal tile');
approx(ratio('enemy_dummy', [10, 5]), 1, 'the tile in front');
approx(ratio('enemy_dummy', [10, 4]), 1, 'blocked on her tile');
approx(ratio('enemy_fly', [10, 6]), 0.8, 'an air unit');
assert.equal(ratio('enemy_dummy', [10, 8]), null, 'out of range');
});
test('领主·Sharp S1 沉默的爆发 (水月): ATK +130 % / +180 %, ASPD +35 / +50, two targets, no ranged reduction for 30 s; then one target at 80 % again', () => {
for (const id of ['chess_char_4_09_a', 'chess_char_4_09_b']) {
const sk = formOf(id).skills[0], bb = sk.bb;
const h = battle([SHARP2(id)]);
h.step();
const u = h.unit(1);
const t0 = u.def.raw.talents.find((t) => t.index === 0).bb, t1 = u.def.raw.talents.find((t) => t.index === 1).bb;
const near = h.spawn('enemy_dummy', { pos: [10, 6] }), far = h.spawn('enemy_dummy', { pos: [10, 7] });
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.active, 3), `${id}: cast (DEFAULT)`);
const t = h.b.time;
approx(u.s.atk, u.base.atk * (1 + t0.atk + bb.atk), `${id}: ATK +${bb.atk}`);
assert.equal(u.s.aspd, 100 + bb.attack_speed + t1.attack_speed, `${id}: ASPD +${bb.attack_speed} (and 陷阵勇气 with two enemies)`);
assert.deepEqual(u.liveRangeGrid, formOf(id).rangeGrid, `${id}: the range stays 3-12`);
h.run(10);
const hits = atkHits(h, u).filter((c) => c.dmg.isSkill && c.t < t + 10);
const per = new Map();
for (const c of hits) per.set(c.dmg.attackId, [...(per.get(c.dmg.attackId) ?? []), c.target]);
assert.ok(per.size >= 5, `${id}: ${per.size} attacks`);
for (const targets of per.values()) assert.deepEqual(new Set(targets), new Set([near, far]), `${id}: both targets every attack`);
for (const c of hits) approx(c.amount, u.s.atk, `${id}: full ATK at range`);
assert.ok(h.runUntil(() => !u.skill.active, 25));
approx(h.b.time - t, sk.duration, `${id}: ${sk.duration} s`, 0.01);
const n = atkHits(h, u).length;
h.run(5);
const after = atkHits(h, u).slice(n).filter((c) => c.t > h.b.time - 4);
assert.ok(after.length >= 2, `${id}: attacks after`);
const ids = new Map();
for (const c of after) { ids.set(c.dmg.attackId, (ids.get(c.dmg.attackId) ?? 0) + 1); approx(c.amount, u.s.atk * 0.8, `${id}: 80 % again`); }
assert.ok([...ids.values()].every((v) => v === 1), `${id}: one target again`);
done(h);
}
});
test('领主·Sharp talents: 无声之锋 ATK + / arts dodge (LOR-X level 3: +25 % / 35 %); 陷阵勇气 ASPD +12 while two enemies she can target are on her range', () => {
const want = { chess_char_4_09_a: [0.2, 0.25], chess_char_5_19_b: [0.2, 0.25], chess_char_6_10_b: [0.25, 0.35] };
for (const [id, [atk, dodge]] of Object.entries(want)) {
const h = battle([SHARP2(id)]);
h.step();
const u = h.unit(1);
const t1 = u.def.raw.talents.find((t) => t.index === 1).bb;
assert.deepEqual([t1.cnt, t1.attack_speed], [2, 12]);
approx(u.s.atk, u.base.atk * (1 + atk), `${id}: ATK`);
approx(u.s.dodgeArts, dodge, `${id}: arts dodge`);
assert.equal(u.s.dodgePhys, 0, `${id}: no physical dodge`);
h.spawn('enemy_dummy', { pos: [10, 6] });
h.spawn('enemy_dummy', { pos: [10, 8] }); // out of her range
h.run(0.5);
assert.equal(u.s.aspd, 100, `${id}: one enemy on her range`);
h.spawn('enemy_fly', { pos: [9, 5], route: 2 });
h.run(0.5);
assert.equal(u.s.aspd, 112, `${id}: two (an air unit counts: she can target it)`);
done(h);
}
});
test('领主·Sharp LOR-X (玛恩纳 / 妮芙 精锐): every damage she deals makes its target lose 10 % of her ATK — a 流失: no RES, through a shield; nothing without the module', () => {
for (const id of ['chess_char_5_19_b', 'chess_char_6_10_b']) {
const h = battle([SHARP2(id)]);
h.step();
const u = h.unit(1);
const m = u.def.raw.talents.find((t) => t.index === -1).bb.magic_atk_scale;
assert.equal(m, 0.1);
const e = h.spawn('enemy_res', { pos: [10, 6] }); // RES 50
h.run(4);
const hits = atkHits(h, u), losses = lorx(h, u);
assert.ok(hits.length >= 2 && losses.length === hits.length, `${id}: one loss per hit (${losses.length} / ${hits.length})`);
for (const c of hits) approx(c.amount, u.s.atk * 0.8, `${id}: the attack: physical, 80 % at range (DEF 0)`);
for (const c of losses) { assert.equal(c.target, e); approx(c.amount, u.s.atk * m, `${id}: the arts 流失 ignores RES 50`); }
// a shield takes the attack but not the loss
h.b.addBuff(e, { key: 'test:shield', shield: 1e9 });
const hp = e.hp, n = lorx(h, u).length;
h.run(4);
const more = lorx(h, u).length - n;
assert.ok(more >= 2, `${id}: losses behind the shield`);
approx(hp - e.hp, more * u.s.atk * m, `${id}: only the losses reach the HP`);
done(h);
}
for (const [id, standIn] of [['chess_char_4_09_b', true], ['chess_char_6_10_b', { moduleId: 'none' }], ['chess_char_6_10_a', true]]) {
const h = battle([SHARP2(id, standIn)]);
h.step();
const u = h.unit(1);
h.spawn('enemy_dummy', { pos: [10, 6] });
h.run(4);
assert.ok(atkHits(h, u).length >= 2);
assert.equal(lorx(h, u).length, 0, `${id} ${JSON.stringify(standIn)}: no LOR-X`);
done(h);
}
});