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.
80 lines
4.7 KiB
JavaScript
80 lines
4.7 KiB
JavaScript
// server/sim/content/kits/ops/chess_char_5_21-sntlla.js — 寒檀 (char_341_sntlla) kit, tier 5.
|
||
// Conventions of the tier-5 kits: ../shared/tier5.js; kit contract and rules: ../README.md.
|
||
|
||
import { COLS } from '../../../constants.js';
|
||
import { attackCountdown } from '../../../ai.js';
|
||
import { toLocal } from '../../../dir.js';
|
||
import { num, talent, skillGrid, batPct, mods, lazySkills, whileOn } from '../shared/tier5.js';
|
||
|
||
/** 寒檀 S2 icicle splash radius (PRTS 备注 "冰凌溅射半径1.5"). */
|
||
const ICICLE_RADIUS = 1.5;
|
||
/** 寒檀 S2 icicle fall time (s) between the attack and the impact. [ASSUMED] */
|
||
const ICICLE_DELAY = 0.3;
|
||
/** 寒檀 S2 icicle lines, lateral offsets in her facing-RIGHT frame: ① the line on her left, ② on her right, ③ her own, then ①. */
|
||
const ICICLE_ROWS = [1, -1, 0];
|
||
|
||
export default {
|
||
// ---------------------------------------------------------------------------------------------------------------
|
||
// 寒檀 — S2 “女巫之泪” (15 s): skill range, BAT −2.4 s; attacks become icicles on random tiles of the range (cycling left
|
||
// row → right row → own row): 65/75 % ATK arts + 1 s cold to every enemy within 1.5 of the impact. T1 生于冰寒: after 20 s on the field ATK +15 % and 抵抗 (negative
|
||
// statuses on her last half as long).
|
||
chess_char_5_21_a: (bb, chess, def) => {
|
||
const t0 = talent(chess, 0);
|
||
const scale = num(bb['attack@atk_scale'], 1), cold = num(bb['attack@cold']);
|
||
const resist = Math.min(1, Math.max(0, -num(t0.one_minus_status_resistance))); // share of the duration removed
|
||
return {
|
||
// S1 迅捷打击·γ型 (duration): ATK +, ASPD +.
|
||
skills: lazySkills({
|
||
'skcom_quickattack[3]': () => ({ kind: 'duration', mods: mods({ atkPct: num(bb.atk), aspd: num(bb.attack_speed) }) }),
|
||
}),
|
||
skill: {
|
||
kind: 'duration',
|
||
mods: mods({ batPct: batPct(bb.base_attack_time, chess) }),
|
||
targeting: skillGrid(chess, def) ? { rangeGrid: skillGrid(chess, def) } : undefined,
|
||
attack: { noAttack: true },
|
||
onStart({ unit }) { unit.mem.iceCd = 0; unit.mem.iceRow = 0; },
|
||
onTick({ battle, unit, dt }) {
|
||
unit.mem.iceCd = attackCountdown(unit.mem.iceCd, dt); // (the engine's attack countdown, PR #402)
|
||
if (unit.mem.iceCd > 0 || !unit.canAct || unit.s.flags.disarm) return;
|
||
if (!battle.enemiesInKeys(unit.rangeKeys, unit, { canHitFly: true }).length) return;
|
||
unit.mem.iceCd = unit.s.interval;
|
||
// PRTS 备注: icicles fall on a random tile of ① the row on her left, ② the row on her right, ③ her own row, …
|
||
let k = null;
|
||
for (let i = 0; i < ICICLE_ROWS.length && k == null; i++) {
|
||
const idx = (num(unit.mem.iceRow) + i) % ICICLE_ROWS.length;
|
||
const lat = ICICLE_ROWS[idx];
|
||
const keys = (unit.rangeKeys || []).filter((x) => toLocal(Math.floor(x / COLS) - unit.tileR, x % COLS - unit.tileC, unit.dir)[0] === lat);
|
||
if (!keys.length) continue;
|
||
k = battle.rng.pick(keys);
|
||
unit.mem.iceRow = (idx + 1) % ICICLE_ROWS.length;
|
||
}
|
||
if (k == null) return;
|
||
const r = Math.floor(k / COLS), c = k % COLS;
|
||
unit.lastAttackAt = battle.time;
|
||
unit.stats.attacks++; // "攻击变为…召唤冰凌": every icicle is one of her attacks
|
||
battle.fx('iceSpike', { x: c, y: r, id: unit.id, r: ICICLE_RADIUS });
|
||
battle.after(ICICLE_DELAY, () => {
|
||
const hit = [];
|
||
for (const e of battle.foesInRadius(c, r, ICICLE_RADIUS)) {
|
||
battle.dealDamage(unit, e, { amount: unit.s.atk * scale, type: 'arts', isAttack: true, isSkill: true, isSplash: true, tags: ['skill', 'iceSpike'] });
|
||
if (e.alive && cold > 0) battle.applyStatus(e, 'cold', { duration: cold, source: unit });
|
||
if (e.alive) hit.push(e);
|
||
}
|
||
// an icicle is an attack: "攻击时" effects (items, bonds) see it with the enemies it hit
|
||
if (hit.length && battle.hasHook('attack')) battle.emit('attack', { attacker: unit, targets: hit, isSkill: true });
|
||
}, { owner: unit });
|
||
},
|
||
},
|
||
talents: [{ install(battle, unit) { // 生于冰寒
|
||
const after = num(t0.interval, 20);
|
||
// + 抵抗 (engine `resist` status: new control statuses last half as long, never compounding with other sources)
|
||
whileOn(battle, unit, 0.5, () => {
|
||
if (battle.time - unit.deployedAt < after - 1e-9 || unit.findBuff('sntlla:born')) return;
|
||
battle.addBuff(unit, { key: 'sntlla:born', visible: true, mods: mods({ atkPct: num(t0.atk) }) });
|
||
if (resist > 0) battle.applyStatus(unit, 'resist', { value: resist, source: unit });
|
||
});
|
||
} }],
|
||
};
|
||
},
|
||
};
|