Files
ark-stronghold-protocol/test/content/standin_acsupo.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

298 lines
18 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_acsupo.test.js — the 补位 stand-in Raidian (char_614_acsupo, kits/ops/standin-acsupo.js) on every
// chess it replaces (灵知 T4 S2, 夕 / 安洁莉娜 T5 S3 + DEC-X, 浊心斯卡蒂 / 仇白 T6 S3 + DEC-X), normal and elite: every skill
// (S1 双声, S2 三形, S3 信号跃动 with its range, auras and the data's ACTIVE_RANGE trigger), both talents (同调 on herself and
// the operators next to her, 诱引's hit-rate cut on the attacks of the enemies in her range — PRTS 命中率), the module
// (DEC-X SP, stats, 同调 at level 3), the 停顿 trait, anti-air; and the engine option behind S3's 虚弱 (applyStatus stackAs).
// Run: node --test test/content/standin_acsupo.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { makeBattle, chessRec, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
import { unitForm } from '../../shared/standIn.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 RAID = 'char_614_acsupo';
const [S1, S2, S3] = ['skchr_acsupo_1', 'skchr_acsupo_2', 'skchr_acsupo_3'];
const RECORDS = BACKUPS.units[RAID].standsIn.flatMap((a) => [a, a.replace(/_a$/, '_b')]);
const ENEMIES = {
d: enemyRec({ key: 'd', hp: 1e7, speed: 0 }),
brute: enemyRec({ key: 'brute', hp: 1e7, atk: 200, bat: 1, speed: 1 }),
holy: enemyRec({ key: 'holy', hp: 1e7, atk: 200, bat: 1, speed: 1, dmgType: 'true' }),
archer: enemyRec({ key: 'archer', hp: 1e7, atk: 200, bat: 1, speed: 0, range: 2.5 }),
};
/** A plain blocker without a kit (no randomness of its own). */
const TANK = chessRec({ id: 't_tank', profession: 'TANK', subProfessionId: 'protector', stats: { maxHp: 1e6, def: 0, atk: 1, blockCnt: 3 }, rangeGrid: [[0, 0]], skill: null });
const close = (a, b, msg, eps = 1e-6) => assert.ok(Math.abs(a - b) <= eps, `${msg}: ${a} vs ${b}`);
const formOf = (id) => unitForm(BACKUPS, RAID, 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;
/** Raidian at (10,4) facing right (y-2: rows 9–11, cols 3–6; S3's y-4 adds (10,7) and rows 8 / 12); extra `units`. */
function arena(chessId, { standIn = true, enemies = [], units = [], sp = 999, seed = 7, timeLimit = 200, setup = null } = {}) {
return makeBattle({
defs: { enemies: ENEMIES, chess: { t_tank: TANK } }, seed, timeLimit, autoFinish: false, setup,
units: [{ chessId, row: 10, col: 4, dir: 'RIGHT', standIn, ...(sp != null ? { carryState: { sp } } : {}) }, ...units],
enemies, hooks: ['skillStart', 'skillEnd', 'damaged', 'attack', 'statusApplied'], captureNoisy: true,
});
}
const done = (h) => { assert.deepEqual(h.b.errors.map((e) => `${e.label} ${e.message}`), []); checkInvariants(h.b); };
const castOf = (h, u) => h.hooksOf('skillStart').find((c) => c.unit === u);
test('Raidian on every chess it replaces: the stand-in body, its kit, the backup skill (S2 for 灵知, S3 else), DEC-X on the T5 / T6 elites; arts + 停顿 0.8 s, anti-air', () => {
assert.equal(RECORDS.length, 10);
for (const id of RECORDS) {
const c = CHESS[id];
const form = formOf(id), mod = moduleOf(id);
const sk = form.skills.find((s) => s.index === c.backup.skillIndex);
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], [RAID, c.charId, sk.skillId, false, 'skills'], where);
assert.equal(sk.skillId, c.tier === 4 ? S2 : S3, where);
assert.equal(!!u.def.raw.module?.active, !!mod, `${where}: module only on the T5 / T6 elite`);
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.canHitFly, u.profile.onHitStatus], ['arts', true, { key: 'sluggish', duration: 0.8 }], `${where}: profile`);
assert.equal(u.rangeKeys.length, 12, `${where}: y-2`);
const t0 = u.def.raw.talents.find((t) => t.index === 0).bb;
assert.deepEqual([t0.attack_speed, t0['acsupo_t_1[ally].attack_speed']], mod && mod.level === 3 ? [20, 12] : [15, 10], `${where}: 同调`);
assert.equal(u.s.aspd, 100 + t0.attack_speed, `${where}: her ASPD`);
done(h);
}
});
test('Raidian triggers: S1 / S2 MANUAL DEFAULT; S3 the data\'s ACTIVE_RANGE over its y-4 (the owner\'s rule), the kit sets none', () => {
for (const id of ['chess_char_4_13_a', 'chess_char_5_12_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);
const rule = sid === S3 ? 'ACTIVE_RANGE' : 'DEFAULT';
assert.deepEqual([u.skill.id, u.skill.rule, u.def.skill.trigger.rule, u.kit.skill.trigger], [sid, rule, rule, undefined], `${id} ${sid}`);
if (sid === S3) assert.deepEqual(u.def.skill.trigger.grid, skillOf(id, S3).rangeGrid, 'trigger grid = the skill range');
done(h);
}
}
});
test('S3 ACTIVE_RANGE: an enemy only in its y-4 range ((10,7)) casts it at once, before any attack; none there, or one beyond it, no cast', () => {
for (const id of ['chess_char_5_12_a', 'chess_char_6_15_b']) {
const h = arena(id, { enemies: [{ key: 'd', pos: [10, 7] }] });
const u = h.unit(1);
assert.ok(h.runUntil(() => u.skill.active, 2), `${id}: cast`);
assert.equal(castOf(h, u).reason, 'ACTIVE_RANGE');
assert.equal(h.hooksOf('attack').filter((c) => c.attacker === u && c.t < castOf(h, u).t).length, 0, 'no attack before (it is out of y-2)');
assert.ok(u.rangeKeySet.has(key(10, 7)), 'then in her running range');
h.run(3);
assert.ok(h.hooksOf('attack').some((c) => c.attacker === u), 'and she attacks it');
done(h);
for (const enemies of [[], [{ key: 'd', pos: [10, 8] }]]) {
const g = arena(id, { enemies });
g.run(5);
assert.equal(g.unit(1).skill.activations, 0, `${id}: no cast with ${JSON.stringify(enemies)}`);
done(g);
}
}
});
test('S1 双声: ATK +25 / +40 %, one extra target, 24 s', () => {
for (const id of ['chess_char_4_13_a', 'chess_char_5_20_b']) {
const sk = skillOf(id, S1);
const h = arena(id, { standIn: { skillIndex: 0 }, enemies: [{ key: 'd', pos: [10, 5] }, { key: 'd', pos: [10, 6] }, { key: 'd', pos: [9, 6] }] });
const u = h.unit(1);
assert.ok(h.runUntil(() => u.skill.active, 3));
const start = castOf(h, u).t;
close(u.s.atk, u.base.atk * (1 + sk.bb.atk), `${id}: ATK`);
h.run(6);
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`);
assert.ok(h.runUntil(() => !u.skill.active, 30));
close(h.b.time - start, sk.duration, `${id}: ${sk.duration} s`, 0.05);
done(h);
}
});
test('S2 三形: base attack time 1.9 → 1.8 / 1.7 s, 3 targets, 停顿 1.1 / 1.2 s (0.8 s before), 14 / 17 s', () => {
for (const id of ['chess_char_4_13_a', 'chess_char_4_13_b']) {
const sk = skillOf(id, S2);
const h = arena(id, { sp: null, enemies: [[10, 5], [10, 6], [9, 6], [11, 6]].map((pos) => ({ key: 'd', pos })) });
const u = h.unit(1);
h.run(2);
const slow = (t0, t1) => h.hooksOf('statusApplied').filter((c) => c.source === u && c.status === 'sluggish' && c.t >= t0 && c.t < t1).map((c) => c.duration);
assert.ok(slow(0, 2).length > 0 && slow(0, 2).every((d) => d === 0.8), 'the trait: 0.8 s');
u.skill.gainSp(999);
assert.ok(h.runUntil(() => u.skill.active, 3));
const start = castOf(h, u).t;
close(u.s.interval * u.s.aspd / 100, 1.9 + sk.bb.base_attack_time, `${id}: base attack time`);
h.run(5);
const atks = h.hooksOf('attack').filter((c) => c.attacker === u && c.t >= start);
assert.ok(atks.length >= 3 && atks.every((c) => c.targets.length === 3), `${id}: 3 targets`);
const during = slow(start, h.b.time);
assert.ok(during.length >= 9 && during.every((d) => d === sk.bb['attack@sluggish']), `${id}: 停顿 ${sk.bb['attack@sluggish']} s`);
assert.ok(h.runUntil(() => !u.skill.active, 30));
close(h.b.time - start, sk.duration, `${id}: ${sk.duration} s`, 0.05);
close(u.s.interval * u.s.aspd / 100, 1.9, 'back to 1.9 s');
done(h);
}
});
test('S3 信号跃动: y-4 while it runs, ATK +40 / +50 %, 同调 ×2 / ×2.5, enemies in range 脆弱 + 虚弱 10 / 20 % (the 虚弱 stacking as 90 / 80 %), 30 / 35 s', () => {
for (const id of ['chess_char_5_12_a', 'chess_char_6_04_b']) {
const sk = skillOf(id, S3), elite = id.endsWith('_b');
const t0 = formOf(id).talents.find((t) => t.index === 0).bb;
const self = elite ? 20 : t0.attack_speed, ally = elite ? 12 : t0['acsupo_t_1[ally].attack_speed'];
const h = arena(id, { enemies: [{ key: 'd', pos: [10, 6] }, { key: 'd', pos: [10, 9] }], units: [{ chessId: 't_tank', row: 11, col: 4 }] });
const [u, tank] = [h.unit(1), h.unit(2)];
h.step();
const [inR, outR] = [...h.b.enemies].sort((a, b) => a.x - b.x);
h.b.applyStatus(inR, 'weaken', { value: 0.3, duration: 100 }); // a stronger 虚弱 of another source
assert.ok(h.runUntil(() => u.skill.active, 2), `${id}: cast`);
const start = castOf(h, u).t;
h.run(0.5);
assert.equal(u.rangeKeys.length, 19, `${id}: y-4`);
close(u.s.atk, u.base.atk * (1 + sk.bb['acsupo_s_3.atk']), `${id}: ATK`);
close(u.s.aspd, 100 + self * sk.bb.talent_scale, `${id}: her ASPD`);
close(tank.findBuff('acsupo:t1')?.mods?.aspd ?? 0, ally * sk.bb.talent_scale, `${id}: the neighbour's ASPD`);
const fr = inR.findBuff('fragile'), wk = inR.findBuff('weaken');
close(fr?.data?.value ?? 0, sk.bb.damage_scale - 1, `${id}: 脆弱`);
close(inR.s.dmgTakenMul, sk.bb.damage_scale, `${id}: damage taken`);
assert.deepEqual([wk?.data?.value, wk?.data?.stackAs].map((v) => Math.round(v * 1e6) / 1e6), [Math.round((1 - sk.bb.atk) * 1e6) / 1e6, sk.bb.atk], `${id}: 虚弱 ${1 - sk.bb.atk}, stacking as ${sk.bb.atk}`);
close(inR.s.atk, inR.base.atk * sk.bb.atk, `${id}: its 虚弱 outranks the 30 % one (PRTS 备注)`);
assert.deepEqual([outR.findBuff('fragile') ?? null, outR.findBuff('weaken') ?? null], [null, null], `${id}: nothing out of her range`);
assert.ok(h.runUntil(() => !u.skill.active, 40));
close(h.b.time - start, sk.duration, `${id}: ${sk.duration} s`, 0.05);
h.run(0.5);
assert.deepEqual([u.rangeKeys.length, u.s.aspd, tank.findBuff('acsupo:t1')?.mods?.aspd], [12, 100 + self, ally], `${id}: back`);
assert.equal(inR.findBuff('fragile') ?? null, null, `${id}: 脆弱 gone`);
close(inR.findBuff('weaken')?.data?.value ?? 0, 0.3, `${id}: the 30 % 虚弱 resumes`);
done(h);
}
});
test('同调: ASPD +15 / +20 on herself, +10 / +12 on the operators of the 4 tiles next to her — not diagonal, not farther', () => {
for (const [id, v] of [['chess_char_5_12_a', 10], ['chess_char_6_15_b', 12]]) {
const h = arena(id, { sp: null, units: [
{ chessId: 't_tank', row: 11, col: 4 }, // adjacent
{ chessId: 'chess_char_1_03_a', row: 9, col: 5 }, // diagonal
{ chessId: 'chess_char_1_17_a', row: 10, col: 2 }, // two tiles away
] });
h.run(1);
const [adj, diag, far] = [2, 3, 4].map((uid) => h.unit(uid));
const buff = (x) => x.findBuff('acsupo:t1')?.mods?.aspd ?? 0;
assert.deepEqual([buff(adj), buff(diag), buff(far), buff(h.unit(1))], [v, 0, 0, 0], id);
done(h);
}
});
/** A blocked brute (atk 200, 1 attack/s) on t_tank at `tile`; `rng0`: every direct battle.rng() draw returns that value. */
function lureField(id, { tile = [10, 5], enemy = 'brute', rng0 = null, standIn = true } = {}) {
const h = arena(id, { sp: null, standIn, enemies: [{ key: enemy, pos: tile }], units: [{ chessId: 't_tank', row: tile[0], col: tile[1] }] });
if (rng0 != null) { const real = h.b.rng; h.b.rng = Object.assign(() => rng0, real); }
const seen = [];
h.b.on('hit', (c) => { if (c.source && c.source.side === 'enemy') seen.push(c.dmg.attackId); }); // a later hit handler
return { h, u: h.unit(1), tank: h.unit(2), seen };
}
test('诱引: an enemy attacking inside her range misses as a whole on a failed roll (no damage, no later hit handler), the attack still spent; outside her range, or true damage, never', () => {
for (const id of ['chess_char_4_13_a', 'chess_char_6_04_b']) {
const miss = lureField(id, { rng0: 0 });
miss.h.run(8);
const e = miss.h.b.enemies[0];
assert.ok(e.blockedBy === miss.tank && e.stats.attacks >= 6, `${id}: it attacks every second (${e.stats.attacks})`);
assert.equal(miss.tank.stats.taken, 0, `${id}: every attack missed`);
assert.equal(miss.seen.length, 0, 'no later hit handler saw them');
assert.ok(miss.h.eventsOf('fx').filter((f) => f[1] === 'dodge' && f[4].id === miss.tank.id).length >= 6, 'a miss shows on the target');
done(miss.h);
const land = lureField(id, { rng0: 0.99 });
land.h.run(8);
close(land.tank.stats.taken, land.h.b.enemies[0].stats.attacks * 200, `${id}: a passed roll lands`, 1e-6);
done(land.h);
}
// out of her range ((10,8)): no roll — even a draw of 0 lands
const out = lureField('chess_char_5_12_a', { tile: [10, 8], rng0: 0 });
out.h.run(5);
assert.ok(out.tank.stats.taken > 0, 'outside her range: no cut');
done(out.h);
// true damage has no 物理 / 法术 hit rate
const holy = lureField('chess_char_5_12_a', { enemy: 'holy', rng0: 0 });
holy.h.run(5);
assert.ok(holy.tank.stats.taken > 0, 'true damage: never misses');
done(holy.h);
// the real roll: about one attack in ten (seeded)
const real = lureField('chess_char_4_13_a', {});
real.h.run(150);
const n = real.h.b.enemies[0].stats.attacks, landed = Math.round(real.tank.stats.taken / 200);
assert.ok(n >= 140, `${n} attacks`);
assert.ok(n - landed >= 4 && n - landed <= 30, `${n - landed} of ${n} attacks missed (10 %)`);
done(real.h);
});
test('诱引: one roll per attack — every damage instance of a two-target attack shares it', () => {
const h = arena('chess_char_5_12_a', {
sp: null, enemies: [{ key: 'archer', pos: [10, 6] }],
units: [{ chessId: 't_tank', row: 10, col: 5 }, { chessId: 't_tank', row: 11, col: 5 }],
setup: (b) => b.on('enemySpawn', ({ enemy }) => { enemy.profile.maxTargets = 2; }),
});
const landed = new Map();
h.b.on('damaged', (c) => { if (c.source && c.source.side === 'enemy') landed.set(c.dmg.attackId, (landed.get(c.dmg.attackId) ?? 0) + 1); });
h.run(120);
h.b.applyStatus(h.b.enemies[0], 'disarm', { duration: 100, force: true }); // no new attack; the arrows in flight land
h.run(2);
const atks = h.hooksOf('attack').filter((c) => c.attacker.side === 'enemy');
assert.ok(atks.length >= 100 && atks.every((c) => c.targets.length === 2), `${atks.length} two-target attacks`);
const counts = [...landed.values()];
assert.ok(counts.every((k) => k === 2), 'a landed attack lands on both targets');
const missed = atks.length - landed.size;
assert.ok(missed >= 3 && missed <= 25, `${missed} of ${atks.length} attacks missed whole`);
done(h);
});
test('DEC-X (T5 / T6 elite): +0.2 SP/s while an enemy is in her range; none without one, nor on the normal form', () => {
for (const [id, enemy, rate] of [['chess_char_5_12_b', true, 1.2], ['chess_char_5_12_b', false, 1], ['chess_char_5_12_a', true, 1], ['chess_char_6_15_b', true, 1.2]]) {
const h = arena(id, { sp: null, enemies: enemy ? [{ key: 'd', pos: [10, 6] }] : [] });
const u = h.unit(1);
h.step();
const t0 = h.b.time, sp0 = u.skill.sp;
h.run(10);
assert.equal(u.skill.activations, 0);
close((u.skill.sp - sp0) / (h.b.time - t0), rate, `${id} ${enemy ? 'enemy in range' : 'no enemy'}`, 0.01);
done(h);
}
});
test('engine: applyStatus stackAs — a 同名效果取最高 status competing with another strength than its effect; tails keep it; without it nothing changes', () => {
const h = makeBattle({ defs: { enemies: ENEMIES }, enemies: [{ key: 'd', pos: [10, 6] }], autoFinish: false, timeLimit: 60 });
h.step();
const e = h.b.enemies[0], b = h.b;
const now = () => e.findBuff('weaken');
b.applyStatus(e, 'weaken', { value: 0.3, duration: 10 });
assert.deepEqual(now().data, { value: 0.3, tail: null }, 'the plain data shape');
b.applyStatus(e, 'weaken', { value: 0.1, stackAs: 0.9, duration: 2 });
assert.deepEqual([now().data.value, now().data.stackAs, now().mods.atkMul, now().data.tail.value, now().data.tail.stackAs], [0.1, 0.9, 0.9, 0.3, undefined], 'it outranks the 30 % (its effect stays 10 %); the 30 % waits as the tail');
b.applyStatus(e, 'weaken', { value: 0.5, duration: 1 });
assert.equal(now().data.value, 0.1, 'a 50 % one does not outrank 90 %');
h.run(2.1);
assert.deepEqual([now().data.value, now().data.stackAs], [0.3, undefined], 'the 30 % resumes after it');
h.run(8);
assert.equal(now() ?? null, null, 'all over');
b.applyStatus(e, 'weaken', { value: 0.05, stackAs: 0.8, duration: 20 });
assert.deepEqual(now().data, { value: 0.05, stackAs: 0.8, tail: null });
b.applyStatus(e, 'weaken', { value: 0.2, stackAs: 0.95, duration: 1 });
assert.deepEqual([now().data.value, now().data.tail.value, now().data.tail.stackAs], [0.2, 0.05, 0.8], 'its tail keeps stackAs');
h.run(1.1);
assert.deepEqual([now().data.value, now().data.stackAs], [0.05, 0.8], 'and resumes with it');
b.applyStatus(e, 'weaken', { value: 0.5, duration: 5 });
assert.equal(now().data.value, 0.05, 'a plain 50 % does not outrank it either');
checkInvariants(h.b);
});