Files
ark-stronghold-protocol/test/content/op_clemnt.test.js
T
sganggs 6bfc08270c 0.2.4-dev: round-29 community fixes and the official shared card pool
- Shared card pool and item stock as in the official mode (the owner's request, from the 路标档案馆 video and public notes): solo low-tier exceptions, co-op operators x2, shared item stock with special-item exceptions, normal / advanced occupancy 1 / 2, re-check on buy, 机变 exhaustion, release on consume / destroy / elimination; 整备 never exceeds stock; invariant held <= cap.
- 突袭: one taunt-first reselect right after a revive (the owner's rule); ordinary polls and redeploys keep the 0.2.3 rule; a revive on an enemy's tile stays. #475: online boss / hidden-boss Raid may land on the empty half (owner-limited scope).
- 浊心斯卡蒂 S1 x 坚守 no longer bounce redirected damage, and the two shares conserve the hit (idea of PR #464 by @HappyTanuki).
- Operator fixes adapted from PRs by @Xinglan233 (#459 哈洛德 S2 elemental trigger, #460 涤火杰西卡 shield on the four adjacent tiles, #461 家族徽章 first hit after stealth, #462 松果 S2 regrow per deploy); 克莱门莎 S2 splash 1.1 / 100% ATK and air auto-trigger (#467); 白面 copying 歌蕾蒂娅 / 余 skips the three-member condition (#473, the owner's video).
- 香槟炸弹 is not chosen by friendly auras / heals (PR #468 / #476 by @Siornya); 琳琅诗怀雅 coin balance (PR #450 by @IceCodeNew); Touch prep display (PR #451 by @YinqiMargatroid); lobby header (PR #471 by @YuriRestia); Japanese UI terms (PR #448 by @scutkane).
- AI-only boss countdown grace, render-failure cache / recovery without restarting a battle, device units kept on a same-field re-entry, DIY detail / skill previews, large-text result buttons.
- golden: battle families equal 0.2.3 except diy-135 / diy-136 (克莱门莎); all 18 release matches move through the shared pool, every flip named in docs/history/0.2.4.md §29.12; two coverage scenarios added.
2026-10-11 01:58:52 +08:00

252 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.
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 } from '../../server/sim/content/kits/index.js';
import { diyPool } from '../../shared/diy.js';
import { performAttack, effectiveProfile } from '../../server/sim/ai.js';
const C = 'char_4231_clemnt';
const data = Object.fromEntries(['chess', 'backups'].map((f) => [f, JSON.parse(readFileSync(new URL(`../../data/${f}.json`, import.meta.url)))]));
const approx = (a, b, label = '') => assert.ok(Math.abs(a - b) < 1e-5 * Math.max(1, Math.abs(b)), `${label}: ${a} != ${b}`);
const enemy = (key, opts = {}) => enemyRec({ key, hp: 1e8, atk: 0, speed: 0, ...opts });
const defs = { enemies: { dummy: enemy('dummy'), light: enemy('light', { mass: 3 }), mid: enemy('mid', { mass: 4 }), heavy: enemy('heavy', { mass: 9 }),
fly: enemy('fly', { motion: 'FLY' }), zero: enemy('zero', { mass: 0 }), armored: enemy('armored', { def: 100 }) } };
function field({ tier = 6, elite = true, skill = 0, potential = 6, dir = 'RIGHT', row = 10, col = 3, flags = {} } = {}) {
const h = makeBattle({ seed: 23, autoFinish: false, timeLimit: 180, defs, flags,
hooks: ['damaged', 'attack', 'elementBurst', 'ammoUsed'], captureNoisy: true,
units: [{ uid: 1, diy: { slot: tier, charId: C, skillIndex: skill }, elite, potential, row, col, dir }] });
h.step(); return { h, u: h.unit(1) };
}
function done(h) { checkInvariants(h.b); assert.deepEqual(h.b.errors, []); }
function start(u) { u.skill.gainSp(999, 'init'); assert.equal(u.skill.activate('test'), true); }
const hits = (h, tag) => h.hooksOf('damaged').filter((c) => c.dmg?.tags?.includes(tag));
for (const tier of [5, 6]) for (const elite of [false, true]) test(`风暴潮 air-only automation retains ground-only normal attacks (${tier}/${elite})`, () => {
const { h, u } = field({ tier, elite, skill: 1, flags: { startOpCooldown: 3 } });
const e = h.spawn('fly', { pos: [10, 6] }); u.skill.gainSp(999, 'init');
h.run(2); assert.equal(u.skill.activations, 0, 'operation cooldown still applies');
assert.ok(h.runUntil(() => u.skill.active, 2)); h.run(3);
const damaged = h.hooksOf('damaged').filter(c => c.source === u && c.target === e);
assert.ok(damaged.length > 0, 'the vortex hits the air-only target');
assert.ok(damaged.every(c => !c.dmg.isAttack), 'normal attacks remain ground-only'); done(h);
});
test('风暴潮 auto-cast rejects distant/hidden/sleeping flyers and waits through stun', () => {
const { h, u } = field({ skill: 1 }); u.skill.gainSp(999, 'init');
h.spawn('fly', { pos: [12, 12] }); h.run(0.3); assert.equal(u.skill.activations, 0);
const e = h.spawn('fly', { pos: [10, 6] }); h.b.addBuff(e, { key: 'test:hide', flags: { stealth: true } });
h.run(0.3); assert.equal(u.skill.activations, 0);
h.b.removeBuff(e, 'test:hide'); h.b.applyStatus(e, 'sleep', { duration: 0.5 });
h.run(0.3); assert.equal(u.skill.activations, 0);
h.b.applyStatus(u, 'stun', { duration: 0.6 }); h.run(0.4); assert.equal(u.skill.activations, 0);
h.run(0.5); assert.equal(u.skill.activations, 1); done(h);
});
test('克莱门莎 is selectable with authored skills in both slots, every form and potential', () => {
assert.ok(KITTED_CHARS.includes(C));
for (const tier of [5, 6]) {
assert.ok(diyPool(tier, { data, kitted: KITTED_CHARS }).includes(C));
for (const elite of [false, true]) for (const skill of [0, 1, 2]) for (const potential of [1, 6]) {
const { h, u } = field({ tier, elite, skill, potential });
assert.equal(u.def.charId, C); assert.equal(u.kit.skillSource, 'skills'); assert.ok(!u.kit.generic);
assert.equal(u.skill.id, `skchr_clemnt_${skill + 1}`);
assert.deepEqual([u.profile.dmgType, u.profile.canHitFly, u.s.blockCnt, u.base.bat], ['phys', false, 2, 1.2]);
assert.ok(u.def.bonds.includes('egirShip'));
assert.equal(u.skill.rule, skill ? 'ACTIVE_RANGE' : 'DEFAULT');
done(h);
}
}
});
test('生死定夺 checks current versus initial DEF for each physical hit; scales before DEF, never elemental damage', () => {
for (const potential of [1, 3, 6]) {
const { h, u } = field({ potential });
const e = h.spawn('armored', { pos: [10, 6] }); h.b.rng = () => 0.3;
approx(h.b.dealDamage(u, e, { amount: 1000, type: 'phys' }), 900);
h.b.addBuff(e, { key: 'test:def', mods: { defFlat: -50 } });
approx(h.b.dealDamage(u, e, { amount: 1000, type: 'phys' }), 1000 * (potential >= 3 ? 1.5 : 1.35) - 50);
approx(h.b.dealDamage(u, e, { amount: 1000, type: 'elemental' }), 1000);
const z = h.spawn('dummy', { pos: [10, 7] }); h.b.addBuff(z, { key: 'test:def', mods: { defFlat: -50 } });
approx(h.b.dealDamage(u, z, { amount: 1000, type: 'phys' }), 1000);
done(h);
}
});
test('习得性防御 handles all facings, boundary tiles, blocked enemies, seamonsters, and damage types', () => {
for (const dir of ['RIGHT', 'LEFT', 'UP', 'DOWN']) {
const { h, u } = field({ dir, col: 5 });
const e = h.spawn('dummy', { pos: [10, 5] });
const take = (type = 'arts') => { u.hp = u.s.maxHp; return h.b.dealDamage(e, u, { amount: 100, type }); };
approx(take(), 100 * 0.9 * 0.65); // same tile/column belongs to the forward half-plane
const [dr, dc] = { RIGHT: [0, 1], LEFT: [0, -1], UP: [1, 0], DOWN: [-1, 0] }[dir];
e.x = 5 - dc; e.y = 10 - dr; approx(take(), 90);
e.blockedBy = u; u.blocking.push(e); approx(take(), 90 * 0.65);
e.def = { ...e.def, tags: ['seamonster'] }; approx(take(), 90 * 0.45);
approx(take('true'), 100); approx(take('elemental'), 100);
e.blockedBy = null; u.blocking.length = 0; done(h);
}
});
test('冲蚀 is every fourth attack and fills erosion from actual post-mitigation damage, with no air target', () => {
for (const elite of [false, true]) {
const { h, u } = field({ elite }); const e = h.spawn('dummy', { pos: [10, 4] }); h.b.rng = () => 0.99;
h.run(5);
const dealt = h.hooksOf('damaged').filter((c) => c.source === u && c.dmg.isAttack);
assert.ok(dealt.length >= 4);
dealt.forEach((c, i) => assert.equal(c.dmg.isSkill, i % 4 === 3));
const r = hits(h, 'clemnt:rider'); assert.ok(r.length > 0);
const first = dealt.find((c) => c.dmg.isSkill);
approx(r[0].amount, first.amount * (elite ? 0.3 : 0.25));
assert.ok(e.elem.erosion > 0); done(h);
}
});
test('风暴潮 carries at most eight total weight, excludes flyers/self-bound enemies and releases after three tiles', () => {
const { h, u } = field({ skill: 1 });
const es = [0, 1, 2].map(() => h.spawn('light', { pos: [10, 4] }));
const heavy = h.spawn('heavy', { pos: [10, 4] }), fly = h.spawn('fly', { pos: [10, 4] });
const bound = h.spawn('zero', { pos: [10, 4] }); h.b.addBuff(bound, { key: 'test:bound', flags: { selfBound: true, noMove: true } });
start(u); assert.ok(u.s.flags.disarm); h.run(0.7);
assert.equal(u.mem.clemntCabin.passengers.length, 2);
assert.equal(u.mem.clemntCabin.mass, 2);
assert.ok(es[0].s.flags.unblockable && es[0].s.flags.disarm);
for (const e of [heavy, fly, bound]) assert.ok(!u.mem.clemntCabin.passengers.includes(e));
h.run(0.9); assert.ok(u.mem.clemntCabin.stopped); approx(u.mem.clemntCabin.x, 6);
assert.ok(!u.s.flags.disarm); assert.ok(!es[0].s.flags.unblockable);
approx(es[0].x, 6); done(h);
});
test('风暴潮 caps passengers at fifteen and cleans them on early skill end', () => {
const { h, u } = field({ skill: 1 });
for (let n = 0; n < 20; n++) h.spawn('zero', { pos: [10, 4] });
start(u); h.run(0.4); assert.equal(u.mem.clemntCabin.passengers.length, 15);
const es = [...u.mem.clemntCabin.passengers]; u.skill.stop();
assert.ok(es.every((e) => !e.s.flags.disarm && !e.s.flags.unblockable && !e.s.flags.noMove));
assert.equal(u.mem.clemntCabin, null); done(h);
});
for (const reason of ['death', 'hidden', 'self-bound', 'teleport-immune', 'no-path']) {
test(`风暴潮 releases ${reason} passengers before admitting a replacement in the same tick`, () => {
const { h, u } = field({ skill: 1 });
const victim = h.spawn('light', { pos: [10, 4] });
const survivor = h.spawn('light', { pos: [10, 4] });
start(u); h.run(0.7);
const c = u.mem.clemntCabin, carry = `clemnt:carry:${u.id}`;
assert.deepEqual(c.passengers.map((e) => e.id), [victim.id, survivor.id]);
assert.equal(c.mass, 2);
if (reason === 'death') h.b.dealDamage(u, victim, { amount: 1e12, type: 'true' });
if (reason === 'hidden') victim.hidden = true;
if (reason === 'self-bound') h.b.addBuff(victim, { key: 'test:bound', flags: { selfBound: true } });
if (reason === 'teleport-immune') victim.def = { ...victim.def, immune: new Set(['teleport']) };
if (reason === 'no-path') {
victim.x = 7; victim.y = 12;
for (const [r, col] of [[11, 6], [11, 7], [11, 8], [12, 6], [12, 8]]) h.b.grid.setObstacle(r, col, true);
assert.equal(h.b.grid.findPath(12, 7, Math.round(c.y), Math.round(c.x)), null);
}
const replacement = h.spawn('light', { pos: [c.y, c.x] });
h.step();
assert.deepEqual(c.passengers.map((e) => e.id), [survivor.id, replacement.id]);
assert.equal(c.mass, 2);
assert.ok(!victim.findBuff(carry));
assert.ok(replacement.findBuff(carry));
approx(replacement.x, c.x); approx(survivor.x, c.x);
h.step(); assert.equal(c.mass, 2, 'released weight is returned only once');
done(h);
});
}
for (const [kind, count] of [['zero', 15], ['mid', 1]]) {
test(`风暴潮 frees the ${kind === 'zero' ? 'fifteenth slot' : 'four-weight budget'} after a passenger dies`, () => {
const { h, u } = field({ skill: 1 });
const riders = Array.from({ length: count }, () => h.spawn(kind, { pos: [10, 4] }));
start(u); h.run(0.7);
const c = u.mem.clemntCabin;
assert.equal(c.passengers.length, count);
h.b.dealDamage(u, riders[0], { amount: 1e12, type: 'true' });
const replacement = h.spawn(kind, { pos: [c.y, c.x] });
h.step();
assert.equal(c.passengers.length, count);
assert.ok(!c.passengers.includes(riders[0]));
assert.ok(c.passengers.includes(replacement));
assert.equal(c.mass, kind === 'zero' ? 8 : 4);
done(h);
});
}
test('风暴潮 returns a released passenger’s current weight after a weight modifier', () => {
const { h, u } = field({ skill: 1 });
const victim = h.spawn('light', { pos: [10, 4] });
start(u); h.run(0.7);
const c = u.mem.clemntCabin;
assert.equal(c.mass, 5);
h.b.addBuff(victim, { key: 'test:lighter', mods: { massFlat: -1 }, flags: { selfBound: true } });
assert.equal(victim.weight, 2);
h.step();
assert.equal(c.passengers.length, 0);
assert.equal(c.mass, 7, 'return current weight 2, not boarding weight 3');
done(h);
});
test('风暴潮 stops before a blocked tile; vortex keeps its continuous stop point, hits air and applies normal erosion resistance', () => {
const { h, u } = field({ skill: 1 }); h.b.grid.setObstacle(10, 5, true);
const fly = h.spawn('fly', { pos: [10, 5.55] });
fly.def = { ...fly.def, epResistance: 99 };
start(u); h.run(0.7); assert.ok(u.mem.clemntCabin.stopped);
approx(u.mem.clemntCabin.x, 4.1); h.run(1.1);
approx(fly.findBuff(`clemnt:vortex:${u.id}`).mods.moveMul, 0.3); approx(fly.elem.erosion, 10); // damage formula's 5% floor at 99 resistance
assert.ok(hits(h, 'clemnt:vortex').some((c) => c.target === fly)); done(h);
});
test('风暴潮 normal attack hits five ground enemies in total, never all neighbours or a flyer', () => {
const { h, u } = field({ skill: 1 }); start(u); h.run(1.6);
const es = Array.from({ length: 9 }, () => h.spawn('heavy', { pos: [10, 4] }));
const fly = h.spawn('fly', { pos: [10, 4] });
const before = h.hooksOf('damaged').length;
performAttack(h.b, u, effectiveProfile(u), [es[0]]);
const victims = new Set(h.hooksOf('damaged').slice(before).filter((c) => c.dmg.isAttack).map((c) => c.target));
assert.equal(victims.size, 5); assert.ok(!victims.has(fly)); done(h);
});
for (const elite of [false, true]) test(`风暴潮 splash uses the published 1.1 collision radius and 100% ATK (${elite})`, () => {
const { h, u } = field({ skill: 1, elite }); start(u); h.run(1.6); h.b.rng = () => 0.99;
const main = h.spawn('dummy', { pos: [10, 4] });
const near = h.spawn('dummy', { pos: [10, 5] });
const outside = h.spawn('dummy', { pos: [10, 5.2] });
const body = h.spawn('dummy', { pos: [10, 5.8] });
body.hitArea = { w: 1.6, h: 1, dx: 0, dy: 0 };
const hp = [main, near, outside, body].map(e => e.hp);
performAttack(h.b, u, effectiveProfile(u), [main]);
approx(hp[0] - main.hp, u.s.atk * u.skill.bb['attack@aoe_atk_scale']);
approx(hp[1] - near.hp, u.s.atk);
assert.equal(outside.hp, hp[2], 'a point outside radius 1.1 is not splashed');
approx(hp[3] - body.hp, u.s.atk, 'a large collision body overlapping radius 1.1 is splashed');
done(h);
});
test('与海为敌 normal attacks hit three and spend no ammunition; remote erosion bursts fire three cross-shaped mixed hits', () => {
const { h, u } = field({ skill: 2 }); start(u); h.b.rng = () => 0.99;
const close = Array.from({ length: 3 }, () => h.spawn('dummy', { pos: [10, 4] }));
performAttack(h.b, u, effectiveProfile(u), close);
assert.equal(u.skill.ammoLeft, 10);
const remote = h.spawn('dummy', { pos: [11, 9] }), near = h.spawn('dummy', { pos: [12, 9] });
const diagonal = h.spawn('dummy', { pos: [12, 8] }), fly = h.spawn('fly', { pos: [11, 9] });
h.b.dealDamage(u, remote, { amount: 1000, type: 'element', element: 'erosion' }); h.step();
assert.equal(u.skill.ammoLeft, 9); u.skill.stop(); h.run(2.6);
const bombs = hits(h, 'clemnt:bomb');
for (const e of [remote, near]) {
assert.equal(bombs.filter((c) => c.target === e && c.type === 'phys').length, 3);
assert.equal(bombs.filter((c) => c.target === e && c.type === 'elemental').length, 3);
}
assert.ok(!bombs.some((c) => c.target === diagonal || c.target === fly)); done(h);
});
test('与海为敌 selects nearest marked enemy, suppresses duplicate bombing tiles and does not mark air or sleeping units', () => {
const { h, u } = field({ skill: 2 }); start(u);
const far = h.spawn('dummy', { pos: [12, 9] }), near = h.spawn('dummy', { pos: [11, 7] });
const same = h.spawn('dummy', { pos: [11, 7] }), fly = h.spawn('fly', { pos: [11, 8] });
const asleep = h.spawn('dummy', { pos: [11, 6] }); h.b.applyStatus(asleep, 'sleep', { duration: 5 });
for (const target of [far, near, same, fly, asleep]) h.b.emit('elementBurst', { source: null, target, element: 'erosion' });
h.step(); assert.equal(u.skill.ammoLeft, 9); assert.ok(!u.mem.clemntBombs.marks.has(near));
h.step(); assert.equal(u.skill.ammoLeft, 8); assert.ok(!u.mem.clemntBombs.marks.has(far));
h.run(1.1); assert.equal(u.skill.ammoLeft, 8); assert.equal(u.mem.clemntBombs.marks.size, 0); done(h);
});