- Shared card pool and item stock as in the official mode (the owner's request, from the 路标档案馆 video and public notes): solo low-tier exceptions, co-op operators x2, shared item stock with special-item exceptions, normal / advanced occupancy 1 / 2, re-check on buy, 机变 exhaustion, release on consume / destroy / elimination; 整备 never exceeds stock; invariant held <= cap. - 突袭: one taunt-first reselect right after a revive (the owner's rule); ordinary polls and redeploys keep the 0.2.3 rule; a revive on an enemy's tile stays. #475: online boss / hidden-boss Raid may land on the empty half (owner-limited scope). - 浊心斯卡蒂 S1 x 坚守 no longer bounce redirected damage, and the two shares conserve the hit (idea of PR #464 by @HappyTanuki). - Operator fixes adapted from PRs by @Xinglan233 (#459 哈洛德 S2 elemental trigger, #460 涤火杰西卡 shield on the four adjacent tiles, #461 家族徽章 first hit after stealth, #462 松果 S2 regrow per deploy); 克莱门莎 S2 splash 1.1 / 100% ATK and air auto-trigger (#467); 白面 copying 歌蕾蒂娅 / 余 skips the three-member condition (#473, the owner's video). - 香槟炸弹 is not chosen by friendly auras / heals (PR #468 / #476 by @Siornya); 琳琅诗怀雅 coin balance (PR #450 by @IceCodeNew); Touch prep display (PR #451 by @YinqiMargatroid); lobby header (PR #471 by @YuriRestia); Japanese UI terms (PR #448 by @scutkane). - AI-only boss countdown grace, render-failure cache / recovery without restarting a battle, device units kept on a same-field re-entry, DIY detail / skill previews, large-text result buttons. - golden: battle families equal 0.2.3 except diy-135 / diy-136 (克莱门莎); all 18 release matches move through the shared pool, every flip named in docs/history/0.2.4.md §29.12; two coverage scenarios added.
200 lines
12 KiB
JavaScript
200 lines
12 KiB
JavaScript
// server/sim/content/kits/ops/chess_char_6_04-skadi2.js — 浊心斯卡蒂 (char_1012_skadi2) kit, tier 6.
|
||
// Conventions of the tier-6 kits: ../shared/tier6.js; kit contract and rules: ../README.md.
|
||
|
||
import { aggregateMods } from '../../../buffs.js';
|
||
import { applyRedirectCut, redirectShare } from '../../../damage.js';
|
||
import { COLS } from '../../../constants.js';
|
||
import { bardRegen } from '../../../professions.js';
|
||
import { startCountdown } from '../../tokens.js';
|
||
import {
|
||
num, bv, tbb, tdesc, moduleBb, parseN, live, enemiesIn, isTok, onDefaultSkill, selectedSkill, aura,
|
||
} from '../shared/tier6.js';
|
||
|
||
const clamp = (v, a, b) => (v < a ? a : v > b ? b : v);
|
||
|
||
/** 深海猎人 (character_table groupId "abyssal" — checked against the official table; data/chess.json has no group id). */
|
||
const ABYSSAL = new Set(['char_143_ghost', 'char_263_skadi', 'char_474_glady', 'char_4145_ulpia', 'char_1023_ghost2']);
|
||
|
||
/**
|
||
* 鼓舞 (ba.inspire "获得额外附加的基础属性加成(同类属性取最高)"): +`val` ATK after the target's own multipliers (flat
|
||
* compensated), strongest source wins. Units flagged `mem.noInspire` (浊心斯卡蒂 "自身不受鼓舞影响") never receive it.
|
||
*/
|
||
function inspire(battle, target, val, src, stat = 'atk') {
|
||
if (!(val > 0) || !live(target) || target.mem.noInspire) return;
|
||
const key = stat === 'def' ? 'inspire:def' : 'inspire';
|
||
const cur = target.findBuff(key);
|
||
if (cur && cur.data && cur.data.src !== src.id && cur.data.val > val && cur.timeLeft > 0.1) return;
|
||
const { add, mul } = aggregateMods(target.buffs.filter((b) => b.key !== key));
|
||
const f = stat === 'def'
|
||
? Math.max(0, 1 + (add.defPct ?? 0)) * (mul.defMul ?? 1)
|
||
: Math.max(0, 1 + (add.atkPct ?? 0)) * (mul.atkMul ?? 1);
|
||
const flat = f > 1e-6 ? val / f : val;
|
||
battle.addBuff(target, { key, mods: stat === 'def' ? { defFlat: flat } : { atkFlat: flat }, duration: 0.75, refresh: 'replace', source: src, visible: true, data: { src: src.id, val } });
|
||
}
|
||
|
||
// ------------------------------------------------------------------------------------------------------------------
|
||
// 浊心斯卡蒂 chess_char_6_04 (吟游者) — S3 "潮涌,潮枯"; 远古血亲 (海嗣); 捕食习性; module 蜕化的残迹
|
||
|
||
function skadi2(bb, chess, def) {
|
||
const t0 = tbb(def, 0), t1 = tbb(def, 1), mod = moduleBb(chess), tb = def?.traitBb || {};
|
||
const isDef = onDefaultSkill(chess), sid = selectedSkill(chess, def);
|
||
const tokId = def?.talents?.[0]?.tokenKey || (chess?.tokens || [])[0] || 'token_10017_skadi2_dedant';
|
||
const auraRatio = num(tb['attack@atk_to_hp_recovery_ratio'], 0.1);
|
||
// S1 / S2 raise the trait ("特性效果提高至N%") while they run; S3 (default) turns it into the tide. The trait is a
|
||
// 生命回复速度 buff on the allies, not a heal (PRTS 分支特性信息 吟游者; professions.js bardRegen)
|
||
const skillRatio = num(bb['attack@atk_to_hp_recovery_ratio'], auraRatio);
|
||
const seaborns = (battle, unit) => battle.allyUnits.filter((t) => isTok(t, tokId, unit) && live(t));
|
||
const covered = (battle, unit, toks) => { // allies inside her range ∪ the seaborns' ranges
|
||
const set = new Set(battle.alliesInGrid(unit));
|
||
for (const t of toks) for (const a of battle.alliesInGrid(t)) set.add(a);
|
||
for (const t of toks) set.delete(t);
|
||
return [...set];
|
||
};
|
||
const inCover = (battle, unit, a) => {
|
||
const k = a.tileR * COLS + a.tileC;
|
||
if (unit.rangeKeySet?.has(k)) return true;
|
||
return seaborns(battle, unit).some((t) => t.rangeKeySet?.has(k));
|
||
};
|
||
const skills = {
|
||
// S1 同归殊途之吟 (SP_FULL): full self heal, max HP +max_hp, trait attack@atk_to_hp_recovery_ratio, and
|
||
// damage_resistance of the damage taken by every ally of her (+ 海嗣) range is transferred to her (install)
|
||
skchr_skadi2_1: {
|
||
kind: 'duration',
|
||
mods: { hpPct: num(bb.max_hp) },
|
||
onStart({ battle, unit }) { unit.hp = unit.s.maxHp; battle.fx('heal', { x: unit.x, y: unit.y, id: unit.id }); },
|
||
},
|
||
// S2 同葬无光之愿 (toggle): 鼓舞 ATK / DEF = atk / def × her ATK / DEF on every other ally of her (+ 海嗣) range,
|
||
// trait attack@atk_to_hp_recovery_ratio (trait pulse). 自动触发 with effects on her allies only (nothing to target):
|
||
// on as soon as it is ready (SP_FULL, like 魔王 S1 往昔萦绕身旁); until 0.2.0 the data's DEFAULT left it off until an
|
||
// enemy came into her (or a 海嗣's) range.
|
||
skchr_skadi2_2: { kind: 'toggle', trigger: 'SP_FULL' },
|
||
};
|
||
return {
|
||
skills,
|
||
skill: {
|
||
kind: 'duration', // effects run in the trait pulse below ("特性变为…")
|
||
onStart({ battle, unit }) { battle.fx('tide', { x: unit.x, y: unit.y, id: unit.id }); },
|
||
},
|
||
trait: {
|
||
install(battle, unit) { // replaces the bard aura: 生命回复速度 normally, tide (true damage + 鼓舞 + self drain) during S3
|
||
unit.mem.noInspire = true; // 自身不受鼓舞影响
|
||
const isInspire = (b) => b.key === 'inspire' || b.status === 'inspire' || b.key.startsWith('inspire:') || b.key.endsWith(':inspire');
|
||
// "海嗣的攻击范围视为自身攻击范围的延伸": an enemy in a 海嗣's range also satisfies her DEFAULT trigger
|
||
if (unit.skill) unit.skill.addTriggerRange(() => seaborns(battle, unit));
|
||
let n = 0;
|
||
battle.every(0.5, () => {
|
||
if (!live(unit)) return; // the continuous trait (including its skill conversion) survives stun
|
||
for (let i = unit.buffs.length - 1; i >= 0; i--) if (isInspire(unit.buffs[i])) battle.removeBuff(unit, unit.buffs[i]);
|
||
n++;
|
||
const toks = seaborns(battle, unit);
|
||
const allies = covered(battle, unit, toks);
|
||
const sk = unit.skill;
|
||
const on = !!(sk && sk.active);
|
||
if (on && isDef) {
|
||
const val = unit.s.atk * num(bb.atk);
|
||
for (const a of allies) if (a !== unit) inspire(battle, a, val, unit);
|
||
if (n % 2 === 0) {
|
||
const dmg = unit.s.atk * num(bb.atk_scale);
|
||
if (dmg > 0) for (const src of [unit, ...toks]) for (const e of enemiesIn(battle, src)) {
|
||
battle.dealDamage(unit, e, { amount: dmg, type: 'true', isSkill: true, tags: ['skill', 'tide'] });
|
||
}
|
||
const loss = unit.s.maxHp * num(bb.hp_ratio);
|
||
if (loss > 0) { if (unit.hp - loss >= 1) battle.loseHp(unit, loss, { source: unit, silent: true }); else unit.hp = Math.min(unit.hp, 1); }
|
||
}
|
||
return;
|
||
}
|
||
if (on && sid === 'skchr_skadi2_2') {
|
||
const va = unit.s.atk * num(bb.atk), vd = unit.s.def * num(bb.def);
|
||
for (const a of allies) if (a !== unit) { inspire(battle, a, va, unit); inspire(battle, a, vd, unit, 'def'); }
|
||
}
|
||
// the trait over her range ∪ the 海嗣' ranges, refreshed every pulse (it lapses 0.75 s after the last one)
|
||
const v = unit.s.atk * (on ? skillRatio : num(unit.profile?.auraRatio, auraRatio));
|
||
for (const a of allies) bardRegen(battle, unit, a, v, 0.75);
|
||
}, { owner: unit });
|
||
},
|
||
},
|
||
install(battle, unit) {
|
||
// S1: "攻击范围内我方所有单位受到伤害的50%直接转移给浊心斯卡蒂承担(同类效果取最高)" — the ally takes (1 − share),
|
||
// she takes the transferred part as true damage from the same attacker
|
||
if (sid === 'skchr_skadi2_1') {
|
||
const share = clamp(num(bb.damage_resistance), 0, 1);
|
||
const TAG = `skadi2:transfer:${unit.id}`;
|
||
battle.on('hit', (ctx) => {
|
||
const t = ctx.target, d = ctx.dmg;
|
||
if (!(share > 0) || !t || t === unit || t.side !== 'ally' || t.kind === 'device' || !live(unit) || !unit.skill?.active) return;
|
||
if (d.type === 'element' || d.type === 'elemental' || d.skadiShare || d.tags?.includes('transfer') || !inCover(battle, unit, t)) return;
|
||
d.skadiShare = { share, tag: TAG }; // 同类效果取最高: one transfer per damage instance
|
||
applyRedirectCut(d, share); // her share of the pre-redirect hit; the victim keeps the rest
|
||
}, { owner: unit, priority: -20 });
|
||
battle.on('damaged', (ctx) => {
|
||
const x = ctx.dmg?.skadiShare;
|
||
if (!x || x.tag !== TAG || !(ctx.amount > 0) || !live(unit)) return;
|
||
// Keep the earlier 坚守 redirect's marker. Rebuilding damage without it lets the same two effects bounce
|
||
// the hit forever; each existing sharing effect still gets its first application along this path.
|
||
battle.dealDamage(ctx.source, unit, { amount: redirectShare(ctx.amount, x.share, ctx.dmg.redirectCuts), type: 'true', canDodge: false,
|
||
tags: ['transfer', ...(ctx.dmg.tags?.includes('bond:stead:share') ? ['bond:stead:share'] : [])] });
|
||
}, { owner: unit });
|
||
}
|
||
// module 新生代: "自身技能期间,攻击范围内的友军获得30点物理与法术伤害减免" (flat reduction per hit)
|
||
const flat = -num(mod.damage_resistance);
|
||
if (flat > 0) {
|
||
battle.on('hit', (ctx) => {
|
||
const t = ctx.target, d = ctx.dmg;
|
||
if (!t || t.side !== 'ally' || t.kind === 'device' || !live(unit) || !unit.skill?.active || !inCover(battle, unit, t)) return;
|
||
if (d.type === 'phys') d.defIgnoreFlat -= flat; // +flat effective DEF ⇒ −flat damage (above the 5 % floor)
|
||
else if (d.type === 'arts') d.amount = Math.max(0, d.amount - flat / Math.max(0.05, 1 - (t.s.res ?? 0) / 100));
|
||
}, { owner: unit, priority: -10 });
|
||
}
|
||
},
|
||
talents: [
|
||
{ install(battle, unit) { // 远古血亲: the seaborn is an inert range extension with a limited life
|
||
battle.on('deploy', ({ unit: t }) => {
|
||
if (!isTok(t, tokId, unit)) return;
|
||
if (t.profile) t.profile.noAttack = true;
|
||
const dur = num(t.def?.talents?.[0]?.bb?.duration, parseN(tdesc(def, 0), /持续(\d+(?:\.\d+)?)秒/, 25));
|
||
const seq = t.deploySeq, r = t.tileR, c = t.tileC;
|
||
startCountdown(battle, t, dur); // a countdown summon: 无敌, 禁疗, its bar = the life left (content/tokens.js)
|
||
battle.after(dur, () => {
|
||
if (!t.alive || t.deploySeq !== seq) return;
|
||
battle.retreat(t, { reason: 'expired', permanent: true });
|
||
const wait = num(t.base.respawnTime, 30), cost = num(t.base.cost, 0);
|
||
let tries = 0;
|
||
const again = () => {
|
||
if (battle.finished || tries++ > 300) return;
|
||
const ps = battle.getPlayer(unit.ownerId);
|
||
if (!live(unit) || !ps || ps.dp + 1e-9 < cost || battle.unitAt(r, c)) { battle.after(1, again, { owner: unit }); return; }
|
||
battle.addDp(unit.ownerId, -cost);
|
||
if (!battle.spawnToken(unit, tokId, r, c)) { battle.addDp(unit.ownerId, cost); battle.after(1, again, { owner: unit }); }
|
||
};
|
||
battle.after(wait, again, { owner: unit });
|
||
}, { owner: t });
|
||
}, { owner: unit });
|
||
} },
|
||
{ install(battle, unit) { // 捕食习性 (module 新生代: ATK 9 / 20 %, DEF +8 %, +3 SP per op deployed in range) + module (≥2 other ops in range: ATK +8 %)
|
||
const a1 = num(t1['skadi2_e_003_t_2[atk][1].atk'], bv(t1, 'skadi2_t_2[atk][1].atk', 0));
|
||
const a2 = num(t1['skadi2_e_003_t_2[atk][2].atk'], bv(t1, 'skadi2_t_2[atk][2].atk', a1));
|
||
const d1 = num(t1['skadi2_e_003_t_2[def].def']), sp = num(t1.sp);
|
||
const mCnt = num(mod.cnt, Infinity), mAtk = num(mod.atk);
|
||
aura(battle, unit, 0.5, () => {
|
||
const toks = seaborns(battle, unit);
|
||
const ops = covered(battle, unit, toks).filter((a) => a !== unit && a.kind === 'op');
|
||
const val = ops.some((a) => ABYSSAL.has(a.def?.charId)) ? a2 : ops.length ? a1 : 0;
|
||
const own = battle.alliesInGrid(unit).filter((a) => a !== unit && a.kind === 'op').length;
|
||
const total = val + (own >= mCnt ? mAtk : 0);
|
||
const dPct = ops.length ? d1 : 0;
|
||
if (total > 0 || dPct > 0) battle.addBuff(unit, { key: 'skadi2:predator', mods: { atkPct: total, defPct: dPct }, duration: 0.75, refresh: 'replace' });
|
||
});
|
||
// "我方干员部署于自身或海嗣范围内后,自身立刻获得3点技力"
|
||
if (sp > 0) battle.on('deploy', ({ unit: u }) => {
|
||
if (!u || u === unit || u.kind !== 'op' || u.side !== 'ally' || !live(unit) || !unit.skill) return;
|
||
if (inCover(battle, unit, u)) unit.skill.gainSp(sp, 'talent');
|
||
}, { owner: unit });
|
||
} },
|
||
],
|
||
};
|
||
}
|
||
|
||
export default {
|
||
chess_char_6_04_a: skadi2,
|
||
};
|