feedback7 R23-TICK: an attack interval or a time-SP cost of a whole number of frames takes exactly that many — 1 s is 30 frames, not 31 (PR #402 by @Cloudnyco, ported onto 0.2.2); golden: every family moves (roster, bonds, fields, matches, standins, diy — regenerated in the next commit)

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.
This commit is contained in:
sganggs committed 2026-10-09 01:37:32 +08:00
1 parent 9bacd9ad45
commit 227ab9e7ca
18 files changed
+4924 -4774

No files matched your search

+2
View File
@@ -172,6 +172,8 @@ describe('#43: a blocked 隐匿 enemy hides again only 3 s after the block ends
h.run(0.2);
assert.equal(hidden(h, e), true, `+${STEALTH_RESTORE + 0.1} s: 隐匿 again`);
assert.ok(Math.abs(h.b.time - t0 - STEALTH_RESTORE - 0.1) < 0.05);
// an arrow loosed before it hid still lands (a projectile keeps its target): count from when none is in flight
h.runUntil(() => !h.b.projectiles.list.some((p) => p.target === e), 1);
const after = e.stats.taken;
h.run(2);
assert.equal(e.stats.taken, after, 'no ranged hit once it hides');
+155
View File
@@ -0,0 +1,155 @@
// 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);
});