Seen (PR #402, measured again on 0.2.2): TICK = 1/30 is not a binary fraction. An attack cooldown counted down from 1 s stops at 2.1e-16 after thirty steps and the fire check `atkCd > 0` waited a 31st frame; 300 time gains of 1/30 SP add up to 9.999999999999975 and `sp >= cost` charged a 10-SP skill a frame late. Over the 265 golden battles 30.85 % of the consecutive attacks on a whole-frame interval came a frame late (control, other intervals: 0.09 %), and 4 157 end-of-tick states sat within 1e-9 under a cost. The engine already took timers within 1e-9 as done (skill timeLeft, buffs, every() / after(), enemy abilities, nextCastAt, the 失衡 end); these two did not. Official: PRTS 作战机制/sandbox 「帧对齐机制对攻速的影响」 — one frame = 1/30 s; since 2019-12-24 a non-integer frame count is rounded (四舍五入); an exact 1 s is 30 frames under any rule. Now: ai.js attackCountdown(cd, dt) (left within 1e-9 of 0 is 0) for the operators' cooldown (the canAct guard kept) and the enemies' (not while stunned or hidden; the 0.2.2 失衡 swing cut untouched) — an enemy's whole-frame wind-up is exact too (0.4 s: 12 frames, 13 before). The same countdown for the two strict `cd -= dt; if (cd > 0)` attack clocks of the same shape: the 双眼皮 turrets (content/devices.js; BAT 4 s: 121 → 120 frames) and 寒檀's S2 icicles (0.5 s: 16 → 15). SkillRuntime.gainSp takes SP within 1e-9 of the cost as full for every charge (a tiny negative remainder → 0); _normalize compares the same way. Left as they are: a non-integer count still ends on the frame that crosses 0 (⌈30 × interval⌉ — the official 四舍五入 is an older, separate difference); the count-up content clocks that restart from 0 (bosses.js self-timers, kit auras — PR #402's list); attack / hurt SP (+1 on an integer cost) unchanged. Tests: test/sim/tick-tolerance.test.js (new, 7: the PR's four adapted — residue, attackCountdown over bat 0.1–10 s × 14 ASPD = ⌈30 × interval⌉, 1 s operator every 30 / 3 s ranged enemy every 90, time SP 4/10/16/20/30/50 at exactly 30 × N gains for one and two charges — plus the 1.2 s / 1.25 s control (36 / 38), the turret at 0 layers (120) and 寒檀's icicles (15); the four engine tests fail on the parent: 31 / 91, cost 4 at 121, 121, 16). The PR's seven test edits, re-run: the same 8 assertions failed on the new timing and each edit passes on the parent and on the fix — feedback2-surtr-stealth (count once no arrow is in flight), kits_alt_t2 拉普兰德 S1 (dummy ATK 300 → 250: 120 × 15 = her 1791 HP by 59.5 s), kits_alt_t6 (medic smoke at half HP; 余 S1 up to the first 燃烧 burst), op_jesca2 (the T6 shield returns a tick after the cast), op_lmlee (hits before the burst), op_logos (count after the clear), op_narant (the first attack thrown after the new enemies). node --test (with the golden of the next commit): 5703 tests, 5701 pass, 0 fail, 2 skipped; eslint 0 errors (135 warnings, the parent's 135); typecheck, check:imports, smoke ok. feedback7 R23-TICK: golden for whole-frame attack intervals and SP costs (PR #402) — 281 of 283 scenarios move: roster 49/49, bonds 46/46, fields 22/22, matches 18/18, standins 10/10, diy 136/138; solo-NORMAL-1 now loses its Final Assault (below) npm run golden:update on the previous commit. Every family moves because both sides attack on time (more enemy kinds than operator forms sit on whole-frame intervals: 3 s, 4 s, 1 s …) and time SP charges a frame sooner. Measured over the 265 battles: whole-frame consecutive attacks a frame late 30.85 % → 0.04 % (control 0.09 % → 0.13 %; 1 s gaps at 30 frames 256 → 3 776 of ~3 800, 3 s at 90 frames 0 → 6 657 of 6 657), SP float-short states 4 157 → 0. Moved battles' totals: kills 10 647 → 10 625, leaks 12 398 → 12 445, deaths 2 913 → 2 944. Unchanged: diy-040, diy-069. End results that flip (one frame cascades; the causes as measured with the matches' battles instrumented): - solo-NORMAL-1: won → lost in round 14 (passed 14 → 13, team LP 8 → 0): the Final Assault on 卢西恩 (pool 390 000) runs as before for two minutes (his 0.4 s wind-up a frame shorter), then the team's damage on the pool trails — 884 left when the overtime drain (from 300 game s) takes the last of the 17 LP at 334 s; before, it emptied at 318 s, 8 LP left. - solo-HARD-1: LP 10 → 12 — round 8 lets one more through (26 → 25; rounds 11–12 even it out) and the Final Assault ends at 300.6 s, before the overtime drain (304.8 s and 2 LP before). - solo-NORMAL-2: LP 9 → 25 — round 5 lets one more through (26 → 25, a coin less), the bot's shop path parts from round 6 and its later board (突袭, 232 layers at round 13 against 94) clears rounds 12–13 with no leak (7 + 18 before). - coop2-NORMAL-3: team LP 63 → 68 — ai_1's round-13 battle leaks 1 instead of 6 (its round-12 leaker now goes to 联防). - coop2-ABYSS-10-boosted: team LP 583 → 630 — the Final Assault ends at 606.6 s (639.6) and the Hidden Core at 335.9 s (397.8): 47 LP less of overtime drain. - coop2-NORMAL-14-standins: team LP 41 → 40 — ai_0's round-13 battle lets one 潜伏者 through (23 → 22). - roster-016: cleared at 220.3 s → time-out at 230 s (主角阵营角色 leaks instead of falling; kills 101 → 105, leaks 26 → 22). - roster-041: time-out → cleared at 189.0 s, perfect (the last 粉碎攻坚组长 falls inside the 190 s). - bond-suntShip-high: cleared at 164.2 s → time-out at 190 s (kills 11 → 13, deaths 4 → 2). - standin-04: perfect → 10 leaks (deaths 8 → 10; 萨卡兹大剑组长 / 萨卡兹穿刺手 / 澪 get through). - diy-005 / diy-008 (艾丽妮 / 赫拉格): cleared at 225.3 s → time-out at 230 s (a last 宿主重装士兵 / 深池方阵指挥官 walking). - diy-127 (白铁 / 酒神): time-out → cleared at 225.8 s (deaths 7 → 13, leaks 57 → 72). - diy-131 (死芒): perfect → 1 leak (a 掠海漂移体). The other matches keep their ends (solo-FUNNY-1/2, solo-HARD-2, solo-ABYSS-1/2, coop2-HARD-4, coop3-ABYSS-5, coop4-FUNNY-6, coop4-ABYSS-7, coop2-NORMAL-8-serverrun, solo-HARD-9-boosted, coop2-NORMAL-70-diy). PR #402's own 0.2.1 table (coop2-NORMAL-14-standins loss → win, solo-HARD-2 / solo-ABYSS-2 earlier exits) was measured before §27 and does not repeat here.
156 lines
7.7 KiB
JavaScript
156 lines
7.7 KiB
JavaScript
// An attack interval or a time-SP cost that is a whole number of ticks takes exactly that many ticks (PR #402 by
|
||
// @Cloudnyco, 0.2.2). Floating point alone left a residue (1/30 is not a binary fraction): 1 s counted down in thirty
|
||
// steps of 1/30 stops at 2.1e-16, so an attack with a 1 s interval came every 31 ticks; 300 gains of 1/30 SP sum to
|
||
// 9.999999999999975, so a 10-SP charge took 301. ai.js attackCountdown and SkillRuntime.gainSp treat what is within
|
||
// 1e-9 as reached (docs/SIM.md §2, §7.1). Official (PRTS 作战机制/sandbox 「帧对齐机制对攻速的影响」): one frame = 1/30 s,
|
||
// an exact 1 s is 30 frames; a non-integer count is rounded there (四舍五入 since 2019-12-24) — here it still ends on the
|
||
// tick that crosses 0 (⌈30 × interval⌉), a separate, older difference this file locks as it is.
|
||
import { test } from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import { attackCountdown } from '../../server/sim/ai.js';
|
||
import { TICK } from '../../server/sim/constants.js';
|
||
import { hasGeneratedData, getDefaultSource } from '../../server/sim/simdata.js';
|
||
import { makeBattle, chessRec, enemyRec, checkInvariants } from '../helpers/battleHarness.js';
|
||
|
||
const REAL = { skip: !hasGeneratedData() };
|
||
|
||
test('the residue: thirty steps of 1/30 leave 1 s above 0, 300 gains of 1/30 leave 10 SP short', () => {
|
||
let cd = 1;
|
||
for (let i = 0; i < 30; i++) cd = Math.max(0, cd - TICK);
|
||
assert.ok(cd > 0 && cd < 1e-15, `${cd}`);
|
||
let sp = 0;
|
||
for (let i = 0; i < 300; i++) sp += TICK;
|
||
assert.ok(sp < 10 && sp > 10 - 1e-13, `${sp}`);
|
||
});
|
||
|
||
test('attackCountdown: every interval bat × 100 / aspd (bat 0.1 … 10 s, aspd 20 … 600) takes ⌈30 × interval⌉ ticks', () => {
|
||
const wrong = [];
|
||
for (let j = 1; j <= 100; j++) {
|
||
const bat = j / 10;
|
||
for (const aspd of [20, 50, 60, 75, 80, 100, 110, 120, 125, 150, 200, 240, 300, 600]) {
|
||
const interval = (bat * 100) / aspd; // units.js _recalc
|
||
const exact = 300 * j; // 30 × interval = 300 j / aspd, in integers
|
||
const want = exact % aspd === 0 ? exact / aspd : Math.ceil(exact / aspd);
|
||
let left = interval, n = 0;
|
||
while (left > 0) { left = attackCountdown(left, TICK); n++; }
|
||
if (n !== want) wrong.push(`${bat} s @ ${aspd}: ${n} ticks, want ${want}`);
|
||
}
|
||
}
|
||
assert.deepEqual(wrong, []);
|
||
});
|
||
|
||
/** Ticks between consecutive normal attacks of `id` (hook `attack`), over `seconds` of battle. */
|
||
function attackGaps(h, id, seconds) {
|
||
h.run(seconds);
|
||
const t = h.hooksOf('attack').filter((c) => c.attacker.id === id && !c.isSkill).map((c) => Math.round(c.t / TICK));
|
||
return t.slice(1).map((x, i) => x - t[i]);
|
||
}
|
||
|
||
test('a 1 s operator attacks every 30 ticks, a 3 s ranged enemy every 90', () => {
|
||
const h = makeBattle({
|
||
defs: {
|
||
chess: { t_one: chessRec({ id: 't_one', stats: { bat: 1, maxHp: 1e6 }, skill: null }) },
|
||
enemies: { enemy_t_three: enemyRec({ key: 'enemy_t_three', hp: 1e9, speed: 0, bat: 3, atk: 1, range: 2.5 }) },
|
||
},
|
||
units: [{ chessId: 't_one', row: 10, col: 4 }],
|
||
enemies: [{ key: 'enemy_t_three', pos: [10, 5] }],
|
||
hooks: ['attack'], captureNoisy: true, timeLimit: 60,
|
||
});
|
||
h.run(0.5); // both deployed / spawned and fighting from here on
|
||
const u = h.unit('t_one'), e = h.enemy('enemy_t_three');
|
||
assert.equal(u.s.interval, 1);
|
||
assert.equal(e.s.interval, 3);
|
||
const ally = attackGaps(h, u.id, 20), enemy = h.hooksOf('attack').filter((c) => c.attacker.id === e.id).map((c) => Math.round(c.t / TICK));
|
||
assert.ok(ally.length >= 15, `${ally.length} operator attacks`);
|
||
assert.deepEqual([...new Set(ally)], [30]);
|
||
const eg = enemy.slice(1).map((x, i) => x - enemy[i]);
|
||
assert.ok(eg.length >= 5, `${eg.length} enemy attacks`);
|
||
assert.deepEqual([...new Set(eg)], [90]);
|
||
checkInvariants(h.b);
|
||
});
|
||
|
||
test('the non-integer control: 1.2 s takes 36 ticks (on time before as well), 1.25 s still ⌈37.5⌉ = 38', () => {
|
||
for (const [bat, want] of [[1.2, 36], [1.25, 38]]) {
|
||
const h = makeBattle({
|
||
defs: {
|
||
chess: { t_bat: chessRec({ id: 't_bat', stats: { bat, maxHp: 1e6 }, skill: null }) },
|
||
enemies: { enemy_t_wall: enemyRec({ key: 'enemy_t_wall', hp: 1e9, speed: 0 }) },
|
||
},
|
||
units: [{ chessId: 't_bat', row: 10, col: 4 }],
|
||
enemies: [{ key: 'enemy_t_wall', pos: [10, 5] }],
|
||
hooks: ['attack'], captureNoisy: true, timeLimit: 60,
|
||
});
|
||
h.run(0.5);
|
||
const u = h.unit('t_bat');
|
||
const gaps = attackGaps(h, u.id, 20);
|
||
assert.ok(gaps.length >= 10, `${bat} s: ${gaps.length} attacks`);
|
||
assert.deepEqual([...new Set(gaps)], [want], `${bat} s`);
|
||
checkInvariants(h.b);
|
||
}
|
||
});
|
||
|
||
test('a time skill of cost N at 1 SP/s gets each charge after exactly 30 × N more gains (one and two charges)', () => {
|
||
// 4, 10, 16 and 50 were a tick late (their float sums fall short); a second charge counts on from the first's remainder
|
||
for (const charges of [1, 2]) for (const cost of [4, 10, 16, 20, 30, 50]) {
|
||
const h = makeBattle({
|
||
defs: { chess: { t_sp: chessRec({ id: 't_sp', skill: { spCost: cost, initSp: 0, maxChargeTime: charges } }) } },
|
||
units: [{ chessId: 't_sp', row: 10, col: 4 }],
|
||
timeLimit: charges * cost + 10, autoFinish: false,
|
||
});
|
||
const u = h.unit('t_sp');
|
||
const at = [];
|
||
let gains = 0;
|
||
while (u.skill.charges < charges && gains < 30 * charges * cost + 5) {
|
||
const sp = u.skill.sp, n = u.skill.charges;
|
||
h.step(1);
|
||
if (u.skill.sp !== sp || u.skill.charges > n) gains++;
|
||
if (u.skill.charges > n) at.push(gains);
|
||
}
|
||
assert.deepEqual(at, Array.from({ length: charges }, (_, i) => 30 * cost * (i + 1)), `cost ${cost}, ${charges} charge(s)`);
|
||
checkInvariants(h.b);
|
||
}
|
||
});
|
||
|
||
test('the same countdown: a “双眼皮” turret at 0 layers (BAT 4 s) shoots every 120 ticks', REAL, () => {
|
||
const ds = getDefaultSource();
|
||
const d = ds.getStage('act2autochess_m01').devices.find((x) => x.raw?.alias === 'trap_1104_aclasert#1');
|
||
assert.equal(d.raw.stats.bat, 4);
|
||
const players = [{ playerId: 'p1', seat: 0, side: 'L', colOffset: 0, units: [],
|
||
playerEffects: [{ id: 'aceffect_band_43', key: 'auto_chess_change_map', params: { 'trap_1104_aclasert#1': 1 } }] }];
|
||
const h = makeBattle({
|
||
stageId: 'act2autochess_m01', players, defs: { enemies: { enemy_t_wall: enemyRec({ key: 'enemy_t_wall', hp: 1e9, speed: 0 }) } },
|
||
enemies: [{ key: 'enemy_t_wall', pos: [10, 9] }], hooks: ['attack'], captureNoisy: true, autoFinish: false, timeLimit: 60,
|
||
});
|
||
h.step();
|
||
const t = h.b.allyUnits.find((u) => u.defId === 'trap_1104_aclasert');
|
||
assert.ok(t && t.alive, 'turret online');
|
||
const gaps = attackGaps(h, t.id, 30);
|
||
assert.equal(t.s.interval, 4);
|
||
assert.ok(gaps.length >= 5, `${gaps.length} shots`);
|
||
assert.deepEqual([...new Set(gaps)], [120]);
|
||
checkInvariants(h.b);
|
||
});
|
||
|
||
test('the same countdown: 寒檀 S2 drops an icicle every 15 ticks (BAT 2.9 − 2.4 = 0.5 s)', REAL, () => {
|
||
const h = makeBattle({
|
||
defs: { enemies: { enemy_t_wall: enemyRec({ key: 'enemy_t_wall', hp: 1e9, speed: 0 }) } },
|
||
units: [{ chessId: 'chess_char_5_21_a', row: 10, col: 3 }],
|
||
enemies: [{ key: 'enemy_t_wall', pos: [10, 4] }], autoFinish: false, timeLimit: 60,
|
||
});
|
||
h.step();
|
||
const u = h.unit('chess_char_5_21_a');
|
||
u.skill.gainSp(1000);
|
||
assert.ok(h.runUntil(() => u.skill.active, 5));
|
||
assert.ok(Math.abs(u.s.interval - 0.5) < 1e-12, `${u.s.interval}`);
|
||
const at = [];
|
||
for (let n = u.stats.attacks, i = 0; i < 150 && u.skill.active; i++) {
|
||
h.step();
|
||
if (u.stats.attacks > n) at.push(Math.round(h.b.time / TICK));
|
||
n = u.stats.attacks;
|
||
}
|
||
const gaps = at.slice(1).map((x, i) => x - at[i]);
|
||
assert.ok(gaps.length >= 5, `${gaps.length} icicles`);
|
||
assert.deepEqual([...new Set(gaps)], [15]);
|
||
checkInvariants(h.b);
|
||
});
|