Files
ark-stronghold-protocol/test/content/standin_accast.test.js
T
sganggs f317adf003 feedback5 K2: stand-in kits for Stormeye, Pith and Raidian — every skill, talent and module at every form
Stormeye (char_611_acnipe; 空弦, 莫斯提马, 百炼嘉维尔 / 妮芙, 蕾缪安 / 异客): 风坠 rolls on every attack instance
(acnipe_t_1: ATK × atk_scale before DEF; S3 talent@prob), 风雨欲来 +0.2 SP/s after `delay` s without an attack
[ASSUMED reading], S1 ATK / ASPD / 100 DEF ignored, S2 an endless toggle (ATK +5 %, 2 targets), S3 3 targets × double
hit plus one physical ATK × 1.0 instance on a target at >= 90 % HP on the attack's first hit (acnipe_s_3[extra], no
风坠 / MAR-X on it); MAR-X stays the fastshot profile's flyScale.

Pith (char_612_accast; 烛煌, 焰影苇草 / 迷迭香 / 荒芜拉普兰德): splash from the trait blackboard, 12 RES ignored, ATK
+10 / +20 % on herself and the 【术师】 of the 4 tiles next to her, S1 ASPD + splash 1.5, S2 ASPD + ATK on every 【术师】,
S3 one ATK × atk_scale_aoe arts hit on every enemy of her range at the cast, then 2 targets, ASPD, splash 1.2 (PRTS
备注); SPC-X range (moduleRangeUp).

Raidian (char_614_acsupo; 灵知, 夕 / 安洁莉娜, 浊心斯卡蒂 / 仇白): 同调 on herself and the operators next to her (×
talent_scale in S3), 诱引 per PRTS 命中率 — one roll per enemy attack in her range, a miss cancels the whole attack
before the enemy riders (hit priority 300), S1 / S2 from the blackboards (S2 flat −0.1 / −0.2 s, 停顿 1.1 / 1.2 s), S3
y-4 + ATK + 脆弱 / 虚弱 aura with the 虚弱 stacking as 90 / 80 % (stackAs); S3 casts by the data's ACTIVE_RANGE;
DEC-X +0.2 SP/s with an enemy in range.

Triggers stay the data's (AUTO attack buffs Stormeye S2 / Pith S1 keep DEFAULT, as the W5C AUTO audit). No golden
scenario fields a stand-in: digests unchanged. Tests: test/content/standin_acnipe / _accast / _acsupo (27).
2026-10-05 18:34:46 +08:00

210 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// test/content/standin_accast.test.js — the 补位 stand-in Pith (char_612_accast, kits/ops/standin-accast.js) on every chess
// it replaces (烛煌 T5, 焰影苇草 / 迷迭香 / 荒芜拉普兰德 T6, all S3 + SPC-X), normal and elite: every skill (S1 "书我所书", S2
// "为我所为", S3 "驭我所驭" with its burst), both talents (12 RES ignored; ATK +10 % on herself and the 【术师】 of the 4 tiles
// next to her), the module (SPC-X range, stats, T2 +20 % at level 3), the splash radius per skill, the data triggers,
// anti-air.
// Run: node --test test/content/standin_accast.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 { absoluteRangeKeys } from '../../server/sim/targeting.js';
import { COLS } 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 PITH = 'char_612_accast';
const [S1, S2, S3] = ['skchr_accast_1', 'skchr_accast_2', 'skchr_accast_3'];
const RECORDS = BACKUPS.units[PITH].standsIn.flatMap((a) => [a, a.replace(/_a$/, '_b')]);
const DUMMIES = {
d: enemyRec({ key: 'd', hp: 1e7, speed: 0 }),
r: enemyRec({ key: 'r', hp: 1e7, res: 50, speed: 0 }),
f: enemyRec({ key: 'f', hp: 1e7, speed: 0, motion: 'FLY' }),
};
const close = (a, b, msg, eps = 1e-6) => assert.ok(Math.abs(a - b) <= eps, `${msg}: ${a} vs ${b}`);
const formOf = (id) => unitForm(BACKUPS, PITH, CHESS[id].status);
const skillOf = (id, sid) => formOf(id).skills.find((s) => s.skillId === sid);
const moduleOf = (id) => {
const c = CHESS[id];
return c.status.equipLevel > 0 && c.backup.uniEquipId ? formOf(id).modules.find((m) => m.uniEquipId === c.backup.uniEquipId) : null;
};
const key = (r, c) => r * COLS + c;
/** Pith at (10,4) facing right (3-6: rows 9–11, cols 4–6; SPC-X adds (10,7)); dummies at `enemies`; extra `units`. */
function arena(chessId, { standIn = true, enemies = [], units = [], sp = 999, seed = 5, timeLimit = 200 } = {}) {
return makeBattle({
defs: { enemies: DUMMIES }, seed, timeLimit, autoFinish: false,
units: [{ chessId, row: 10, col: 4, dir: 'RIGHT', standIn, ...(sp != null ? { carryState: { sp } } : {}) }, ...units],
enemies, hooks: ['skillStart', 'skillEnd', 'damaged', 'attack'], captureNoisy: true,
});
}
const done = (h) => { assert.deepEqual(h.b.errors.map((e) => `${e.label} ${e.message}`), []); checkInvariants(h.b); };
const hits = (h, u, t0 = -1) => h.hooksOf('damaged').filter((c) => c.source === u && c.t >= t0);
test('Pith on every chess it replaces: the stand-in body, its kit, S3; SPC-X on the elites (level 1 at T5, 3 at T6) with its range; arts splash 1.1, anti-air', () => {
assert.equal(RECORDS.length, 8);
for (const id of RECORDS) {
const c = CHESS[id];
const form = formOf(id), mod = moduleOf(id);
const h = arena(id, { sp: null });
h.step();
const u = h.unit(1);
const where = `${id} ${c.name}`;
assert.deepEqual([u.def.charId, u.def.standInFor, u.skill.id, !!u.kit.generic, u.kit.skillSource], [PITH, c.charId, S3, false, 'skills'], where);
assert.equal(!!u.def.raw.module?.active, !!mod, `${where}: module only on the elite`);
assert.equal(!!mod, id.endsWith('_b'), where);
if (mod) assert.equal(u.def.raw.module.level, c.tier === 6 ? 3 : 1, where);
assert.equal(u.base.atk, form.stats.atk + (mod ? mod.attr.atk : 0), `${where}: ATK`);
assert.equal(u.base.maxHp, form.stats.maxHp + (mod ? mod.attr.maxHp : 0), `${where}: HP`);
assert.deepEqual([u.profile.dmgType, u.profile.splashRadius, u.profile.canHitFly], ['arts', 1.1, true], `${where}: profile`);
// the range: 3-6 (9 tiles), SPC-X its 3-1 grid (+ the tile [0,3])
const want = absoluteRangeKeys(mod ? mod.talentChanges.find((t) => t.talentIndex === -1).rangeGrid : form.rangeGrid, 10, 4, 'RIGHT', 0);
assert.deepEqual([...u.rangeKeys].sort((a, b) => a - b), [...want].sort((a, b) => a - b), `${where}: range`);
assert.equal(u.rangeKeySet.has(key(10, 7)), !!mod, `${where}: (10,7) only with SPC-X`);
const t1 = u.def.raw.talents.find((t) => t.index === 1);
assert.equal(t1.bb.atk, mod && mod.level === 3 ? 0.2 : 0.1, `${where}: 授我所授`);
done(h);
}
});
test('Pith triggers: the data rules — S1 AUTO DEFAULT (an attack buff waits for her attack), S2 / S3 MANUAL DEFAULT', () => {
for (const id of ['chess_char_5_03_a', 'chess_char_5_03_b']) {
for (const [i, sid] of [[0, S1], [1, S2], [2, S3]]) {
const h = arena(id, { standIn: { skillIndex: i }, sp: null });
h.step();
const u = h.unit(1);
assert.deepEqual([u.skill.id, u.skill.rule, u.def.skill.skillType], [sid, 'DEFAULT', sid === S1 ? 'AUTO' : 'MANUAL'], `${id} ${sid}`);
done(h);
}
}
});
test('"见我所见": her arts hits ignore 12 RES (flat 法术穿透)', () => {
for (const id of ['chess_char_5_03_a', 'chess_char_6_12_b']) {
const h = arena(id, { sp: null, enemies: [{ key: 'r', pos: [10, 6] }] });
const u = h.unit(1);
assert.equal(u.s.resIgnoreFlat, 12);
h.run(1);
const t0 = h.b.time;
assert.ok(h.runUntil(() => hits(h, u, t0).length > 0, 5));
close(hits(h, u, t0)[0].amount, u.s.atk * (1 - (50 - 12) / 100), `${id}: RES 50 − 12`, 1e-3);
done(h);
}
});
test('"授我所授": ATK +10 % (T6 elite: +20 %) on herself and the 【术师】 operators of the 4 tiles next to her — not diagonal, not farther, not another class', () => {
for (const [id, v] of [['chess_char_5_03_a', 0.1], ['chess_char_5_03_b', 0.1], ['chess_char_6_08_b', 0.2]]) {
const h = arena(id, { sp: null, units: [
{ chessId: 'chess_char_1_03_a', row: 10, col: 3 }, // 惊蛰 (CASTER), behind her: adjacent
{ chessId: 'chess_char_1_14_a', row: 9, col: 3 }, // 格雷伊 (CASTER), diagonal
{ chessId: 'chess_char_1_17_a', row: 12, col: 4 }, // 深靛 (CASTER), two tiles away
{ chessId: 'chess_char_1_02_a', row: 11, col: 4 }, // 角峰 (TANK), adjacent
] });
h.run(1);
const [p, adj, diag, far, tank] = [1, 2, 3, 4, 5].map((uid) => h.unit(uid));
const buff = (x) => x.findBuff('talent:accast')?.mods?.atkPct ?? 0;
assert.deepEqual([buff(p), buff(adj), buff(diag), buff(far), buff(tank)], [v, v, 0, 0, 0], id);
close(p.s.atk, p.base.atk * (1 + v), `${id}: her ATK`);
done(h);
}
});
test('S1 "书我所书" (自动触发): no enemy, no cast; at her attack: ASPD +30 / +40 and the splash radius 1.5 for 25 s (1.1 before and after)', () => {
for (const id of ['chess_char_5_03_a', 'chess_char_5_03_b']) {
const sk = skillOf(id, S1);
// (10,6) is a target; (11,7) is 1.41 tiles from it and out of her range: splash only, within 1.5 but not 1.1
const h = arena(id, { standIn: { skillIndex: 0 }, enemies: [{ key: 'd', pos: [10, 6], time: 3 }, { key: 'd', pos: [11, 7], time: 3 }] });
const u = h.unit(1);
h.run(2.9);
assert.equal(u.skill.activations, 0, `${id}: nobody to attack — no cast`);
assert.ok(h.runUntil(() => u.skill.active, 3), `${id}: cast`);
const start = h.b.time;
assert.equal(u.s.aspd, 100 + sk.bb.attack_speed, `${id}: ASPD`);
const side = h.b.enemies.find((e) => Math.round(e.y) === 11);
h.run(5);
assert.ok(hits(h, u, start).some((c) => c.target === side && c.dmg.isSplash), `${id}: the 1.41-tile neighbour takes the splash`);
assert.ok(h.runUntil(() => !u.skill.active, 30));
close(h.b.time - start, sk.duration, `${id}: ${sk.duration} s`, 0.05);
assert.equal(u.s.aspd, 100);
const t1 = h.b.time + 1.5; // the last S1 bolts land
h.run(6);
assert.equal(hits(h, u, t1).filter((c) => c.target === side).length, 0, `${id}: splash 1.1 again`);
assert.ok(hits(h, u, t1).length > 0);
done(h);
}
});
test('S2 "为我所为": her ASPD +35 / +50 and every 【术师】 operator ATK +30 / +35 % (herself included, wherever they stand; no other class) for 35 s', () => {
for (const id of ['chess_char_5_03_a', 'chess_char_5_03_b']) {
const sk = skillOf(id, S2);
const h = arena(id, { standIn: { skillIndex: 1 }, enemies: [{ key: 'd', pos: [10, 6] }], units: [
{ chessId: 'chess_char_1_03_a', row: 12, col: 9 }, // 惊蛰 (CASTER), far away
{ chessId: 'chess_char_1_02_a', row: 9, col: 2 }, // 角峰 (TANK)
] });
const [u, caster, tank] = [1, 2, 3].map((uid) => h.unit(uid));
assert.ok(h.runUntil(() => u.skill.active, 3), `${id}: cast`);
const start = h.b.time;
assert.equal(u.s.aspd, 100 + sk.bb.attack_speed, `${id}: ASPD`);
h.run(0.5);
const buff = (x) => x.findBuff('accast:s2')?.mods?.atkPct ?? 0;
assert.deepEqual([buff(u), buff(caster), buff(tank)], [sk.bb.atk, sk.bb.atk, 0], id);
close(u.s.atk, u.base.atk * (1 + 0.1 + sk.bb.atk), `${id}: her ATK (T2 + S2)`);
h.run(30);
assert.equal(buff(caster), sk.bb.atk, `${id}: kept up while it runs`);
assert.ok(h.runUntil(() => !u.skill.active, 10));
close(h.b.time - start, sk.duration, `${id}: ${sk.duration} s`, 0.05);
h.run(0.5);
assert.deepEqual([buff(u), buff(caster)], [0, 0], `${id}: gone after it`);
done(h);
}
});
test('S3 "驭我所驭": one 100 / 140 % arts hit on every enemy of her range at the cast (SPC-X range included), then 2 targets, ASPD +40 / +55, splash 1.2, 35 s', () => {
for (const id of ['chess_char_5_03_a', 'chess_char_6_08_b']) {
const sk = skillOf(id, S3), elite = id.endsWith('_b');
const h = arena(id, { sp: null, enemies: [[10, 5], [9, 6], [11, 6], [10, 7], [9, 8]].map((pos) => ({ key: 'd', pos })) });
const u = h.unit(1);
h.run(1);
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.active, 3), `${id}: cast`);
const start = h.hooksOf('skillStart').find((c) => c.unit === u).t, atk = u.s.atk;
const burst = hits(h, u, start).filter((c) => (c.dmg.tags || []).includes('accast:aoe'));
const at = (c) => `${Math.round(c.target.y)},${Math.round(c.target.x)}`;
const want = ['10,5', '9,6', '11,6', ...(elite ? ['10,7'] : [])].sort();
assert.deepEqual(burst.map(at).sort(), want, `${id}: every enemy of her range once — (9,8) is out of it`);
assert.ok(burst.every((c) => Math.abs(c.amount - atk * sk.bb.atk_scale_aoe) < 1e-6 && c.type === 'arts' && c.dmg.isSkill && !c.dmg.isAttack), `${id}: ATK × ${sk.bb.atk_scale_aoe} arts, a skill hit`);
assert.equal(u.s.aspd, 100 + sk.bb.attack_speed, `${id}: ASPD`);
h.run(8);
const atks = h.hooksOf('attack').filter((c) => c.attacker === u && c.t >= start);
assert.ok(atks.length >= 3 && atks.every((c) => c.targets.length === 2), `${id}: 2 targets per attack (the cast's own attack too)`);
assert.equal(h.hooksOf('skillStart').filter((c) => c.unit === u).length, 1, 'one burst per cast');
assert.ok(h.runUntil(() => !u.skill.active, 40));
close(h.b.time - start, sk.duration, `${id}: ${sk.duration} s`, 0.05);
done(h);
}
// the splash radius 1.2: a neighbour 1.15 tiles from her only target, out of her range, is splashed during S3 only
const h = arena('chess_char_5_03_a', { sp: null, enemies: [{ key: 'd', pos: [10, 6] }, { key: 'd', pos: [10, 7.15] }] });
const u = h.unit(1);
h.run(3);
const side = h.b.enemies.find((e) => e.x > 7);
assert.ok(side && hits(h, u).length > 0);
assert.equal(hits(h, u).filter((c) => c.target === side).length, 0, 'splash 1.1: not reached');
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.active, 3));
const t0 = h.b.time + 0.01;
h.run(4);
assert.ok(hits(h, u, t0).some((c) => c.target === side && c.dmg.isSplash), 'splash 1.2: reached');
done(h);
});
test('Pith hits air units too: a flyer in range is her target', () => {
const h = arena('chess_char_6_12_a', { sp: null, enemies: [{ key: 'f', pos: [10, 6], route: 2 }] });
const u = h.unit(1);
assert.ok(h.runUntil(() => hits(h, u).length > 0, 5));
assert.ok(hits(h, u)[0].target.isFlying);
done(h);
});