0.2.5-dev: merge claude/036-sim
# Conflicts: # CHANGELOG.md # docs/history/0.2.5.md # test/golden/matches.json
This commit is contained in:
commit
3dd2213e47
42 files changed
+2335
-961
No files matched your search
@@ -177,8 +177,8 @@ for (const kind of ['boss', 'hidden']) for (const side of ['L', 'R']) {
|
||||
});
|
||||
}
|
||||
|
||||
test('#475: even buildable hand, temporary-storage, side-margin and off-rect tiles remain invalid Raid landings', () => {
|
||||
for (const pos of [[0, 3], [1, 3], [0, 17], [1, 17], [3, 1], [3, 19], [6, 17]]) {
|
||||
test('#475: even buildable hand, temporary-storage and side-margin tiles remain invalid Raid landings', () => {
|
||||
for (const pos of [[0, 3], [1, 3], [0, 17], [1, 17], [3, 1], [3, 19]]) {
|
||||
const h = emptyHalfLanding({ pos, ranged: true });
|
||||
const u = h.unit('probe'), home = [u.tileR, u.tileC];
|
||||
h.run(11);
|
||||
@@ -187,6 +187,15 @@ test('#475: even buildable hand, temporary-storage, side-margin and off-rect til
|
||||
}
|
||||
});
|
||||
|
||||
test('#475: no Raid lands off the rectangle — an enemy spawned on (6,17) is held on the edge row and stands on (5,17)', () => {
|
||||
// Battle._clampPos keeps an enemy FIELD_EDGE_INSET inside the field, so its tile is the edge tile (body.js).
|
||||
const h = emptyHalfLanding({ pos: [6, 17], ranged: true });
|
||||
const u = h.unit('probe');
|
||||
h.run(11);
|
||||
assert.deepEqual([u.tileR, u.tileC], [5, 17]);
|
||||
checkInvariants(h.b);
|
||||
});
|
||||
|
||||
test('#475: empty-half Raid still respects melee/high-ground, water and occupied/downed-unit reservations', () => {
|
||||
for (const glyph of ['h', 'd']) {
|
||||
const h = emptyHalfLanding({ pos: [3, 17], glyph });
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
// Unreleased funnel drones add damage to the caster's attack (PRTS 分支特性信息 驭械术师).
|
||||
// Controlled Battle inputs; the production Match / browser reproduction is recorded separately.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { makeBattle, chessRec, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
|
||||
import { hitCount } from '../../server/sim/content/enemies/helpers.js';
|
||||
import { bodyInKeys } from '../../server/sim/body.js';
|
||||
import { DRONE_TAG } from '../../server/sim/content/kits/ops/chess_char_3_20-kjera.js';
|
||||
|
||||
const near = (a, b, label) => assert.ok(Math.abs(a - b) < 1e-6, `${label}: ${a} vs ${b}`);
|
||||
const ordinary = ['2_10', '3_13', '3_20', '4_03', '6_18'].flatMap((id) => ['a', 'b'].map((f) => ({ chessId: `chess_char_${id}_${f}` })));
|
||||
const glow = [5, 6].flatMap((slot) => [false, true].flatMap((elite) => (elite ? [null, 'uniequip_002_gdglow', 'uniequip_003_gdglow'] : [null]).map((uniEquipId) => ({
|
||||
diy: { slot, charId: 'char_377_gdglow', skillIndex: 2, uniEquipId }, elite,
|
||||
}))));
|
||||
|
||||
function field(unit, enemy = {}, opts = {}) {
|
||||
const h = makeBattle({
|
||||
seed: 3, autoFinish: false, timeLimit: 120, captureNoisy: true, hooks: ['damaged', 'attack'],
|
||||
defs: { enemies: { enemy_dummy: enemyRec({ key: 'enemy_dummy', hp: 1e8, speed: 0, mass: 0, ...enemy }) } },
|
||||
units: [{ uid: 1, row: 10, col: 4, ...unit }], ...opts,
|
||||
});
|
||||
h.step();
|
||||
return { h, u: h.unit(1), e: h.spawn('enemy_dummy', { pos: [10, 6] }) };
|
||||
}
|
||||
const mine = (h, u) => h.hooksOf('damaged').filter((c) => c.source === u);
|
||||
const volleys = (h, u) => {
|
||||
const out = new Map();
|
||||
for (const c of mine(h, u)) {
|
||||
const key = c.dmg.attackId;
|
||||
if (key > 0) { const l = out.get(key) ?? []; l.push(c); out.set(key, l); }
|
||||
}
|
||||
return [...out.values()];
|
||||
};
|
||||
function done(h) { checkInvariants(h.b); assert.deepEqual(h.b.errors, []); }
|
||||
|
||||
test('all normal/elite funnel casters and eight Goldenglow forms: a body hit plus a separate ramped addition', () => {
|
||||
for (const unit of [...ordinary, ...glow]) {
|
||||
const { h, u, e } = field(unit);
|
||||
h.run(2);
|
||||
const vs = volleys(h, u);
|
||||
assert.ok(vs.length >= 2, `${u.defId}: two attacks`);
|
||||
for (const [i, v] of vs.slice(0, 2).entries()) {
|
||||
assert.equal(v.length, 2, `${u.defId}: body and drone, attack ${i + 1}`);
|
||||
const [body, drone] = v;
|
||||
assert.ok(body.dmg.isAttack && !body.dmg.tags.includes('addition'));
|
||||
assert.ok(!drone.dmg.isAttack && drone.dmg.tags.includes('addition') && drone.dmg.tags.includes('funnel'));
|
||||
const crit = u.def.charId === 'char_4054_malist' ? u.def.talents[0].bb.atk_scale : 1;
|
||||
assert.ok([u.s.atk, u.s.atk * crit].some((n) => Math.abs(body.amount - n) < 1e-6));
|
||||
near(drone.amount / body.amount, u.profile.funnel.init + i * u.profile.funnel.delta, 'independent drone ramp');
|
||||
}
|
||||
near(1e8 - e.hp, mine(h, u).reduce((sum, c) => sum + c.amount, 0), 'actual HP agrees with all damage events');
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('Lolo ATK 283: each component passes through RES, hit-count HP and a one-hit barrier separately', () => {
|
||||
for (const mode of ['plain', 'res', 'times', 'barrier']) {
|
||||
const { h, u, e } = field({ chessId: 'chess_char_2_10_a' }, { res: mode === 'res' ? 50 : 0, hp: mode === 'times' ? 100 : 1e8 });
|
||||
near(u.s.atk, 283, 'unbuffed Lolo');
|
||||
if (mode === 'times') hitCount(h.b, e, true);
|
||||
if (mode === 'barrier') h.b.addBuff(e, { key: 'test:barrier', shieldHits: 1 });
|
||||
h.run(0.7);
|
||||
const loss = (mode === 'times' ? 100 : 1e8) - e.hp;
|
||||
near(loss, { plain: 339.6, res: 169.8, times: 2, barrier: 56.6 }[mode], mode);
|
||||
assert.equal(h.hooksOf('attack').filter((c) => c.attacker === u).length, 1, 'one attack action');
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('unreleased attribute skills keep a body hit; released Kjera drones and Lolo S2 do not gain one', () => {
|
||||
// S1 (skillIndex 0) keeps the unreleased pair. S2 (skillIndex 1) releases the drone; rockrock-s2-lock covers it.
|
||||
for (const id of ['chess_char_2_10_a', 'chess_char_2_10_b']) {
|
||||
const { h, u } = field({ chessId: id, skillIndex: 0 });
|
||||
u.skill.gainSp(999);
|
||||
assert.ok(h.runUntil(() => u.skill.active, 2));
|
||||
const start = mine(h, u).length;
|
||||
h.run(2);
|
||||
const active = mine(h, u).slice(start);
|
||||
assert.ok(active.some((c) => c.dmg.isAttack && !c.dmg.tags.includes('addition')), `${id} S1: body`);
|
||||
assert.ok(active.some((c) => c.dmg.tags.includes('funnel')), `${id} S1: drone`);
|
||||
for (const v of volleys(h, u)) assert.equal(v.length, 2, `${id}: one pair per attack`);
|
||||
done(h);
|
||||
}
|
||||
for (const id of ['chess_char_3_20_a', 'chess_char_3_20_b', 'chess_char_4_03_a', 'chess_char_4_03_b']) {
|
||||
const { h, u } = field({ chessId: id });
|
||||
h.run(2);
|
||||
const before = mine(h, u).filter((c) => c.dmg.tags?.includes('funnel'));
|
||||
assert.ok(before.length >= 1, `${id}: a pre-skill drone hit`);
|
||||
const preScale = before.at(-1).amount / u.s.atk;
|
||||
u.skill.gainSp(999);
|
||||
assert.ok(h.runUntil(() => u.skill.active, 5));
|
||||
const t0 = h.b.time;
|
||||
h.run(2);
|
||||
const full = u.s.atk * u.s.atkScaleMul;
|
||||
const later = mine(h, u).filter((c) => c.t + 1e-9 >= t0 - h.b.dt);
|
||||
const body = later.filter((c) => c.dmg.isAttack && !c.dmg.isSkill && !(c.dmg.tags || []).includes('funnel'));
|
||||
const drones = later.filter((c) => (c.dmg.tags || []).includes(DRONE_TAG));
|
||||
assert.ok(body.length > 0, `${id}: the operator's own hit`);
|
||||
assert.ok(drones.length >= 2, `${id}: both released drones`);
|
||||
for (const c of body) {
|
||||
near(c.amount, full, `${id} body is 100% ATK`);
|
||||
assert.ok(!(c.dmg.tags || []).includes('addition'));
|
||||
}
|
||||
for (const c of drones) {
|
||||
assert.ok(c.dmg.isSkill && !c.dmg.isAttack && c.type === 'arts', `${id}: drone skill hit`);
|
||||
assert.ok(!(c.dmg.tags || []).includes('addition') && !(c.dmg.tags || []).includes('funnel'));
|
||||
const r = c.amount / full;
|
||||
assert.ok(r + 1e-6 >= u.profile.funnel.init && r <= u.profile.funnel.max + 1e-6, `${id} drone scale ${r}`);
|
||||
}
|
||||
const first = drones[0].amount / full;
|
||||
if (id.startsWith('chess_char_4_03')) near(first, u.profile.funnel.init, `${id} restarts at the initial scale`);
|
||||
else assert.ok(first > u.profile.funnel.init + 1e-6 && first + 1e-6 >= Math.min(u.profile.funnel.max, preScale + u.profile.funnel.delta) - 1e-6,
|
||||
`${id} continues the pre-skill ramp (${first} after ${preScale})`);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('Whitw2 S1 passive and head-wolf stage 3: only the drone count grows', () => {
|
||||
for (const id of ['chess_char_6_18_a', 'chess_char_6_18_b']) {
|
||||
const { h, u } = field({ chessId: id, skillIndex: 0 });
|
||||
h.run(0.7);
|
||||
assert.equal(volleys(h, u)[0].filter((c) => c.dmg.isAttack).length, 1);
|
||||
assert.equal(volleys(h, u)[0].filter((c) => c.dmg.tags.includes('funnel')).length, 2);
|
||||
// Controlled input keeps S1 off while its deployment-time talent progresses.
|
||||
h.b.addBuff(u, { key: 'test:no-skill', flags: { silence: true } });
|
||||
h.run(65);
|
||||
assert.equal(u.mem.wolfStage, 3);
|
||||
const last = volleys(h, u).at(-1);
|
||||
assert.equal(last.filter((c) => c.dmg.isAttack).length, 1);
|
||||
assert.equal(last.filter((c) => c.dmg.tags.includes('funnel')).length, 3);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('Minimalist charged S2: two body hits and two drone additions share one talent roll and one attack action', () => {
|
||||
for (const id of ['chess_char_3_13_a', 'chess_char_3_13_b']) {
|
||||
const { h, u } = field({ chessId: id });
|
||||
u.skill.gainSp(999);
|
||||
h.run(2);
|
||||
const vs = volleys(h, u).filter((v) => v[0].dmg.isSkill);
|
||||
assert.ok(vs.length > 0);
|
||||
for (const v of vs) {
|
||||
assert.equal(v.length, 4);
|
||||
const body = v.filter((c) => c.dmg.isAttack), drone = v.filter((c) => c.dmg.tags.includes('funnel'));
|
||||
assert.equal(body.length, 2); assert.equal(drone.length, 2);
|
||||
near(body[0].amount, body[1].amount, 'same talent roll on both body hits');
|
||||
near(drone[0].amount, drone[1].amount, 'same ramp and talent roll on both drone hits');
|
||||
assert.ok(drone[0].amount / body[0].amount <= u.profile.funnel.max + 1e-6);
|
||||
}
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('Whitw2 S1 on: the toggle keeps one body hit and both unreleased additions', () => {
|
||||
for (const id of ['chess_char_6_18_a', 'chess_char_6_18_b']) {
|
||||
const { h, u } = field({ chessId: id, skillIndex: 0 });
|
||||
assert.ok(u.skill.activate('test', { free: true }));
|
||||
const t0 = h.b.time;
|
||||
h.run(2);
|
||||
const vs = volleys(h, u).filter((v) => v[0].t + 1e-9 >= t0);
|
||||
assert.ok(vs.length >= 1, `${id}: attacks while S1 is on`);
|
||||
for (const v of vs) {
|
||||
const body = v.filter((c) => c.dmg.isAttack && !(c.dmg.tags || []).includes('addition'));
|
||||
const drone = v.filter((c) => (c.dmg.tags || []).includes('funnel'));
|
||||
assert.equal(body.length, 1, `${id}: the 100% body is not dropped`);
|
||||
assert.equal(drone.length, 2, `${id}: both unreleased drones`);
|
||||
near(body[0].amount, u.s.atk * u.s.atkScaleMul, `${id} body`);
|
||||
for (const c of drone) {
|
||||
assert.ok(!c.dmg.isAttack);
|
||||
const r = c.amount / body[0].amount;
|
||||
assert.ok(r + 1e-6 >= u.profile.funnel.init && r <= u.profile.funnel.max + 1e-6, `${id} ramp ${r}`);
|
||||
}
|
||||
}
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('Whitw2 S2 stays drone-only: no full-ATK body beside the ramp', () => {
|
||||
for (const id of ['chess_char_6_18_a', 'chess_char_6_18_b']) {
|
||||
const { h, u } = field({ chessId: id, skillIndex: 1 });
|
||||
u.skill.gainSp(999);
|
||||
assert.ok(h.runUntil(() => u.skill.active, 8));
|
||||
const t0 = h.b.time;
|
||||
h.run(2);
|
||||
const full = u.s.atk * u.s.atkScaleMul;
|
||||
const active = mine(h, u).filter((c) => c.t + 1e-9 >= t0 - h.b.dt && c.dmg.isSkill);
|
||||
assert.ok(active.length > 0, `${id}: skill hits`);
|
||||
assert.ok(active.every((c) => c.dmg.isAttack && !(c.dmg.tags || []).includes('funnel')), `${id}: ramp stays on the attack`);
|
||||
assert.equal(active.filter((c) => Math.abs(c.amount - full) < 1e-4).length, 0, `${id}: no 100% body`);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('Kjera S2: a lock that leaves her range still takes drone hits, and the body does not follow it', () => {
|
||||
const route = {
|
||||
motion: 'FLY', start: [10, 6], end: [9, 2],
|
||||
steps: [{ t: 'wait', s: 1.2 }, { t: 'move', p: [12, 6] }, { t: 'wait', s: 20 }, { t: 'move', p: [9, 2] }],
|
||||
};
|
||||
for (const id of ['chess_char_3_20_a', 'chess_char_4_03_a']) {
|
||||
const h = makeBattle({
|
||||
seed: 4, autoFinish: false, timeLimit: 40, captureNoisy: true, hooks: ['damaged', 'attack'],
|
||||
defs: { enemies: { enemy_lock: enemyRec({ key: 'enemy_lock', hp: 1e8, speed: 1, motion: 'FLY' }) } },
|
||||
units: [{ uid: 1, row: 10, col: 4, chessId: id, carryState: { sp: 999 } }],
|
||||
enemies: [{ key: 'enemy_lock', route }],
|
||||
});
|
||||
h.step();
|
||||
const u = h.unit(1), e = h.enemies()[0];
|
||||
assert.ok(u.skill.active, `${id} opens on the carried SP`);
|
||||
assert.ok(h.runUntil(() => e.alive && !bodyInKeys(e, u.rangeKeySet), 8), `${id}: the lock leaves range`);
|
||||
h.run(1.5);
|
||||
const t = h.b.time, attacks = u.stats.attacks;
|
||||
h.run(3);
|
||||
assert.ok(e.alive && u.skill.active && !bodyInKeys(e, u.rangeKeySet));
|
||||
const out = mine(h, u).filter((c) => c.target === e && c.t + 1e-9 >= t);
|
||||
assert.ok(out.length > 0, `${id}: damage outside range`);
|
||||
assert.ok(out.every((c) => (c.dmg.tags || []).includes(DRONE_TAG) && c.dmg.isSkill && !c.dmg.isAttack), `${id}: drone only`);
|
||||
assert.equal(u.stats.attacks, attacks, `${id}: the body does not attack with nobody in range`);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('one attached volley: attack-SP, attack equipment and afterHit fire once while two damage instances share attackId', () => {
|
||||
const h = makeBattle({
|
||||
content: 'full', autoFinish: false, captureNoisy: true, hooks: ['damaged', 'attack'],
|
||||
defs: {
|
||||
chess: { test_funnel: chessRec({ id: 'test_funnel', profession: 'CASTER', subProf: 'funnel',
|
||||
rangeGrid: [[0, 0], [0, 1], [0, 2]], stats: { atk: 100, bat: 2 },
|
||||
skill: { spType: 'INCREASE_WHEN_ATTACK', spCost: 100, initSp: 0 } }) },
|
||||
enemies: { enemy_dummy: enemyRec({ key: 'enemy_dummy', hp: 10000, speed: 0, mass: 0 }) },
|
||||
},
|
||||
units: [{ uid: 1, chessId: 'test_funnel', row: 10, col: 4, items: ['chess_item_4_03_e_a'] }],
|
||||
});
|
||||
h.step();
|
||||
const u = h.unit(1), e = h.spawn('enemy_dummy', { pos: [10, 6] });
|
||||
const callbacks = [];
|
||||
// Read-only callback observer on the controlled profile; no damage or attack is added by it.
|
||||
u.profile.afterHit = (b, source, target, ctx) => callbacks.push({ source, target, dealt: ctx.dealt });
|
||||
h.run(0.7);
|
||||
const hits = mine(h, u);
|
||||
assert.equal(hits.length, 2);
|
||||
assert.equal(hits[0].dmg.attackId, hits[1].dmg.attackId);
|
||||
assert.equal(h.hooksOf('attack').length, 1);
|
||||
assert.equal(u.stats.attacks, 1);
|
||||
assert.equal(u.skill.spType, 'attack');
|
||||
near(u.skill.spTotal, 1, 'one attack-SP gain');
|
||||
assert.equal(u.s.aspd, u.base.aspd + 1, 'one accelerator stack');
|
||||
assert.equal(callbacks.length, 1);
|
||||
near(callbacks[0].dealt, 120, 'afterHit receives the combined actual HP damage');
|
||||
near(10000 - e.hp, 120, 'body and addition HP damage');
|
||||
done(h);
|
||||
});
|
||||
|
||||
test('damage equipment: Pegasus riders stay bounded and burn sees both arts components without recursive attack events', () => {
|
||||
for (const items of [['chess_item_6_01_e_a', 'chess_item_5_09_e_a'], ['chess_item_4_09_e_a']]) {
|
||||
const { h, u, e } = field({ chessId: 'chess_char_2_10_a', items });
|
||||
h.run(0.7);
|
||||
const main = mine(h, u).filter((c) => c.dmg.attackId > 0);
|
||||
assert.equal(main.length, 2, 'body plus drone');
|
||||
assert.equal(main[0].type, 'arts'); assert.equal(main[1].type, 'arts');
|
||||
assert.equal(h.hooksOf('attack').length, 1, 'riders are not extra attack actions');
|
||||
if (items.length === 2) {
|
||||
const riders = mine(h, u).filter((c) => c.dmg.tags.includes('item:pegasus'));
|
||||
assert.equal(riders.length, 2, 'one rider per component; no recursion');
|
||||
assert.ok(riders.every((c) => c.type === 'true' && !c.dmg.isAttack));
|
||||
near(1e8 - e.hp, mine(h, u).reduce((sum, c) => sum + c.amount, 0), 'all bounded damage agrees with HP');
|
||||
} else {
|
||||
near(e.elem.burn, 0.1 * main.reduce((sum, c) => sum + c.amount, 0), 'burn from body and drone arts damage');
|
||||
}
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('lethal body or addition: one death and no remaining instances redirected to another target', () => {
|
||||
for (const hp of [200, 320]) {
|
||||
const { h, u, e } = field({ chessId: 'chess_char_2_10_a' }, { hp }, { hooks: ['damaged', 'attack', 'death', 'kill'] });
|
||||
const other = h.spawn('enemy_dummy', { pos: [11, 6] });
|
||||
h.run(0.7);
|
||||
assert.equal(e.alive, false);
|
||||
const hits = mine(h, u);
|
||||
assert.equal(hits.length, hp === 200 ? 1 : 2);
|
||||
assert.ok(hits.every((c) => c.target === e));
|
||||
near(hits.reduce((sum, c) => sum + c.amount, 0), hp === 200 ? 283 : 339.6, 'mitigated amounts before HP clamping');
|
||||
near(u.stats.dmg, hp, 'damage statistics count only HP actually removed');
|
||||
near(other.hp, hp, 'no transfer to a spare target');
|
||||
assert.equal(h.hooksOf('death').filter((c) => c.unit === e).length, 1);
|
||||
assert.equal(h.hooksOf('kill').filter((c) => c.victim === e && c.killer === u).length, 1);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('a target killed during projectile flight receives neither the body nor drone on impact', () => {
|
||||
const { h, u, e } = field({ chessId: 'chess_char_2_10_a' });
|
||||
assert.ok(h.runUntil(() => h.hooksOf('attack').length === 1, 1));
|
||||
assert.equal(mine(h, u).length, 0, 'projectile is still flying');
|
||||
h.b.kill(e, null);
|
||||
h.run(0.7);
|
||||
assert.equal(mine(h, u).length, 0);
|
||||
done(h);
|
||||
});
|
||||
|
||||
test('Minimalist S2: one charge and one talent roll for all four arts instances', () => {
|
||||
for (const id of ['chess_char_3_13_a', 'chess_char_3_13_b']) {
|
||||
const { h, u } = field({ chessId: id });
|
||||
const original = h.b.rng.chance;
|
||||
let talentRolls = 0;
|
||||
h.b.rng.chance = (p) => { talentRolls++; return original(p); };
|
||||
u.skill.gainSp(999);
|
||||
h.run(0.7);
|
||||
const hits = mine(h, u);
|
||||
assert.equal(hits.length, 4);
|
||||
assert.ok(hits.every((c) => c.type === 'arts' && c.dmg.isSkill));
|
||||
assert.equal(new Set(hits.map((c) => c.dmg.attackId)).size, 1);
|
||||
assert.equal(u.skill.activations, 1);
|
||||
assert.equal(u.skill.charges, u.skill.maxCharges - 1);
|
||||
assert.equal(talentRolls, 1);
|
||||
assert.equal(h.hooksOf('attack').length, 1);
|
||||
done(h);
|
||||
}
|
||||
});
|
||||
|
||||
test('Siracusa rolls once for each body, while two attached additions and its own riders do not roll again', () => {
|
||||
const { h, u } = field({ chessId: 'chess_char_6_18_a', skillIndex: 0 }, {}, {
|
||||
bonds: { siracusaShip: { count: 6, active: true, tier: 2, layers: 0 } },
|
||||
});
|
||||
const original = h.b.rng;
|
||||
let rolls = 0;
|
||||
h.b.rng = Object.assign(() => { rolls++; return original(); }, original);
|
||||
h.run(0.7);
|
||||
const attacks = h.hooksOf('attack').filter((c) => c.attacker === u).length;
|
||||
assert.ok(attacks > 0);
|
||||
assert.equal(mine(h, u).filter((c) => c.dmg.tags.includes('funnel')).length, attacks * 2);
|
||||
assert.equal(rolls, attacks, 'one ordinary-damage roll, no additional/rider recursion');
|
||||
done(h);
|
||||
});
|
||||
@@ -0,0 +1,158 @@
|
||||
// PRTS 分支特性信息 驭械术师: changing targets or redeploying resets the drone ramp.
|
||||
// Controlled battle inputs here; the real Match route is covered in test/match/funnel-redeploy.test.js.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { makeBattle, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
|
||||
|
||||
const make = (id = 'chess_char_2_10_a', extra = []) => makeBattle({
|
||||
units: [{ chessId: id, row: 10, col: 4 }, ...extra],
|
||||
defs: { enemies: { dummy: enemyRec({ key: 'dummy', hp: 1e7, speed: 0 }) } },
|
||||
enemies: [{ key: 'dummy', pos: [10, 6] }], timeLimit: 300, autoFinish: false,
|
||||
hooks: ['damaged'], captureNoisy: true,
|
||||
});
|
||||
const hits = (h, u) => h.hooksOf('damaged').filter((c) => c.source === u && c.target.side === 'enemy');
|
||||
const steps = (h, u) => hits(h, u).filter((c) => c.dmg?.tags?.includes('funnel'));
|
||||
const near = (a, b) => assert.ok(Math.abs(a - b) < 1e-8, `${a} != ${b}`);
|
||||
const ramp = (h, u) => {
|
||||
h.runUntil(() => steps(h, u).length >= 8, 30);
|
||||
assert.ok(steps(h, u).length >= 8);
|
||||
};
|
||||
|
||||
for (const id of ['chess_char_2_10_a', 'chess_char_2_10_b', 'chess_char_4_03_a', 'chess_char_4_03_b']) {
|
||||
test(`${id}: same living target starts at the configured initial scale after automatic redeployment`, () => {
|
||||
const h = make(id), u = h.unit(id);
|
||||
ramp(h, u);
|
||||
const e = h.enemies()[0];
|
||||
assert.ok(u.trait.funnelScale > u.profile.funnel.init);
|
||||
const before = hits(h, u).length;
|
||||
h.b.dealDamage(e, u, { amount: 1e7, type: 'true' });
|
||||
assert.equal(u.alive, false);
|
||||
h.runUntil(() => u.alive, 100);
|
||||
assert.equal(u.alive, true);
|
||||
assert.equal(e.alive, true);
|
||||
assert.equal(u.trait.funnelTarget, null);
|
||||
near(u.trait.funnelScale, u.profile.funnel.init);
|
||||
assert.equal(h.runUntil(() => hits(h, u).length > before, 5), true);
|
||||
assert.ok(hits(h, u).length > before);
|
||||
assert.equal(hits(h, u).at(-1).target, e);
|
||||
near(u.trait.funnelScale, u.profile.funnel.init);
|
||||
checkInvariants(h.b);
|
||||
assert.deepEqual(h.b.errors, []);
|
||||
});
|
||||
}
|
||||
|
||||
for (const id of ['chess_char_3_13_a', 'chess_char_3_13_b', 'chess_char_3_20_a', 'chess_char_3_20_b']) {
|
||||
test(`${id}: per-target funnel map restarts on redeployment`, () => {
|
||||
const h = make(id), u = h.unit(id);
|
||||
ramp(h, u);
|
||||
const e = h.enemies()[0];
|
||||
assert.ok(u.trait.funnelMap.get(e.id).s > u.profile.funnel.init);
|
||||
h.b.retreat(u);
|
||||
assert.equal(h.b.redeploy(u), true);
|
||||
assert.equal(u.trait.funnelMap.size, 0);
|
||||
const before = hits(h, u).length;
|
||||
assert.equal(h.runUntil(() => hits(h, u).length > before, 5), true);
|
||||
assert.ok(hits(h, u).length > before);
|
||||
near(u.trait.funnelMap.get(e.id).s, u.profile.funnel.init);
|
||||
checkInvariants(h.b);
|
||||
});
|
||||
}
|
||||
|
||||
test('continuous attacks ramp and cap; changing the living target resets normally', () => {
|
||||
const h = make(), u = h.unit('chess_char_2_10_a');
|
||||
ramp(h, u);
|
||||
near(u.trait.funnelScale, u.profile.funnel.max);
|
||||
const replacement = h.spawn('dummy', { pos: [10, 5] });
|
||||
const before = hits(h, u).length;
|
||||
assert.equal(h.runUntil(() => hits(h, u).length > before, 5), true);
|
||||
assert.ok(hits(h, u).length > before);
|
||||
assert.equal(hits(h, u).at(-1).target, replacement);
|
||||
near(u.trait.funnelScale, u.profile.funnel.init);
|
||||
});
|
||||
|
||||
test('another unit deployment and a free move keep this ramp; unrelated trait state survives redeployment', () => {
|
||||
const h = make('chess_char_2_10_a', [{ chessId: 'chess_char_1_02_a', row: 12, col: 3 }]);
|
||||
const u = h.unit('chess_char_2_10_a'), other = h.unit('chess_char_1_02_a');
|
||||
ramp(h, u);
|
||||
const scale = u.trait.funnelScale, target = u.trait.funnelTarget;
|
||||
u.trait.testUnrelated = { value: 7 }; // controlled sentinel: no production trait object may be replaced
|
||||
const sentinel = u.trait.testUnrelated;
|
||||
h.b.retreat(other);
|
||||
assert.equal(h.b.redeploy(other), true);
|
||||
assert.equal(u.trait.funnelTarget, target);
|
||||
near(u.trait.funnelScale, scale);
|
||||
assert.equal(h.b.moveRedeploy(u, 10, 3), true);
|
||||
assert.equal(u.trait.funnelTarget, target);
|
||||
near(u.trait.funnelScale, scale);
|
||||
h.b.retreat(u);
|
||||
assert.equal(h.b.redeploy(u), true);
|
||||
assert.equal(u.trait.testUnrelated, sentinel);
|
||||
near(u.trait.funnelScale, u.profile.funnel.init);
|
||||
});
|
||||
|
||||
test('an old in-flight drone can advance the reset ramp before the first fresh drone lands', (t) => {
|
||||
for (const drainOld of [false, true]) {
|
||||
const h = make(), u = h.unit('chess_char_2_10_a');
|
||||
ramp(h, u);
|
||||
const e = h.enemies()[0], launches = new Map(), impacts = [], events = [];
|
||||
const state = () => ({ at: h.b.time, currentSeq: u.deploySeq, scale: u.trait.funnelScale, hp: e.hp });
|
||||
h.b.on('attack', (c) => {
|
||||
if (c.attacker !== u) return;
|
||||
const shot = { kind: 'launch', attackId: h.b._attackSeq, ...state() };
|
||||
launches.set(shot.attackId, shot);
|
||||
events.push(shot);
|
||||
});
|
||||
h.b.on('deploy', (c) => {
|
||||
if (c.unit === u) events.push({ kind: 'deploy', target: u.trait.funnelTarget, ...state() });
|
||||
}, { priority: -1000 });
|
||||
h.b.on('damaged', (c) => {
|
||||
if (c.source !== u || c.target !== e || !(c.dmg?.attackId > 0)) return;
|
||||
const launch = launches.get(c.dmg.attackId);
|
||||
const hit = { kind: 'impact', attackId: c.dmg.attackId, launchAt: launch?.at,
|
||||
launchSeq: launch?.currentSeq, ...state(), amount: c.amount, hpBefore: e.hp + c.amount,
|
||||
addition: !!c.dmg.tags?.includes('funnel'), isAttack: !!c.dmg.isAttack };
|
||||
impacts.push(hit);
|
||||
events.push(hit);
|
||||
});
|
||||
const volleysLanded = () => new Set(impacts.map((i) => i.attackId)).size;
|
||||
assert.equal(h.runUntil(() => launches.size === 1, 3), true);
|
||||
const oldShot = [...launches.values()][0];
|
||||
assert.equal(impacts.length, 0);
|
||||
assert.ok(h.b.projectiles.list.some((p) => p.source === u && p.target === e));
|
||||
if (drainOld) assert.equal(h.runUntil(() => volleysLanded() >= 1, 1), true);
|
||||
h.b.retreat(u);
|
||||
assert.equal(h.b.redeploy(u), true);
|
||||
const seq = u.deploySeq;
|
||||
assert.equal(h.runUntil(() => impacts.some((i) => i.launchSeq === seq), 2), true);
|
||||
t.diagnostic(JSON.stringify({ drainOld, events }));
|
||||
const deployment = events.find((i) => i.kind === 'deploy');
|
||||
assert.equal(deployment.target, null);
|
||||
near(deployment.scale, u.profile.funnel.init);
|
||||
const of = (id) => impacts.filter((i) => i.attackId === id);
|
||||
const oldParts = of(oldShot.attackId);
|
||||
const fresh = impacts.find((i) => i.launchSeq === seq);
|
||||
const freshParts = of(fresh.attackId);
|
||||
assert.equal(oldParts[0].launchSeq, oldShot.currentSeq);
|
||||
assert.notEqual(oldParts[0].launchSeq, fresh.launchSeq);
|
||||
assert.ok(oldParts.at(-1).at <= fresh.at);
|
||||
assert.equal(oldParts[0].currentSeq, drainOld ? oldShot.currentSeq : seq);
|
||||
near(oldParts[0].scale, drainOld ? u.profile.funnel.max : u.profile.funnel.init);
|
||||
near(fresh.scale, u.profile.funnel.init + (drainOld ? 0 : u.profile.funnel.delta));
|
||||
for (const parts of [oldParts, freshParts]) {
|
||||
assert.equal(parts.filter((p) => p.isAttack).length, 1);
|
||||
assert.equal(parts.filter((p) => p.addition).length, 1);
|
||||
const body = parts.find((p) => p.isAttack);
|
||||
const add = parts.find((p) => p.addition);
|
||||
near(body.amount, u.s.atk * u.s.atkScaleMul);
|
||||
near(add.amount, body.amount * body.scale);
|
||||
}
|
||||
const oldSum = oldParts.reduce((s, p) => s + p.amount, 0);
|
||||
const freshSum = freshParts.reduce((s, p) => s + p.amount, 0);
|
||||
near(oldShot.hp - oldParts.at(-1).hp, oldSum);
|
||||
near(oldParts.at(-1).hp, freshParts[0].hpBefore);
|
||||
near(freshParts[0].hpBefore - freshParts.at(-1).hp, freshSum);
|
||||
assert.ok(freshParts.at(-1).hp > 0);
|
||||
checkInvariants(h.b);
|
||||
assert.deepEqual(h.b.errors, []);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
// The tile of a target is its position rounded half to even — PRTS 作战机制 「受击方坐标经round函数取整(四舍六入五成双)
|
||||
// 得到其所属格坐标,按是否属于该格判定」 — not Math.round, which rounds every tie up: an enemy exactly halfway between
|
||||
// columns 10 and 11 (x = 10.5) is on column 10, one at x = 11.5 on column 12.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { makeBattle, chessRec, enemyRec } from '../helpers/battleHarness.js';
|
||||
import { roundHalfEven } from '../../server/sim/detmath.js';
|
||||
import { bodyKeys, bodyOnTile, bodyTileReach } from '../../server/sim/body.js';
|
||||
import { tileKeyOf } from '../../server/sim/targeting.js';
|
||||
import { COLS } from '../../server/sim/constants.js';
|
||||
|
||||
test('roundHalfEven: ties to the even integer, the rest to the nearest; never −0', () => {
|
||||
const cases = [[0.5, 0], [1.5, 2], [2.5, 2], [3.5, 4], [10.5, 10], [11.5, 12], [-0.5, 0], [-1.5, -2], [-2.5, -2],
|
||||
[2.4999999999999996, 2], [2.5000000000000004, 3], [-0.4, 0], [-0, 0], [7, 7], [4503599627370495.5, 4503599627370496]];
|
||||
for (const [x, want] of cases) assert.ok(Object.is(roundHalfEven(x), want), `${x} → ${roundHalfEven(x)}, want ${want}`);
|
||||
for (let i = -2000; i <= 2000; i++) {
|
||||
const x = i / 8, v = roundHalfEven(x), d = Math.abs(x - v);
|
||||
assert.ok(Number.isInteger(v) && (d < 0.5 || (d === 0.5 && v % 2 === 0)), `${x} → ${v}`);
|
||||
if (d !== 0.5) assert.equal(v, Math.round(x) + 0, `${x}: away from a tie it is Math.round`);
|
||||
}
|
||||
});
|
||||
|
||||
test('a point target exactly halfway is on the even tile: bodyKeys, bodyOnTile, bodyTileReach, tileKeyOf', () => {
|
||||
const e = { x: 10.5, y: 3.5 };
|
||||
assert.deepEqual(bodyKeys(e), [4 * COLS + 10]);
|
||||
assert.ok(bodyOnTile(e, 4, 10) && !bodyOnTile(e, 4, 11) && !bodyOnTile(e, 3, 10));
|
||||
assert.equal(bodyTileReach(e, 4, 10), 0);
|
||||
assert.equal(bodyTileReach(e, 4, 12), 2);
|
||||
assert.equal(tileKeyOf(e), 4 * COLS + 10);
|
||||
assert.equal(tileKeyOf({ x: 11.5, y: 2.5 }), 2 * COLS + 12);
|
||||
});
|
||||
|
||||
test('the battle\'s enemy index puts an enemy at x = 10.5 on column 10, not 11', () => {
|
||||
const h = makeBattle({
|
||||
kind: 'boss', content: 'none', autoFinish: false, timeLimit: 60,
|
||||
defs: {
|
||||
chess: { t_one: chessRec({ id: 't_one', stats: { atk: 100, blockCnt: 0, maxHp: 1e7 }, rangeGrid: [[0, 0]], skill: null }) },
|
||||
enemies: { enemy_still: enemyRec({ key: 'enemy_still', hp: 1e8, speed: 0 }) },
|
||||
},
|
||||
players: [{ playerId: 'p1', seat: 0, side: 'L', coords: 'field', units: [{ uid: 1, kind: 'chess', chessId: 't_one', row: 1, col: 1 }], bonds: {} }],
|
||||
});
|
||||
h.step();
|
||||
const e = h.spawn('enemy_still', { pos: [3, 10], routeIndex: 0 });
|
||||
e.x = 10.5;
|
||||
e.y = 3;
|
||||
h.step();
|
||||
e.x = 10.5;
|
||||
e.y = 3;
|
||||
const u = h.unit('t_one');
|
||||
assert.deepEqual(h.b.enemiesInKeys([3 * COLS + 10], u, { canHitFly: true }).map((x) => x.id), [e.id]);
|
||||
assert.deepEqual(h.b.enemiesInKeys([3 * COLS + 11], u, { canHitFly: true }).map((x) => x.id), []);
|
||||
});
|
||||
Reference in new issue
Block a user