Files
ark-stronghold-protocol/server/sim/content/kits/ops/op-doroth.js
T
sganggs ec0a2fc669 feedback7 R22-LOADOUT: the battle takes each operator's potential and 练度 from its spec entry; golden: unchanged
A PlayerBattleInput operator entry may carry `potential` (潜能 1–6) and `cultivate` (练度 0–3, the 自持有 bonus) —
the player's 干员调配 settings (the owner's decision of 2026-10-08). Specs without them field what 0.2.1 fielded, so
no golden scenario moves here; the match states both next.

- shared/loadoutRecord.js: a resolved loadout carries `potential`; loadoutRecord composes the record at it first
  (shared/potential.js atPotential), then the skill / module as before.
- server/sim/simdata.js: getChess(id, { …, potential }), getDiy(slot, pick, potential) (an owned 自选 pick's form at it;
  a prototype has none — `diyProto` on its record, shared/diy.js), getToken / getDiyToken compose the owner variant at
  the owner's potential (talents; a 自选 summon's deploy limit / holding / count); the per-battle loadout view keeps it
  per unit; DataSource.cultivateMul reads the effects.json CHAR_MAP record's char_attribute_mul.
- battle/players.js: `potential` composes the def (never a stand-in's); `cultivate` sets unit.cultMul — ×ATK / ×DEF /
  ×max HP in the unit's Πmul (units.js), a multiplier of its own, never summed with the 直接乘算 percentages (PRTS
  盟约记录 「…与自持有干员的属性加成独立」); a stand-in, a prototype 自选 pick and a summon take none [ASSUMED for summons].
- spec.js keeps both only when well-formed (never on a stand-in or a summon); content/index.js passes the potential to
  the loadout sync; the kits that read a summon variant raw (content/tokens.js ownVariant, summoner.js tokenStat,
  多萝西, 麦克尼斯特) compose it at the owner's potential; UnitInfo carries `potential` (below 6) / `cultivate`;
  unitStatsEntry counts the 练度 in the unit's own numbers (no green delta for it on the card).
- Docs: SIM.md §2 (Πmul), §9 (UnitInfo), §12.
- Tests: test/sim/potential-battle.test.js (new, 6): a unit at each potential (刺玫 413 / 17 / 70 s and 8 % at 潜能1;
  the elite on 不装备), the four 练度 tiers and their product with a +20 % ATK bonus, HP at deployment and the card's
  numbers, stand-in / prototype take neither and an owned 自选 pick both, Mon3tr / 望's 棋子 / “小自在” at the owner's
  potential, the spec round trip (kept, dropped, the JSON spec gives the same result digest), two players with the same
  chess at different settings on one field; test/sim/loadout.test.js expects the resolved loadout's new fields.
2026-10-08 20:38:08 +08:00

295 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// server/sim/content/kits/ops/op-doroth.js — 多萝西 (char_4048_doroth) 自选 operator kit: 6★ 陷阱师 (特种), an owned-6★ pick
// of the tier-5 and tier-6 自选 slots; every skill, both talents, the trait, both modules (TRP-Y 童话书, TRP-X 梦中人) at
// every form, and the kit of her trap 共振装置 (token_10025_doroth_recttp). Kit contract and the 自选 rules: ../README.md
// ("How to add an operator (自选)").
//
// Forms (data/backups.json units.char_4048_doroth): normal = E2 Lv1, skills at rank 4, no module; elite = E2 Lv60, rank 7,
// the picked module at stage 1 (tier 5) or 3 (tier 6) — the owner's decision of 2026-10-05. Full potential (the owner's
// decision of 2026-10-07). Sources: character_table / skill_table / battle_equip_table / token_table (zh_CN, as built into backups.json),
// PRTS 多萝西 / 共振装置 (备注) and 分支特性信息 §陷阱师, and the client's battle data (charpack char_4048_doroth: SpawnTokens,
// KillTokens, the T2 mode switch; skills skchr_doroth_1–3 / sktok_doroth_1–3; equips doroth_equip_1_* / 2_3_p1; buff
// templates doroth_t_2, doroth_token[*], trapper_e_trait[*], doroth_e_003[advanced_spawn_token], charge_token[refresh],
// trigger_charge_token, die_to_kill_token).
// - Trait (陷阱师) "可以使用陷阱来协助作战,但陷阱无法放置于敌人已在的格子中": ranged physical arrows, 3-3, hits air units
// (分支特性信息 "可对空"; the data's canHitFly over the profession table), blocks 1, ground enemies target her. Her traps
// never (re)deploy on a tile a ground enemy stands on (分支特性信息: "适用飞行单位判定的敌人(如浮空、近地悬浮)不影响陷阱的部署")
// and leave when she leaves the field (分支特性信息 "干员离场后,附属的陷阱随之消失"; die_to_kill_token).
// - The traps in this mode: her 共振装置 are hand pieces the player places (data `placeable`; the prep hands out a stack —
// content/tokens.js, server/match/player/diy.js). Placed pieces deploy with the board, free and ignoring her stock (PRTS
// 卫戍协议/帮助 "所有手动部署的召唤物,无视所属干员的持有状态…作战开始时立即部署一次"); a used-up one stays its tile's piece and
// comes back there (PRTS "若召唤物在战斗期间退场,将在满足条件后立即原地再部署1个") once the deployment conditions hold: she is on
// the field, her stock holds a trap, the token card's redeploy time has run since the last trap deployment (data
// respawnTime 5 s: 备注 "…会让共振装置进入再部署CD"), its player has its cost in DP (3; TRP-X 2), fewer of her traps than
// the deploy limit stand (data deployLimit 10; TRP-X 13 — 备注 "陷阱部署上限与最多拥有数量相同") and no ground enemy is on
// the tile; one piece per check (uid order), paying the cost and spending one trap.
// - T1 共振装置 "可以使用8个共振装置(最多拥有10个),踩上去的第一个敌人会触发其效果,部署后立刻在攻击范围内召唤2个共振装置"
// (full potential: 10个; bb cnt, attack@max_cnt; TRP-X: 13 and stage 3: 3): her stock is refilled to cnt at each deployment
// (charge_token[refresh]) and holds at most the deploy limit; at each deployment attack@max_cnt traps appear on free melee
// tiles of her attack range with no ground enemy (the enemies' ground paths first, random among equals — [ASSUMED]: the client's selector
// filter 16 is not decoded), spending no trap but counting toward the limit (备注); the placed pieces off the field keep
// their slots (no summon takes one) [ASSUMED]. 阻回 while the stock is full (备注; `_stopSpWhenTokenIsFull`) — at full
// potential her deployment stock 10 fills the limit 10 (TRP-X 13 leaves room), so 阻回 until a trap is spent.
// - 共振装置 (its kit below): 不会受到攻击 (data: untargetable), blocks nothing, no normal attack. The first selectable ground
// enemy standing on its tile (sktok_doroth_* SkillTrigger: targetMotion ground, range 0-1; the earliest spawned of
// several) sets it off — the effect of the token skill of her pick (bySkill), with her current ATK (PRTS "技能伤害始终借用
// 持有者的攻击力计算"), from the trap (its source) —, then it withdraws (doroth_token[withdraw]; the blast fx carries
// `consumed`). S1 危险目标清除: that enemy takes atk_scale × ATK physical and DEF ×(1 + def) for `duration` s (formula
// FINAL_SCALER: the catalogue `defDown`). S2 流沙区域生成: every selectable ground enemy within 1.2 (备注 "陷阱爆炸范围为1.2半径
// 的圆") takes atk_scale × ATK physical and 束缚 `duration` s — `duration_2` s when it hit only one (cnt_2). S3
// 高速共振排障: every selectable ground enemy on the trap's x-6 takes atk_scale × ATK arts and 停顿 `sluggish` s, and every
// other trap of hers on that x-6 goes off `interval` s later (备注 "延迟触发时长为2s"; doroth_token_s3[trigger]: once — a
// trap already waiting keeps its time [ASSUMED]), with no enemy needed, and sets off its own x-6 the same way.
// - T2 梦想家 "陷阱触发后,多萝西获得2%的攻击力,最多叠加10层" (full potential: 12层; bb atk, max_stack_cnt; TRP-Y stage 3: 4%):
// every trap that goes off gives her one stack (ATK +atk, 直接乘算) until she leaves the field; with S1 / S2 before its
// damage, with S3 after it (备注). Full stacks switch her attack mode (doroth_t_2 SwitchMode: the Attack_2 clip — cosmetic here).
// - TRP-Y 童话书 "有20%概率部署造成2倍伤害的陷阱" (trait bb prob, atk_scale): each trap deployment rolls prob; such a trap deals
// ×atk_scale (trapper_e_trait[token_atk_scale] AtkScaleUp; the red "!" — fx 'mark'). Stage 3: T2 4 %.
// - TRP-X 梦中人: traps cost 1 less and the limit is 13 (the token's module stats), T1 "最多拥有13个"; stage 3: T1 summons 3 and
// "部署共振装置时有50%概率在攻击范围内额外召唤一个共振装置" (hidden talent prob, max_cnt, rangeGrid): on each deployment of a
// placed piece (备注 "仅手动部署的共振装置可触发模组3级效果" — the pieces the player placed [ASSUMED]: the start deployment and
// the redeploys) prob to summon max_cnt more on a tile as T1's, within the limit, spending no trap and ignoring the card's
// redeploy time but restarting it (备注); and when her T2 stacks fill (her attack mode switch) every placed piece standing
// rolls again (备注 "多萝西切换攻击模式时…为所有已手动部署的共振装置重新判定“概率额外召唤”").
// - S1 / S2 / S3 (AUTO, 19 / 16 SP, the data's DEFAULT): 被动效果 = the trap's effect above; 主动效果 "立即获得一个陷阱" (bb cnt):
// +cnt to her stock (trigger_charge_token). The cast acts on herself only, so it fires as soon as its SP is full
// (`trigger: 'SP_FULL'` — the owner's AUTO rule, kits/README.md checklist 5; skchr_doroth_* `_allowNoTarget`).
import { num, talentBb, moduleBb, traitBb, up, toggleBuff, summonTileFree } from '../shared/tier1.js';
import { absoluteRangeKeys, canTargetEnemy } from '../../../targeting.js';
import { bodyOnTile } from '../../../body.js';
import { COLS } from '../../../constants.js';
import { atPotential } from '../../../../../shared/potential.js';
const S1 = 'skchr_doroth_1';
const S2 = 'skchr_doroth_2';
const S3 = 'skchr_doroth_3';
/** 共振装置, her trap. */
export const RESONATOR = 'token_10025_doroth_recttp';
const TS1 = 'sktok_doroth_1';
const TS2 = 'sktok_doroth_2';
const TS3 = 'sktok_doroth_3';
/** S2's blast (PRTS S2 备注 "陷阱爆炸范围为1.2半径的圆"). */
const S2_RADIUS = 1.2;
/** S3's area when the token skill carries no grid: range x-6 (the cross of radius 2). */
const X6 = Object.freeze([[2, 0], [1, 0], [0, -2], [0, -1], [0, 0], [0, 1], [0, 2], [-1, 0], [-2, 0]]);
/** S3's delayed trigger when the blackboard carries none (PRTS 备注 "延迟触发时长为2s"). */
const S3_DELAY = 2;
/** How often a used-up piece checks its redeploy conditions. */
const WATCH = 0.25;
const GROUND = Object.freeze({ canHitFly: false, groundOnly: true });
const DREAM_KEY = 'talent:doroth:dream';
const tokenOf = (t) => t && t.kind === 'token' && t.defId === RESONATOR;
/** A ground enemy stands on tile (r, c) — a huge one on any tile of its body (body.js); air units never count. */
function groundEnemyOn(battle, r, c) {
for (const e of battle.enemies) {
if (!e.alive || e.hidden || e.isFlying) continue;
if (e.hitArea ? bodyOnTile(e, r, c) : Math.abs(e.x - c) <= 0.5 && Math.abs(e.y - r) <= 0.5) return true;
}
return false;
}
export default {
char_4048_doroth: (bb, chess) => {
const t0 = talentBb(chess, 0); // cnt, attack@max_cnt
const t1 = talentBb(chess, 1); // atk, max_stack_cnt
const tb = traitBb(chess); // TRP-Y: prob, atk_scale
const hidden = moduleBb(chess); // TRP-X stage 3: prob, max_cnt
const hiddenGrid = (chess?.talents ?? []).find((t) => t && t.index === -1 && t.rangeGrid)?.rangeGrid ?? null;
const stockRefill = Math.max(0, Math.floor(num(t0.cnt, 0)));
const onDeploy = Math.max(0, Math.floor(num(t0['attack@max_cnt'], 0)));
const dream = { atk: num(t1.atk), max: Math.max(0, Math.floor(num(t1.max_stack_cnt, 0))) };
const dbl = { prob: num(tb.prob), scale: num(tb.atk_scale, 1) };
const extra = { prob: num(hidden.prob), cnt: Math.max(0, Math.floor(num(hidden.max_cnt, 0))) };
// her trap stock, the card's ready time, whether her T2 has filled this deployment, the deploy limit (per unit: the
// factory runs per unit; also `unit.trait.doroth` for diagnostics and tests)
const D = { stock: 0, readyAt: -Infinity, full: false, limit: Infinity };
const traps = (battle, unit) => battle.allyUnits.filter((t) => tokenOf(t) && t.ownerUnit === unit);
const standing = (battle, unit) => traps(battle, unit).filter((t) => t.alive).length;
/** Placed pieces still to come (not on the field, not gone): the T1 summons leave their slots free. */
const waiting = (battle, unit) => traps(battle, unit).filter((t) => t.uid != null && !t.alive && !t.removed).length;
/** The token's deploy limit for her loadout (variant stats, the module's when it carries one); her max stock too. */
function limitOf(battle, unit) {
const raw = battle.data.rawToken?.(RESONATOR);
const v = atPotential(raw?.variants?.[unit.def?.tokenOwner], unit.def?.loadout?.potential);
const mod = unit.def?.loadout?.moduleId;
const n = num(v?.byModule?.[mod]?.stats?.deployLimit, num(v?.stats?.deployLimit, num(raw?.deployLimit, 0)));
if (n > 0) return n;
const m = String(chess?.talents?.find((t) => t && t.index === 0)?.desc ?? '').match(/最多拥有(\d+)个/);
return m ? +m[1] : Infinity;
}
/** Free melee tiles of her range (or of `grid` around her) with no ground enemy: enemy paths first, then random. */
function summonTiles(battle, unit, grid = null) {
const keys = grid ? absoluteRangeKeys(grid, unit.tileR, unit.tileC, unit.dir, unit.s.rangeExtend || 0) : unit.rangeKeys || [];
const path = battle.groundPathTiles();
const out = [];
for (const k of keys) {
const r = Math.floor(k / COLS), c = k % COLS;
if (!summonTileFree(battle, r, c) || !battle.grid.canStand(r, c) || groundEnemyOn(battle, r, c)) continue;
out.push({ r, c, p: path.has(k) ? 0 : 1 });
}
battle.rng.shuffle(out);
return out.sort((a, b) => a.p - b.p);
}
/** Summon up to `n` traps (no stock spent) on summonTiles, within the limit (`room` more at most). */
function summon(battle, unit, n, grid = null, room = Infinity) {
let left = Math.min(n, room, D.limit - standing(battle, unit));
if (!(left > 0)) return 0;
let made = 0;
for (const { r, c } of summonTiles(battle, unit, grid)) {
if (left <= 0) break;
const t = battle.spawnToken(unit, RESONATOR, r, c, { kit: trapKit(unit) });
if (!t) continue;
battle.fx('summon', { x: t.x, y: t.y, id: t.id, token: RESONATOR });
left--; made++;
}
return made;
}
/** TRP-X stage 3 on a placed piece's deployment (and her mode switch): prob to summon max_cnt more. */
function rollExtra(battle, unit) {
if (!(extra.prob > 0) || !(extra.cnt > 0) || !up(unit) || !battle.rng.chance(extra.prob)) return;
summon(battle, unit, extra.cnt, hiddenGrid, D.limit - standing(battle, unit) - waiting(battle, unit));
}
/** 梦想家: one stack (to max_stack_cnt); full stacks are her attack mode switch (TRP-X stage 3 re-rolls). */
function dreamStack(battle, unit) {
if (!up(unit) || !(dream.atk > 0) || !(dream.max > 0)) return;
const b = unit.findBuff(DREAM_KEY);
const n = b ? b.stacks : 0;
if (n >= dream.max) return;
battle.addBuff(unit, { key: DREAM_KEY, refresh: 'stack', stacks: 1, maxStacks: dream.max, mods: { atkPct: dream.atk }, tags: ['talent'] });
if (n + 1 >= dream.max && !D.full) {
D.full = true;
for (const t of traps(battle, unit)) if (t.alive && t.uid != null) rollExtra(battle, unit);
}
}
/** The trap `t` goes off (`e` = the enemy that stepped on it; null for a delayed S3 trigger). */
function fire(battle, unit, t, e) {
if (!t.alive || t.mem.dorFired) return;
t.mem.dorFired = true;
const sk = t.def?.skill ?? null;
const tb2 = sk?.bb ?? {};
const id = sk?.id ?? TS1;
const s3 = id === TS3;
if (!s3) dreamStack(battle, unit); // 备注: with S1 / S2 before the damage
const atk = (unit.s ? unit.s.atk : t.s.atk) * (t.mem.dorDouble ? dbl.scale : 1);
const tags = ['summon', 'trap', 'doroth:trap'];
if (id === TS2) {
const victims = battle.foesInRadius(t.x, t.y, S2_RADIUS, true).filter((x) => canTargetEnemy(t, x, GROUND));
const dur = victims.length === Math.max(1, Math.floor(num(tb2.cnt_2, 1))) ? num(tb2.duration_2, num(tb2.duration)) : num(tb2.duration);
for (const x of victims) {
battle.dealDamage(t, x, { amount: atk * num(tb2.atk_scale), type: 'phys', isSkill: true, tags });
if (x.alive && dur > 0) battle.applyStatus(x, 'bind', { duration: dur, source: t });
}
battle.fx('explode', { x: t.x, y: t.y, r: S2_RADIUS, id: t.id, consumed: true, kind: 'doroth:quicksand' });
} else if (s3) {
const keys = absoluteRangeKeys(sk?.rangeGrid ?? X6, t.tileR, t.tileC, t.dir, 0);
for (const x of battle.enemiesInKeys(keys, t, GROUND)) {
battle.dealDamage(t, x, { amount: atk * num(tb2.atk_scale), type: 'arts', isSkill: true, tags });
if (x.alive && num(tb2.sluggish) > 0) battle.applyStatus(x, 'sluggish', { duration: num(tb2.sluggish), source: t });
}
// 并延迟触发范围内的其他共振装置
const set = new Set(keys);
const delay = num(tb2.interval, S3_DELAY);
for (const o of traps(battle, unit)) {
if (o === t || !o.alive || o.mem.dorFired || o.mem.dorWaiting || !set.has(o.tileR * COLS + o.tileC)) continue;
o.mem.dorWaiting = true;
const seq = o.deploySeq;
battle.after(delay, () => { if (o.alive && o.deploySeq === seq) fire(battle, unit, o, null); }, { owner: o });
}
battle.fx('explode', { x: t.x, y: t.y, r: 2, id: t.id, consumed: true, dmgType: 'arts', kind: 'doroth:resonance' });
dreamStack(battle, unit); // 备注: with S3 after the damage
} else {
if (e && e.alive) {
battle.dealDamage(t, e, { amount: atk * num(tb2.atk_scale), type: 'phys', isSkill: true, tags });
if (e.alive && num(tb2.duration) > 0 && num(tb2.def) < 0) battle.applyStatus(e, 'defDown', { duration: num(tb2.duration), value: -num(tb2.def), source: t });
}
battle.fx('explode', { x: t.x, y: t.y, r: 0.6, id: t.id, consumed: true, kind: 'doroth:clear' });
}
battle.retreat(t, { reason: 'expired', permanent: true });
}
/** 共振装置's kit (`unit` = 多萝西): its rolls at each deployment, the trigger, a placed piece kept for its redeploys. */
function trapKit(unit) {
return {
skill: null,
trait: { noAttack: true },
install(battle, t) {
battle.on('deploy', (ctx) => {
if (ctx.unit !== t) return;
t.mem.dorFired = false;
t.mem.dorWaiting = false;
t.mem.dorDouble = dbl.prob > 0 && dbl.scale !== 1 && battle.rng.chance(dbl.prob);
if (t.mem.dorDouble) battle.fx('mark', { x: t.x, y: t.y, id: t.id, kind: 'doroth:double' });
D.readyAt = battle.time + Math.max(0, num(t.base.respawnTime, 0));
if (t.uid != null) rollExtra(battle, unit);
}, { owner: t });
battle.on('tick', () => {
if (!t.alive || t.mem.dorFired) return;
let hit = null;
for (const e of battle.enemies) {
if (!canTargetEnemy(t, e, GROUND)) continue;
const on = e.hitArea ? bodyOnTile(e, t.tileR, t.tileC) : Math.abs(e.x - t.tileC) <= 0.5 && Math.abs(e.y - t.tileR) <= 0.5;
if (on && (!hit || e.spawnSeq < hit.spawnSeq)) hit = e;
}
if (hit) fire(battle, unit, t, hit);
}, { owner: t });
// a placed piece stays its tile's piece (redeployed by her kit's watch), whatever took it off the field
if (t.uid != null) battle.on('death', (ctx) => { if (ctx.unit === t && !battle.finished) t.removed = false; }, { owner: t, priority: -10 });
},
};
}
const skillSpec = () => ({
kind: 'instant', trigger: 'SP_FULL',
onStart({ skill }) { // 主动效果:立即获得一个陷阱
D.stock = Math.min(D.limit, D.stock + Math.max(1, Math.floor(num(skill.bb?.cnt, 1))));
},
});
return {
skills: { [S1]: skillSpec(), [S2]: skillSpec(), [S3]: skillSpec() },
talents: [
{ install(battle, unit) { // 共振装置: the pieces' kit, the stock, the summons at each deployment, the redeploys
D.limit = limitOf(battle, unit);
unit.trait.doroth = D;
for (const t of battle.allyUnits) {
if (!tokenOf(t) || t.ownerUnit !== unit || t.alive || t.deployed) continue;
if (t.kit) battle.offOwner(t); // a piece set up before her: drop its generic kit's hooks
battle._setupUnit(t, trapKit(unit));
}
battle.on('deploy', (ctx) => {
if (ctx.unit !== unit || ctx.move) return;
D.stock = Math.min(D.limit, stockRefill); // charge_token[refresh]
D.full = false;
if (onDeploy > 0) summon(battle, unit, onDeploy, null, D.limit - standing(battle, unit) - waiting(battle, unit));
}, { owner: unit });
// her traps leave with her (die_to_kill_token); the placed pieces wait for her return
battle.on('death', (ctx) => {
if (ctx.unit !== unit) return;
for (const t of traps(battle, unit)) if (t.alive) battle.retreat(t, { reason: 'expired', permanent: true });
}, { owner: unit });
// a used-up / withdrawn piece comes back on its tile once the deployment conditions hold (see the header)
battle.every(WATCH, () => {
if (!up(unit) || D.stock < 1 || battle.time + 1e-9 < D.readyAt || standing(battle, unit) >= D.limit) return;
const pieces = traps(battle, unit).filter((t) => t.uid != null && !t.alive && !t.removed).sort((a, b) => a.uid - b.uid);
for (const p of pieces) {
if (groundEnemyOn(battle, p.homeR, p.homeC)) continue;
if (battle.redeploy(p, { free: false })) { D.stock--; break; }
}
}, { owner: unit });
// 阻回 while her stock is full (_stopSpWhenTokenIsFull)
toggleBuff(battle, unit, 'talent:doroth:full', () => D.stock >= D.limit, {}, { flags: { noSp: true } });
} },
{ install() {} }, // 梦想家: the stacks come with every trap that goes off (fire → dreamStack)
],
};
},
};