- server/sim/detmath.js: hypot (sqrt of the squares, rescaled outside 2^±500), powi (integer powers in double-double, one rounding), sin / cos (fdlibm 5.3) and atan2 (fdlibm / FreeBSD msun) built only from correctly rounded IEEE operations; the sim's Math.hypot / sin / cos / atan2 / pow calls go through it (65 files); eslint.config.js forbids the approximated Math functions under server/sim and test/sim/detmath.test.js scans for them. - sin / cos / atan2 are the algorithms V8 runs in Node, so the server's results stay; only hypot differs from V8's. Golden: 2 moved — diy-027 (a hypot distance, damage +80) and coop3-ABYSS-5 (a later card pick from round 9, same end). - Applied unchanged after review (Grok static review: MERGE; the diff read in full: no network, no new dependency). - Tests: broad node --test 5155 / 5155 with the new file tracked (detmath 7 / 7, package 10 / 10); lint 0 errors, typecheck, check:imports.
284 lines
20 KiB
JavaScript
284 lines
20 KiB
JavaScript
// server/sim/content/kits/shared/tier6.js — helpers and notes of the hand-authored kits (formerly tier6.js; the kits
|
||
// live one per file in ../ops/) for every tier-6 chess (阶 VI, DIY slots excluded) plus the
|
||
// hidden chess granted by effects (盟约·辅助干员 chess_char_1_15, band Pith "优等生"; 妮芙 chess_char_6_10 is the hidden
|
||
// tier-6 entry). `export default { [baseChessId]: (bb, chess, def) => Kit }` (docs/SIM.md §7.2).
|
||
//
|
||
// Every number comes from the blackboards (skill bb at the chess level: normal Lv4 / elite Lv7; talents from
|
||
// `def.talents[i].bb`, elite module upgrades included; module-only talent parts from `chess.talents[index −1]`; trait
|
||
// module upgrades from `def.traitBb`). A few numbers exist only in the official text; they are parsed from it
|
||
// (e.g. 锏 "总计10次斩击", 艾雅法拉 "5连发", 仇白 "额外攻击2个目标", 海嗣 duration). The profession defaults of
|
||
// professions.js (bard aura, phalanx guard, chain bounces, funnel ramp, bombarder aftershocks, geek drain, tactician
|
||
// reinforcement …) are kept and built upon.
|
||
//
|
||
// Simplifications (one line per id; see also the report of the content phase):
|
||
// 1_15 盟约·辅助干员 every damage she deals attaches all three elements, neural → burn → apoptosis (see pithst).
|
||
// 6_01 蕾缪安 locks every 0.5 s while an enemy is in range (ends early — and bombs — when the range empties);
|
||
// the shells then follow one every 0.3 s (PRTS), each landing 0.3 s after it is fired [ASSUMED flight];
|
||
// the 1.5 radius (PRTS "碰撞箱判定") is read as the centre distance like every engine radius; knocked
|
||
// out / withdrawn after the end, she fires no further shell (those already in the air still land);
|
||
// "wanted" needs a continuous 8 s stay in some 拉特兰 range; wanted targets are added to her range tiles.
|
||
// 6_02 圣聆初雪 "诱导" (ba.attract 无法被阻挡并向目标位置移动) = the engine `attract` status for attract_time: the enemy
|
||
// is unblockable and walks (own speed, grid path) to the nearest ground tile around her, then waits there;
|
||
// snow lives on the ground tiles of her current range; the frozen "保护目标" token is not used (she just
|
||
// freezes herself).
|
||
// 6_03 余 the fire wall is her column; "crossing" = source and target on opposite sides of it; bullet clearing =
|
||
// cancelling a ranged enemy hit that crosses it; talent 2 given to all ops = each op regenerates itself
|
||
// (elite: and gets the ×1.14 arts vs burning targets); 庇护 = phys/arts taken ×(1−0.25).
|
||
// 6_04 浊心斯卡蒂 S3 HP drain is non-lethal (like the geek drain); 鼓舞 = flat ATK added after the target's multipliers;
|
||
// 海嗣 is inert (no attack) and only extends her aura (and her DEFAULT trigger); it expires after its
|
||
// duration and is re-summoned after its redeploy time when DP ≥ its cost (auto-redeploy emulation).
|
||
// 6_05 异客 storm zone radius 1.5 tiles (not in data); strikes chain like her trait (4 targets, trait falloff);
|
||
// the storm stops when she leaves the field.
|
||
// 6_06 佩佩 splash stun via `damaged` (isSplash) hook; module ×1.15 when ≥3 enemies in the splash area.
|
||
// 6_07 维娜 "attack enemies blocked by allies in talent range" = those enemies' tiles are added to her range;
|
||
// S3 puts a 黄金盟誓 on every free deployable melee tile of her talent-1 area (fences too), each for
|
||
// the skill duration (the token's maxDeployCount 1 is its hand limit — tokens.js).
|
||
// 6_08 焰影苇草 灼痕 = marker (ATK −20 %) + the 法术脆弱 status (同名效果取最高); applied during S3 it lasts until the
|
||
// skill ends (duration = remaining skill time).
|
||
// 6_09 塑心 cannot normal attack; each skill charge is one attack; 精神逆构 multiplies every apoptosis gauge fill on
|
||
// enemies in her range (`elementHit`; several 塑心 do not stack — the strongest applies).
|
||
// 6_10 妮芙 失魂 = 元素伤害 ('elemental') per second while the target's apoptosis burst lasts.
|
||
// 6_11 缪尔赛思 流形 copies the nearest allied operator of its player on the field (no hand/bench in battle; same pick
|
||
// as content/tokens.js) when its own skill starts; it copies the listed stats + range + damage type (not
|
||
// splash/chain/multi-hit shapes); clones do not split again. MLYSS_WTRMAN trigger = DEFAULT or an enemy
|
||
// in a (copied) 流形's range. An uncopied 流形 never attacks and its copy skill waits (ready) until someone
|
||
// can be copied. "被击败后25秒后自动刷新": one pending respawn at a time, only while she is on the field
|
||
// and no 流形 of hers stands (her knock-out cancels it; her redeploy re-summons it as her 援军).
|
||
// 6_12 迷迭香 "溅射范围扩大": S2 radius 1.5 (PRTS 溅射半径一览; ×1.3 [ASSUMED] until 0.1.1); 感知稳定 picks among the
|
||
// owner's deployed casters (none ⇒ no buff).
|
||
// 6_13 新约能天使 bombardment radius 1 tile (not in data).
|
||
// 6_14 流明 S3 heals an abnormal ally even at full HP (forced heal); 抵抗 = the engine `resist` status.
|
||
// 6_15 仇白 入隙 reads the target's sluggish/bind statuses; module adds 10 % ATK arts per hit.
|
||
// 6_16 溯光星源 link transfers the pre-mitigation arts amount × share to the other locked target(s); 能源解析 = the
|
||
// 脆弱 status (同名效果取最高).
|
||
// 6_17 耀骑士临光 "上一名部署干员" = the op of the same owner deployed right before her (deploy order).
|
||
// 6_18 荒芜拉普兰德 S3 drones are virtual (fx events) flying PRTS's 技能流程 (spread attack@times s, chase 2.0 → 4.0
|
||
// tiles/s; [ASSUMED] the turn rate is not modelled: straight at the target); every drone is out, so she
|
||
// makes no normal attack herself, while each drone on its target attacks like a normal drone (her attack
|
||
// interval, ATK × its own funnel ramp; neither attack nor skill damage — PRTS 备注); 头狼 stage 2
|
||
// "特殊能力失效" = silence; stage 3 = +1 drone (normal attacks hit once more; S3 releases one more drone,
|
||
// mid-skill too). Base drone count 1.
|
||
// 6_19 锏 10 slashes every d_hit_interval, pulls every p_hit_interval, final blow (skill range) at the end;
|
||
// S3 slashes and pulls air units too (PRTS 备注 "可对空"; a 静态刚体 — every drone of the mode — is hit
|
||
// but stays put: Battle._displaceable).
|
||
// 6_20 纯烬艾雅法拉 5 shots are padded by cycling targets when fewer injured allies exist.
|
||
//
|
||
// Operator loadouts (DESIGN §16): every visible chess also authors its selectable NON-default skills in `skills`
|
||
// ({ [skillId]: SkillSpec }, built from the SELECTED skill's `bb`: normal Lv4 / elite Lv7; triggers from that skill's
|
||
// data). Talent / install hooks written for the default skill check `onDefaultSkill(chess)` (余 闲云隐市 for all, 维娜
|
||
// S3 targets, 焰影苇草 灼痕 until the skill ends, 流明 S3 bullets, 溯光星源 S3 locks, 耀骑士临光 S3 true damage, 缪尔赛思
|
||
// S3 bind, 浊心斯卡蒂 tide). Module choices read the loadout-resolved record (trait.bb, talents, hidden module parts);
|
||
// the module-specific code: 浊心斯卡蒂 新生代, 维娜 秩序圣“球”, 焰影苇草 “独属自己的一隅”, 塑心 音乐家的旅程, 缪尔赛思
|
||
// 落叶四季, 仇白 欲雪时, 耀骑士临光 “骑士家族”, 锏 新合同, 纯烬艾雅法拉 想要留下的生命 (生息演算 / 集成战略-only parts of
|
||
// the RA / IS modules are not modelled). Alternate-skill simplifications:
|
||
// 6_01 S2 the aim (disarmed meanwhile) spends its bullet when it begins; target = a wanted enemy (lowest DEF first).
|
||
// 6_02 S1 casts with an enemy in her range (data SEARCH read as "search in range" [ASSUMED]; the engine SEARCH =
|
||
// any enemy on the field wasted both charges); the snow spreads along her facing line (≤ trig_cnt tiles);
|
||
// S2 "目标点变为冻结状态" = the frozen 保护目标 token (icetgt, one at a time) on a protection point (goal tile) reaching
|
||
// max snow, deploy attributes ignored, whose snow is used up (PRTS 备注; until 0.1.3 any standable tile froze, never the
|
||
// gate); spreads go to the thinnest 4-neighbour; the 20 % DoT ticks once per second.
|
||
// 6_03 S2 teleport = the ground-reachable (grid path) enemies of the skill grid moved onto his tile, in the cast's
|
||
// tick (PRTS: 0.13 s after the damage), leaders included unless 自缚.
|
||
// 6_04 S1 transfer: the ally takes ×(1 − share), she takes the rest as true damage from the attacker; module 新生代
|
||
// "30点伤害减免" = +30 effective DEF (physical) / −30 ÷ (1 − RES) before mitigation (arts).
|
||
// 6_06 S2 the ASPD stacks last the whole battle; S1 "异常状态时可以释放" = cleanse + cast (tick check).
|
||
// 6_07 S2 passive counts the OTHER allies of the talent-1 area.
|
||
// 6_08 S2 carriers = operators of her range (ground first, then lowest HP); a fireball shoots an enemy of the carrier's
|
||
// range (else hers).
|
||
// 6_09 S2 partner = the highest-ATK other operator of her range (re-picked every 0.5 s); S3 picks by base stats.
|
||
// 6_11 S1 one DP every interval, the remainder when it ends; module 落叶四季 "援军阻挡的敌人更容易受到我方的攻击" = the
|
||
// 流形 variant's taunt_level (+1) on the enemies it blocks.
|
||
// 6_05 S2 / 6_12 S3 base_attack_time scales the base attack time (akdata "乘算负数"); see batOf.
|
||
// 6_12 S1 the extra arts hit lands on the main + splash victims (not the aftershocks).
|
||
// 6_13 S1 "主动关闭" = skill.stop(); S3 the 投递坐标 (the player's pick) goes next to the first enemy entering her range
|
||
// (at the latest when S3 starts); delivered = the knocked-out non-ranged operator of hers with the latest
|
||
// respawnAt; the bombardment radius is AIRSTRIKE_RADIUS.
|
||
// 6_14 S1 the HoT covers the allies within 1.5 tiles of the healed target; S2 "蓄力" = cast with every charge full.
|
||
// 6_15 S1 the bind-end explosion radius is 1.2 tiles [ASSUMED].
|
||
// 6_16 S1 bounces are full hits within attack@projectile_range, never on the same enemy twice in a row.
|
||
// 6_18 S1 "非移动敌人" = blocked / not walking / immobilised enemies; S2 every drone ramps on its own target.
|
||
// 6_20 S2 the barrier is one pool over her range at the cast, absorbing gauge fills (before 元素抗性).
|
||
//
|
||
// Summons: skill summons (黄金盟誓, “耀阳”) are spawned without a kit so content/tokens.js's token kit applies (true
|
||
// damage / appear burst / lifetime); the fallbacks here only run when no token kit exists (`kit.fromTokens`).
|
||
// Owner-coupled behaviour of placeable summons (海嗣 aura/lifetime/redeploy, 流形 copy/steal/split/respawn) lives in
|
||
// these kits — tokens.js skips it for summoners with a hand-authored kit (its `managed()` rule).
|
||
// Engine notes: "extra targets" (蕾缪安 wanted, 维娜 S3) use the engine's extra range keys (`battle.setExtraRange`,
|
||
// merged into every range rebuild); 海嗣 / 流形 extend their summoner's DEFAULT trigger through
|
||
// `skill.addTriggerRange`; element-type damage skips the `hit` hook, so element (损伤) amplification uses the
|
||
// `elementHit` hook (`onElementHit`: × dmg.mul before the gauge fill).
|
||
|
||
import { absoluteRangeKeys } from '../../../targeting.js';
|
||
import { COLS, ROWS, PULL_STOP_RADIUS } from '../../../constants.js';
|
||
import { hasHp } from '../../../damage.js';
|
||
import { hypot } from '../../../detmath.js';
|
||
|
||
// ------------------------------------------------------------------------------------------------------------------
|
||
// helpers
|
||
|
||
const num = (v, d = 0) => (typeof v === 'number' && Number.isFinite(v) ? v : d);
|
||
|
||
/** Blackboard value by exact key, else the first key ending with `.key` / `]key` (prefixed official keys). */
|
||
function bv(bb, key, d = 0) {
|
||
if (!bb) return d;
|
||
const v = bb[key];
|
||
if (typeof v === 'number' && Number.isFinite(v)) return v;
|
||
for (const k of Object.keys(bb)) {
|
||
if (k.endsWith('.' + key) || k.endsWith(']' + key)) {
|
||
const x = bb[k];
|
||
if (typeof x === 'number' && Number.isFinite(x)) return x;
|
||
}
|
||
}
|
||
return d;
|
||
}
|
||
const tbb = (def, i) => (def && def.talents && def.talents[i] && def.talents[i].bb) || {};
|
||
const tdesc = (def, i) => String((def && def.talents && def.talents[i] && def.talents[i].description) || '');
|
||
/** Module-only (hidden, index −1) talent parts of a chess record, merged (first occurrence wins). */
|
||
function moduleBb(chess) {
|
||
const out = {};
|
||
for (const t of (chess && chess.talents) || []) {
|
||
if (!t || t.index !== -1 || !t.bb) continue;
|
||
for (const [k, v] of Object.entries(t.bb)) if (!(k in out)) out[k] = v;
|
||
}
|
||
return out;
|
||
}
|
||
const parseN = (text, re, d) => { const m = String(text ?? '').match(re); return m ? +m[1] : d; };
|
||
const live = (u) => !!u && u.alive && u.deployed && !u.removed && !u.hidden;
|
||
const isElite = (e) => !!e && (e.isBoss || e.def?.rank === 'ELITE' || e.def?.rank === 'BOSS');
|
||
const hasBond = (u, id) => !!(u && u.def && Array.isArray(u.def.bonds) && u.def.bonds.includes(id));
|
||
const keyOf = (u) => Math.round(u.y) * COLS + Math.round(u.x);
|
||
const opsOf = (battle, ownerId) => battle.allies(ownerId).filter((a) => a.kind === 'op');
|
||
const ANY = Object.freeze({ canHitFly: true });
|
||
const enemiesIn = (battle, unit, keys) => battle.enemiesInKeys(keys || unit.rangeKeys || [], unit, ANY);
|
||
const isTok = (t, id, owner) => !!t && t.kind === 'token' && t.defId === id && t.ownerUnit === owner;
|
||
|
||
/**
|
||
* Whether the SELECTED skill of a loadout-resolved chess record is its default skill (DESIGN §16: `chess.skill` is the
|
||
* selected SkillRecord, `chess.skills[]` flags the default one). Records without skill choices count as default.
|
||
* Talent / install hooks written for the default skill check it (they run under every selected skill).
|
||
*/
|
||
function onDefaultSkill(chess) {
|
||
const d = (chess?.skills ?? []).find((s) => s && s.isDefault);
|
||
return !d || !chess?.skill || d.skillId === chess.skill.skillId;
|
||
}
|
||
/** Id of the selected skill (loadout-resolved record, else the def). */
|
||
const selectedSkill = (chess, def) => chess?.skill?.skillId ?? def?.skill?.id ?? null;
|
||
/**
|
||
* Skill blackboard `base_attack_time` → engine batPct, the rule of the AK damage calculator (akdata attributes.js,
|
||
* checked against the game): a shortening (v < 0) is a FLAT change of the base attack time in seconds (送葬人 −0.5 on
|
||
* 2.3 s ⇒ 1.8 s, the shared/tier1.js `batMod` convention), a lengthening (v > 0) scales it (迷迭香 S2 +0.5 ⇒ ×1.5, 佩佩 S3 +0.2 ⇒
|
||
* ×1.2). `mul` = a skill the calculator lists as "攻击间隔缩短,但是是乘算负数" (异客 S2 −0.3 ⇒ ×0.7, 迷迭香 S3 −0.5 ⇒ ×0.5).
|
||
*/
|
||
function batOf(v, def, mul = false) {
|
||
const x = num(v);
|
||
if (!x) return 0;
|
||
if (x > 0 || mul) return Math.max(-0.9, x);
|
||
const bat = num(def?.stats?.bat, 1) || 1;
|
||
return Math.max(-0.9, x / bat);
|
||
}
|
||
/** Spec kind of an instant skill (charges when the data gives it several). */
|
||
const instantKind = (def) => ((def?.skill?.maxCharges ?? 1) > 1 ? 'charges' : 'instant');
|
||
/** The selected skill's own range grid (null: the unit's range). */
|
||
const skillGridOf = (def) => (def?.skill?.rangeGrid?.length ? def.skill.rangeGrid : null);
|
||
/**
|
||
* 异常状态 (ba.debuff "包括晕眩、寒冷、冻结等") — the control statuses cleansed by 流明 (抵抗 itself is the engine `resist`
|
||
* status: RESIST_STATUSES). Stat debuffs (虚弱/脆弱/…) are not 异常状态.
|
||
*/
|
||
const ABNORMAL = new Set(['stun', 'freeze', 'cold', 'sleep', 'silence', 'fear', 'attract', 'tremble', 'bind', 'levitate',
|
||
'disarm', 'palsy', 'sluggish', 'slow']);
|
||
const hasAbnormal = (u) => u.buffs.some((b) => b.status && ABNORMAL.has(b.status));
|
||
/** Remove every 异常状态 of `u`; returns how many were removed. */
|
||
function cleanseAbnormal(battle, u) {
|
||
let n = 0;
|
||
for (const b of u.buffs.slice()) if (b.status && ABNORMAL.has(b.status)) { battle.removeStatus(u, b.status); n++; }
|
||
if (n) battle.fx('cleanse', { x: u.x, y: u.y, id: u.id });
|
||
return n;
|
||
}
|
||
const AROUND8 = Object.freeze([[1, -1], [1, 0], [1, 1], [0, -1], [0, 1], [-1, -1], [-1, 0], [-1, 1]]);
|
||
const N4 = Object.freeze([[0, 0], [1, 0], [-1, 0], [0, 1], [0, -1]]);
|
||
/** Every offset of the 19×21 field: "攻击范围扩大至整个战场". */
|
||
const WHOLE_FIELD = (() => {
|
||
const g = [];
|
||
for (let dr = -(ROWS - 1); dr <= ROWS - 1; dr++) for (let dc = -(COLS - 1); dc <= COLS - 1; dc++) g.push([dr, dc]);
|
||
return Object.freeze(g);
|
||
})();
|
||
|
||
const STATE = new WeakMap();
|
||
/** Per-battle shared state for kits (trackers registered once per battle). */
|
||
function bstate(battle) {
|
||
let s = STATE.get(battle);
|
||
if (!s) { s = {}; STATE.set(battle, s); }
|
||
return s;
|
||
}
|
||
|
||
/**
|
||
* Free deployable tiles of `grid` around `unit` (melee ground tiles unless `ranged`). Skips `battle.isReservedTile`
|
||
* (a living unit, or the home tile of a piece that has not deployed yet / waits to redeploy: a summon parked there
|
||
* would keep that operator off the field).
|
||
*/
|
||
function freeTiles(battle, unit, grid, { ranged = false, ground = true } = {}) {
|
||
const out = [];
|
||
const own = unit.tileR * COLS + unit.tileC;
|
||
for (const k of absoluteRangeKeys(grid, unit.tileR, unit.tileC, unit.dir, 0)) {
|
||
if (k === own) continue;
|
||
const r = (k / COLS) | 0, c = k % COLS;
|
||
if (!battle.grid.inRect(r, c) || battle.isReservedTile(r, c)) continue;
|
||
if (!battle.grid.canStand(r, c, { ranged })) continue;
|
||
if (ground && !battle.grid.groundPassable(r, c, true)) continue;
|
||
out.push([r, c]);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/** Tile closest to any living enemy (first tile when there is no enemy). */
|
||
function bestTile(battle, tiles) {
|
||
if (!tiles.length) return null;
|
||
const en = battle.enemies.filter((e) => e.alive && !e.hidden);
|
||
if (!en.length) return tiles[0];
|
||
let best = tiles[0], bd = Infinity;
|
||
for (const t of tiles) {
|
||
let d = Infinity;
|
||
for (const e of en) d = Math.min(d, hypot(e.x - t[1], e.y - t[0]));
|
||
if (d < bd - 1e-9) { bd = d; best = t; }
|
||
}
|
||
return best;
|
||
}
|
||
|
||
/**
|
||
* Element (损伤) amplification: element gauge fills skip the `hit` hook but fire `elementHit` { source, target, dmg }
|
||
* before the fill — multiply `dmg.mul` there (one instance, exact burst threshold). `fn(ctx)` returns the factor.
|
||
*/
|
||
function onElementHit(battle, unit, fn) {
|
||
battle.on('elementHit', (ctx) => {
|
||
if (!ctx.dmg || ctx.dmg.type !== 'element' || !ctx.target) return;
|
||
const f = fn(ctx);
|
||
if (Number.isFinite(f) && f > 0 && f !== 1) ctx.dmg.mul *= f;
|
||
}, { owner: unit });
|
||
}
|
||
|
||
/** Deal `amount` of element `el` (元素损伤) — nothing on a target at 0 HP (a lethal hit's `damaged` hook, see hasHp). */
|
||
const elementDmg = (battle, src, tgt, el, amount, tags = ['skill']) =>
|
||
(amount > 0 && hasHp(tgt) ? battle.dealDamage(src, tgt, { type: 'element', element: el, amount, tags }) : 0);
|
||
|
||
/**
|
||
* Pull `e` with 力度 `force` towards the point / unit `unit` ("向自己中心…拖拽", "拉向目标所在位置"), stopping `stop` tiles
|
||
* from it — Battle.pull: the official 力度 − 重量 pull (PRTS 推与拉; user playtest #6 item 14). The default stop is the
|
||
* 急停 radius 0.6708 around a pulling unit. A pulling ally is also the pull's `center`, so an enemy it blocks itself
|
||
* stays where it is held (as in Battle.pullToFront) — e.g. the 流形 S3 pulse then still stuns it.
|
||
*/
|
||
function pullToward(battle, unit, e, force, stop = PULL_STOP_RADIUS) {
|
||
if (!e || !e.alive || !unit) return 0;
|
||
return battle.pull(e, num(force), { to: { x: unit.x, y: unit.y }, center: unit.side === 'ally' ? unit : null, stop });
|
||
}
|
||
|
||
/** Stat-aura pulse: short buffs refreshed every `period` s on `targets()`. */
|
||
function aura(battle, unit, period, fn) {
|
||
battle.every(period, () => { if (live(unit)) fn(); }, { owner: unit });
|
||
}
|
||
|
||
export {
|
||
num, bv, tbb, tdesc, moduleBb, parseN, live, isElite, hasBond, keyOf, opsOf, ANY, enemiesIn, isTok, onDefaultSkill,
|
||
selectedSkill, batOf, instantKind, skillGridOf, ABNORMAL, hasAbnormal, cleanseAbnormal, AROUND8, N4, WHOLE_FIELD,
|
||
bstate, freeTiles, bestTile, onElementHit, elementDmg, pullToward, aura,
|
||
};
|