Files
ark-stronghold-protocol/test/content/op_gdglow.test.js
T
sganggs 5d5336e233 feedback5 O17: 自选 kits for 琴柳, 可露希尔 (with her 指挥中心) and 澄闪
Three owned-6★ picks get operator kits (kits/ops/op-sleach.js, op-closur.js, op-gdglow.js, appended to OPERATOR_KIT_FILES),
every skill under `skills`, every talent with its module change, the trait and every module at stages 1 and 3, numbers from
the composed record; sources: backups.json, PRTS (skill / talent 备注, 指挥中心, 分支特性信息), Terra Wiki and the client's
charpacks, skill / projectile / token prefabs and buff templates.
- 琴柳: the flag as a tile effect (its 3×3: operators ASPD +10/+13, enemies −10/−13, following S2 / S3's throw); 精神感召's
  −2 on the operators off the field until the next deployment, BEA-Y Lv3 +2 DP on a 近战位 one; S1 / S2 DP over time, S2's
  DEF + 生命回复速度 (hpRegen) on the flag's operator, S3's 停顿 + 脆弱 area and the strike 1 s later; BEA-X block +1 in
  front, BEA-Y 迷彩 while a skill runs.
- 可露希尔: the 指挥中心 (placed piece or tactical point; 禁疗, range x-5 / x-4 / x-6 by her skill, 15 s 战术点形态, withdrawn
  with her) marks her 援军; trait ×1.5 vs enemies they block; S1 shields + growing DP (AUTO ⇒ SP_FULL), S2 援军 DEF / block,
  2 targets, the 40 % refund, S3 range union, 迟钝 stacks (a kit-local applyStrongest 'slowdown'), +1 target per 9 attacks;
  极限调度 ATK for 罗德岛 operators; TAC-X ×0.85.
- 澄闪: skill drones (1 + attack@cnt) locking her first target with their own ramps, 技能直接伤害 that 缴械 does not stop;
  信标的愤怒's per-drone stacks (1.5 % × stacks, sure past 40) and its 1.1 blast; S2 (AUTO, self) ⇒ SP_FULL, range 3-18;
  S3 whole field, 停顿 per hit; 精准导流 resIgnoreFlat.
No engine change. Tests: test/content/op_sleach.test.js (8), op_closur.test.js (9), op_gdglow.test.js (7).
Golden: only diy moved — diy-094 … diy-097 re-chunked (the configurations after the new kits shift), diy-098 … diy-103 new
(the three operators' forms × modules × skills); roster, bonds, fields, matches and standins unchanged.
2026-10-06 03:08:39 +08:00

227 lines
15 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/op_gdglow.test.js — the 自选 operator kit of 澄闪 (char_377_gdglow, 6★ 驭械术师; kit
// server/sim/content/kits/ops/op-gdglow.js), fielded the production way (a DIY slot + its `diy` pick, simdata getDiy) in every
// form: tiers 5 / 6, normal (E2 Lv1, skill rank 4, no module) and elite (E2 Lv60, rank 7) with no module, FUN-X “辛劳之翼” or
// FUN-Y 梦想终将实现 at stage 1 (tier 5) / 3 (tier 6). Numbers from data/backups.json; the fidelity checklist of kits/README.md.
// Run: node --test test/content/op_gdglow.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 { KITTED_CHARS, OPERATOR_KITS, KITS } from '../../server/sim/content/kits/index.js';
import { diyPool, validateDiyPicks } from '../../shared/diy.js';
import { BLAST_RADIUS, DRONE_TAG, BLAST_TAG } from '../../server/sim/content/kits/ops/op-gdglow.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 GD = 'char_377_gdglow';
const FORMS = BACKUPS.units[GD].forms;
const SLOT = { 5: 'chess_char_5_diy1_a', 6: 'chess_char_6_diy1_a' };
const FX = 'uniequip_002_gdglow', FY = 'uniequip_003_gdglow';
const S1 = 'skchr_gdglow_1', S2 = 'skchr_gdglow_2', S3 = 'skchr_gdglow_3';
const formOf = (tier, elite) => FORMS[elite ? (tier === 5 ? '2/60/7/1' : '2/60/7/3') : '2/1/4/0'];
const skillOf = (tier, elite, id) => formOf(tier, elite).skills.find((s) => s.skillId === id);
const modOf = (tier, mod) => (mod ? formOf(tier, true).modules.find((m) => m.uniEquipId === mod) : null);
const talentOf = (tier, elite, mod, i) => modOf(tier, elite ? mod : null)?.talentChanges?.find((t) => t.talentIndex === i) ?? formOf(tier, elite).talents.find((t) => t.index === i);
const traitOf = (tier, elite, mod) => (elite && mod ? modOf(tier, mod).traitOverride : formOf(tier, elite).trait);
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' }), enemy_res: dummy('enemy_res', { res: 50 }), enemy_weak: dummy('enemy_weak', { hp: 10 }) };
const FORMS_ALL = [[5, false, null], [6, false, null], ...[5, 6].flatMap((t) => [null, FX, FY].map((m) => [t, true, m]))];
const label = ([tier, elite, mod]) => `T${tier} ${elite ? 'elite' : 'normal'} ${mod ?? 'none'}`;
/** 澄闪 as uid 1 at (row, col) facing RIGHT. */
function field({ tier = 5, elite = false, mod = null, skill = 0, row = 10, col = 4, others = [], seed = 5 } = {}) {
const h = makeBattle({
defs: { enemies: ENEMIES }, timeLimit: 900, autoFinish: false, seed,
flags: { dpPerSec: 0, dpMax: 999 }, hooks: ['damaged', 'skillStart', 'skillEnd', 'statusApplied', 'attack'], captureNoisy: true,
units: [{ uid: 1, diy: { slot: SLOT[tier], charId: GD, skillIndex: skill, uniEquipId: mod }, elite, row, col }, ...others],
});
h.step();
return { h, u: h.unit(1) };
}
function done(h) {
checkInvariants(h.b);
assert.equal(h.b.errors.length, 0, JSON.stringify(h.b.errors[0]));
}
const mine = (h, u) => h.hooksOf('damaged').filter((c) => c.source === u);
const droneHits = (h, u, e = null) => mine(h, u).filter((c) => c.dmg.tags?.includes(DRONE_TAG) && (!e || c.target === e));
test('澄闪 in every 自选 form: her kit (all three skills authored), the form\'s stats + module attributes, 3-1, 驭械术师 (ranged arts, hits air, blocks 1; the trait ramp of the form — FUN-X init 0.35, FUN-Y cap 1.2), ground-targetable; S1 DEFAULT, S2 at full SP (AUTO on herself), S3 GDGLOW_SKILL_2; 精准导流 15 (FUN-Y stage 3 20)', () => {
assert.equal(OPERATOR_KITS[GD], KITS[GD]);
for (const f of FORMS_ALL) {
const [tier, elite, mod] = f;
for (const skill of [0, 1, 2]) {
const { h, u } = field({ tier, elite, mod, skill });
const form = formOf(tier, elite), m = elite ? modOf(tier, mod) : null, sk = form.skills[skill];
assert.deepEqual([u.def.charId, u.def.diyFor, u.skill.id, !!u.kit.generic, u.kit.skillSource], [GD, SLOT[tier], sk.skillId, false, 'skills'], label(f));
assert.deepEqual([u.base.maxHp, u.base.atk, u.base.def, u.base.aspd], [form.stats.maxHp + (m?.attr.maxHp ?? 0), form.stats.atk + (m?.attr.atk ?? 0), form.stats.def + (m?.attr.def ?? 0), 100 + (m?.attr.aspd ?? 0)], `${label(f)}: stats`);
assert.deepEqual([u.s.blockCnt, u.profile.attack, u.profile.canHitFly, u.profile.dmgType, u.profile.sub, u.base.bat], [1, 'ranged', true, 'arts', 'funnel', 1.3], `${label(f)}: 驭械术师`);
const tb = traitOf(tier, elite, mod).bb;
assert.deepEqual(u.profile.funnel, { init: tb.init_atk_scale, delta: tb.delta_atk_scale, max: tb.max_atk_scale }, `${label(f)}: the trait ramp`);
assert.deepEqual(u.liveRangeGrid, form.rangeGrid, `${label(f)}: 3-1`);
assert.ok(!u.s.flags.liftoff && !u.s.flags.camou && !u.s.flags.stealth, `${label(f)}: ground enemies target her`);
assert.deepEqual([u.skill.rule, u.skill.kind], [[sk.trigger.rule, 'SP_FULL', 'GDGLOW_SKILL_2'][skill], ['duration', 'toggle', 'duration'][skill]], `${label(f)}: S${skill + 1}`);
assert.equal(u.s.resIgnoreFlat, talentOf(tier, elite, mod, 1).bb.magic_resist_penetrate_fixed, `${label(f)}: 精准导流`);
done(h);
}
}
assert.deepEqual([skillOf(5, false, S1).trigger.rule, skillOf(5, false, S2).trigger.rule, skillOf(5, false, S2).skillType], ['DEFAULT', 'DEFAULT', 'AUTO']);
assert.deepEqual([modOf(5, FX).attr, modOf(6, FX).attr, modOf(5, FY).attr, modOf(6, FY).attr], [{ maxHp: 90, atk: 22 }, { maxHp: 125, atk: 38 }, { atk: 30, aspd: 5 }, { atk: 45, aspd: 7 }]);
assert.deepEqual([talentOf(6, true, FY, 1).bb.magic_resist_penetrate_fixed, talentOf(6, true, FX, 0).bb['attack@atk_scale_2'], talentOf(5, true, FX, 0).bb['attack@atk_scale_2']], [20, 3.6, 3]);
});
test('a 自选 pick: 澄闪 is offered at tiers 5 and 6 and a roster with her passes validateDiyPicks', () => {
const data = { chess: CHESS, backups: BACKUPS };
assert.ok(KITTED_CHARS.includes(GD));
for (const t of [5, 6]) assert.ok(diyPool(t, { data, kitted: KITTED_CHARS }).includes(GD), `tier ${t}`);
assert.deepEqual(validateDiyPicks({ [SLOT[6]]: { charId: GD, skillIndex: 2, uniEquipId: FY } }, { data, kitted: KITTED_CHARS }),
{ ok: true, picks: { [SLOT[6]]: { charId: GD, skillIndex: 2, uniEquipId: FY } } });
});
test('her normal attack (no skill): one drone hit per attack — init, +delta per hit on the same target, up to the cap (FUN-X from 0.35, FUN-Y to 1.2); 精准导流 lowers the RES it meets', () => {
for (const f of [[5, false, null], [5, true, FX], [6, true, FY]]) {
const [tier, elite, mod] = f;
const { h, u } = field({ tier, elite, mod, skill: 2 });
const e = h.spawn('enemy_res', { pos: [10, 6] });
h.run(12);
const hits = mine(h, u).filter((c) => c.target === e && c.dmg.isAttack);
assert.ok(hits.length >= 8, `${label(f)}: ${hits.length} attacks`);
const tb = traitOf(tier, elite, mod).bb, pen = talentOf(tier, elite, mod, 1).bb.magic_resist_penetrate_fixed;
hits.slice(0, 8).forEach((c, i) => {
const ramp = Math.min(tb.max_atk_scale, tb.init_atk_scale + i * tb.delta_atk_scale);
approx(c.amount, u.s.atk * ramp * (1 - (50 - pen) / 100), `${label(f)}: attack ${i + 1} at ${ramp}`);
assert.equal(c.type, 'arts');
});
done(h);
}
});
test('S1 火花四溅 (DEFAULT: on her attack with an enemy in range): 25 s, ATK +atk, ASPD +aspd, no attack of her own; 2 drones lock her first target (in range or not afterwards) and hit it every attack interval — each its own ramp, arts 技能伤害 — and take her next first target once it falls; 缴械 stops nothing', () => {
for (const f of [[5, false, null], [6, true, FX]]) {
const [tier, elite, mod] = f;
const sk = skillOf(tier, elite, S1);
assert.deepEqual([sk.duration, sk.bb.atk, sk.bb.attack_speed, sk.bb['attack@cnt']], [25, elite ? 0.32 : 0.26, elite ? 38 : 29, 1], label(f));
const { h, u } = field({ tier, elite, mod, skill: 0 });
u.skill.gainSp(999);
h.run(1);
assert.equal(u.skill.activations, 0, `${label(f)}: no enemy, no cast`);
const a = h.spawn('enemy_dummy', { pos: [10, 6] });
assert.ok(h.runUntil(() => u.skill.active, 3), `${label(f)}: cast as she is about to attack`);
const t0 = h.b.time;
approx(u.s.aspd, u.base.aspd + sk.bb.attack_speed, `${label(f)}: ASPD +${sk.bb.attack_speed}`);
h.b.applyStatus(u, 'disarm', { duration: 30, source: u, force: true });
h.run(3);
const hits = droneHits(h, u, a);
assert.ok(hits.length >= 4, `${label(f)}: drone hits while 缴械 (${hits.length})`);
assert.ok(hits.every((c) => c.dmg.isSkill && !c.dmg.isAttack && c.type === 'arts'), `${label(f)}: arts 技能伤害, no normal attack`);
const f0 = u.profile.funnel;
approx(hits[0].amount, u.s.atk * f0.init, `${label(f)}: the first at init`);
approx(hits[1].amount, u.s.atk * f0.init, `${label(f)}: the second drone too`);
approx(hits[2].amount, u.s.atk * (f0.init + f0.delta), `${label(f)}: +delta`);
approx(hits[2].t - hits[0].t, u.s.interval, `${label(f)}: one hit per attack interval each`, 0.05);
assert.equal(h.hooksOf('attack').filter((c) => c.attacker === u && c.t > t0).length, 0, `${label(f)}: no attack of her own`);
// locked: the enemy walks out of her range — still hit
a.x = 12; a.y = 12;
const n1 = droneHits(h, u, a).length;
h.run(2);
assert.ok(droneHits(h, u, a).length > n1, `${label(f)}: kept out of range`);
// it falls: the drones take her first target
const b = h.spawn('enemy_dummy', { pos: [11, 5] });
h.b.kill(a, null);
h.run(1.5);
assert.ok(droneHits(h, u, b).length >= 2, `${label(f)}: the next first target`);
h.runUntil(() => !u.skill.active, 25);
approx(h.b.time - t0, 25, `${label(f)}: 25 s`, 0.01);
assert.equal(u.mem.gdDrones, null, `${label(f)}: the drones back`);
done(h);
}
});
test('S2 电流翻涌 (AUTO, at full SP — no enemy needed): on for good, range 3-18, ATK +atk, 2 drones on an enemy of the 3-18 outside her 3-1', () => {
for (const f of [[5, false, null], [6, true, FY]]) {
const [tier, elite, mod] = f;
const sk = skillOf(tier, elite, S2);
assert.deepEqual([sk.duration, sk.bb.atk, sk.bb['attack@cnt'], sk.rangeId, sk.initSp, sk.spCost], [-1, elite ? 0.45 : 0.32, 1, '3-18', 0, elite ? 70 : 75], label(f));
const { h, u } = field({ tier, elite, mod, skill: 1 });
h.run(sk.spCost - 0.5);
assert.equal(u.skill.activations, 0, `${label(f)}: not before its SP is full`);
assert.ok(h.runUntil(() => u.skill.active, 1), `${label(f)}: on at full SP with no enemy`);
assert.deepEqual(u.liveRangeGrid, sk.rangeGrid, `${label(f)}: 3-18`);
// (12,4) = [2, 0]: in the 3-18, not in the 3-1
const e = h.spawn('enemy_fly', { pos: [12, 4] });
h.run(3);
const hits = droneHits(h, u, e);
assert.ok(hits.length >= 4, `${label(f)}: the drones on it (${hits.length})`);
approx(hits[0].amount, u.s.atk * u.profile.funnel.init, `${label(f)}: ATK +${sk.bb.atk * 100} % (live ATK)`);
h.run(200);
assert.ok(u.skill.active, `${label(f)}: 持续时间无限`);
done(h);
}
});
test('S3 澄净闪耀 (GDGLOW_SKILL_2: an enemy anywhere): 30 s, no attack of her own, the whole field, ATK +atk, 3 drones, each hit 0.5 s 停顿', () => {
for (const f of [[5, false, null], [6, true, FY]]) {
const [tier, elite, mod] = f;
const sk = skillOf(tier, elite, S3);
assert.deepEqual([sk.duration, sk.bb.atk, sk.bb['attack@cnt'], sk.bb['attack@sluggish']], [30, elite ? 0.55 : 0.4, 2, 0.5], label(f));
const { h, u } = field({ tier, elite, mod, skill: 2 });
u.skill.gainSp(999);
h.run(1);
assert.equal(u.skill.activations, 0, `${label(f)}: no enemy, no cast`);
const e = h.spawn('enemy_dummy', { pos: [12, 10] }); // far out of her 3-1
assert.ok(h.runUntil(() => u.skill.active, 1), `${label(f)}: cast with an enemy anywhere`);
const t0 = h.b.time;
h.run(0.2);
assert.equal(u.mem.gdDrones.length, 3, `${label(f)}: 3 drones`);
h.run(3);
const hits = droneHits(h, u, e);
assert.ok(hits.length >= 6, `${label(f)}: hit across the field (${hits.length})`);
const sl = h.hooksOf('statusApplied').filter((c) => c.source === u && c.status === 'sluggish' && c.target === e);
assert.ok(sl.length >= hits.length - 1, `${label(f)}: 停顿 per hit`);
approx(sl[0].duration, 0.5, `${label(f)}: 0.5 s`);
assert.equal(h.hooksOf('attack').filter((c) => c.attacker === u && c.t > t0).length, 0, `${label(f)}: 停止攻击`);
h.runUntil(() => !u.skill.active, 30);
approx(h.b.time - t0, 30, `${label(f)}: 30 s`, 0.01);
done(h);
}
});
test('T1 信标的愤怒: per drone 1.5 % × its stacks (from 1, +1 per miss, sure past 40): ~10 % of the drone attacks; the blast — atk_scale_2 × ATK arts within 1.1 of the target (air too), no ramp (FUN-X stage 3 360 %), 停顿 under S3 — sends the drone back and restarts its count; reset at a deployment', () => {
for (const f of [[5, false, null], [6, true, FX]]) {
const [tier, elite, mod] = f;
const tb = talentOf(tier, elite, mod, 0).bb;
assert.deepEqual([tb['attack@prob'], tb['attack@atk_scale_2'], tb['attack@max_stack_cnt']], [0.015, elite && mod === FX && tier === 6 ? 3.6 : 3, 40], label(f));
// a sure blast: both drones past the cap
const { h, u } = field({ tier, elite, mod, skill: 2 });
const e = h.spawn('enemy_dummy', { pos: [10, 6] }), near = h.spawn('enemy_fly', { pos: [11, 6] }), far = h.spawn('enemy_dummy', { pos: [10, 8] });
u.mem.gdStacks = [41, 41, 41];
u.skill.gainSp(999);
assert.ok(h.runUntil(() => mine(h, u).some((c) => c.dmg.tags?.includes(BLAST_TAG)), 3), `${label(f)}: blast`);
const blast = mine(h, u).filter((c) => c.dmg.tags?.includes(BLAST_TAG));
const hitT = new Set(blast.map((c) => c.target));
assert.ok(hitT.has(e) && hitT.has(near) && !hitT.has(far), `${label(f)}: within ${BLAST_RADIUS} of the target (air too), not 2 tiles away`);
for (const c of blast) {
approx(c.amount, u.s.atk * tb['attack@atk_scale_2'], `${label(f)}: ${tb['attack@atk_scale_2'] * 100} % ATK, no ramp`);
assert.deepEqual([c.type, c.dmg.isSkill], ['arts', true]);
}
assert.ok(h.hooksOf('statusApplied').some((c) => c.source === u && c.status === 'sluggish' && c.target === far) === false, 'no 停顿 outside');
assert.ok(h.hooksOf('statusApplied').some((c) => c.source === u && c.status === 'sluggish' && c.target === near), `${label(f)}: S3's 停顿 on the blast`);
assert.ok(u.mem.gdStacks.slice(0, 3).some((k) => k <= 2), `${label(f)}: a count restarted`);
h.b.retreat(u);
h.b.redeploy(u, { free: true });
assert.deepEqual(u.mem.gdStacks, [], `${label(f)}: reset at a deployment`);
done(h);
}
// the macro rate: ~10 % of the drone attacks (the pseudo-random distribution's 1 / 9.91)
const { h, u } = field({ tier: 6, elite: true, mod: FY, skill: 1 });
h.spawn('enemy_dummy', { pos: [10, 6] });
u.skill.gainSp(999);
h.run(600);
const n = droneHits(h, u).length, b = new Set(mine(h, u).filter((c) => c.dmg.tags?.includes(BLAST_TAG)).map((c) => c.t)).size;
assert.ok(n > 800, `${n} drone attacks`);
assert.ok(b / n > 0.08 && b / n < 0.125, `${b} / ${n} blasts ≈ 10 %`);
done(h);
});