Files
ark-stronghold-protocol/server/sim/content/kits/ops/op-weedy.js
T
sganggs 806a047717 feedback6 R21P: every operator fights at full potential (潜能 6) — the owner's decision of 2026-10-07 (GitHub #252, PR #255; no host slider): tools/build-data.mjs OPERATOR_POTENTIAL adds every character_table potentialRanks attribute step (部署费用 / 再部署时间 / ATK / HP / DEF / RES / ASPD, all ADDITION, before rounding) to every chess (normal and elite), 补位 and 自选 unit form and picks talent / trait / module-talent candidates up to requiredPotentialRank 5 (「天赋效果增强」); a summon picks its talent candidates at its owner's potential (their requiredPotentialRank mirrors the owner's — PRTS 凯尔希: Mon3tr 3.5 s / 1400) and takes none of the owner's attribute steps; the 原型干员 stand-ins have no potential ranks (unchanged). 刺玫 413 / 17 → 435 / 15 as in the tournament video, 砾 18 → 16 s, 至简 80 → 70 s. data/chess.json (256 records), backups.json (the 71 owned picks, 8 summons), tokens.json (5 summons) and the data translations en / ja / ko / zh-TW rebuilt — only potential-driven fields (JSON-compared); the 干员调配 局内数值 and detail cards read the records. op-wang.js reads her stock cap 「最多拥有8(+1)枚」 through the potential step (the pattern fell back to 7: she sat at the cap from the start). Docs: DATA §2 / §18 / tokens, PLAYING (detail card), research 03, kits README, shared/diy.js / standIn.js, the 71 自选 kit headers and the kit comments that quoted potential-0 numbers. Tests: test/full-potential.test.js (new, fails on the parent); data.test.js re-derivation adds the potential steps (+ respawnTime); every test that hard-coded a potential-0 number carries the full-potential one (from the data and the test's own formula; no check loosened); golden: moves almost everywhere — roster 49/49, bonds 46/46, fields 22/22, matches 18/18, diy 133/138 (each battle parts on a stat: roster-001 / roster-020 at tick 1 on 角峰's HP (生命上限+250), bond-arcaneShip-high at 4.73 s and diy-001 at 3.70 s on an enemy's HP from a higher ATK; diy-110 / 111 also by 望's cap; the matches through those battles); unchanged: standins 10/10 and diy-135…138 (no potential ranks), diy-012 (twelve 夜莺 never hit, never redeployed)
2026-10-07 18:35:39 +08:00

302 lines
19 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-weedy.js — 温蒂 (char_400_weedy) 自选 operator kit: 6★ 推击手 (特种), an owned-6★ pick of the
// tier-5 and tier-6 自选 slots; every skill, both talents, the trait, both modules (PUS-X 仿生海龙改型, PUS-Y 新型仿生原型) at
// every form, and the kit of her summon 工程蓄水炮 (token_10009_weedy_cannon). Kit contract and the 自选 rules: ../README.md
// ("How to add an operator (自选)").
//
// Forms (data/backups.json units.char_400_weedy, the DIY slot statuses): 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). 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; the cannon's
// owner-form / module variants) and PRTS 温蒂 (蓄水炮强化 备注 "天赋文本后半段效果(及模组效果)的作用对象为温蒂自身"; S2 "攻击间隔增大
// (+220%)", 备注 "溅射范围为0.9半径"; S3 备注 "蓄水炮以温蒂的攻击力进行发射…技能范围内没有敌人时温蒂/蓄水炮不会对应发射出液氮炮 /
// 自身优先攻击剩余路径最短的敌人,水炮优先攻击距离自身最近的敌人;液氮炮溅射范围半径1.2,均可对空 / “正比于距离的真实伤害”即…
// 移动创伤(1200伤害/格),再次施加…时仅刷新BUFF的持续时间,不改变真实伤害的来源与每格伤害比例"), PRTS 工程蓄水炮 (备注 "持有禁疗 /
// 蓄水炮的技能和攻击均优先选择离自身最近的敌人作为目标 / 普通攻击…该位移效果仅对前方三格内的地面敌方单位生效 / 部署后20秒自动撤退";
// 技能备注 "蓄水炮进行的发射除基本力度外均以本体的数值为准"), PRTS 特殊机制 移动创伤 (every 0.066 s and once at the end: (distance
// moved since the last update) × 每格伤害 预计算的真实持续伤害, nothing when the difference is > 4 tiles), PRTS 卫戍协议/帮助 (a
// placed summon deploys with the board; "若召唤物在战斗期间退场,将在满足条件后立即原地再部署1个"; a summon's manual skill is
// force-opened).
// - Trait (推击手) "同时攻击阻挡的所有敌人 / 可以放置于远程位": the profession default (pusher `hitAllBlocked`, block 2; melee,
// ground-only — PRTS 分支特性信息 推击手) and the placement rule of the prep (shared/highGround.js: her trait line lets the
// match put her on a 高台, where she attacks and blocks nothing). Module PUS-X "可以放置于远程位,并返还该次部署费用的一半"
// (trait bb value): a redeployment on a ranged (高台) tile gives value × her cost back (as 见行者's PUS-X; the battle-start
// deployment costs nothing). Module PUS-Y "阻挡2个及以上敌人时自身推力增加一级" (trait bb cnt / base_force_level): her
// pushes +base_force_level while she blocks ≥ cnt enemies.
// - T1 工程蓄水炮 (the cannon, a hand piece the player places — "部署位置=全部位"): deploys with the board, leaves 20 s after
// each deployment (its talent `duration`) and comes back on its tile after its redeploy time (35 s; PUS-Y stage 3 "再部署
// 时间减少8秒": the module variant's 27 s), paying its 5 DP, while she is on the field [ASSUMED, as 鸿雪's 打字机]. The
// cannon: untargetable ("不会受到攻击"), 禁疗, blocks nothing, 3-2 range, ranged physical, hits air, the nearest enemy to
// itself first; each attack pushes its target 小力 + 蓄水炮强化's base_force_level along its facing — a ground enemy on the
// three tiles ahead only. Its skill (the copy of S3 the data gives, sktok_weedy_token, MANUAL, 0 SP) is force-opened by the
// 卫戍 strategy: free, once per deployment, by the data's DEFAULT (cast on its attack) — and it fires with every S3 of hers
// while it stands on one of the 4 tiles around her. It fires with her numbers (her ATK, her S3 level; the damage counts
// as hers [ASSUMED: "均以本体的数值为准"]) but its own force (+1 by 蓄水炮强化).
// - T2 蓄水炮强化 (the cannon's talent, x-5): while the cannon stands on one of the 4 tiles around her she gains `sp` SP every
// `interval` s of it (3 s; PUS-X stage 2+: 2.5 / 2 s — the module's token variant) [ASSUMED: the clock runs while it is
// there] and, PUS-X stage 2+, ATK +atk (the module's hidden token talent, 15 % / 20 %; PRTS 备注: on 温蒂 herself).
// - S1 炮管敲击 (AUTO, data DEFAULT — a "next attack"): that attack (every enemy she blocks) at atk_scale × ATK, each target
// pushed along her facing with `force` (中力) and 晕眩 `stun` s.
// - S2 水炮模式 (AUTO, 持续时间无限; SP_FULL — a mode switch on herself, the owner's AUTO rule): attack interval ×(1 +
// base_attack_time) (PRTS lists it as a ratio, "+220%"), ATK +atk, range +ability_range_forward_extend, a ranged attack
// — one shot with a 0.9-tile splash (PRTS 备注), air units too [ASSUMED: a ranged attack reaches air] — every enemy it hits
// pushed with base_force_level (小力) along her facing.
// - S3 液氮大炮 (MANUAL, data SKILL_RANGE on its 4-1): at once, the enemy of the 4-1 with the shortest remaining path and
// every enemy within 1.2 of it (air units too) take atk_scale × ATK arts, the 移动创伤 (`value` × `dist` per tile moved,
// true damage, `duration` s) and a push of `force` along her facing; the cannon fires too (above). The shot is instant
// [ASSUMED: no travel time]. Pushes go along her facing (knockback[dir], as every 推击手 push) [ASSUMED for S2 / S3,
// whose text gives no direction].
import { num, traitBb, skillRec, up, giveSp } from '../shared/tier1.js';
import { absoluteRangeKeys } from '../../../targeting.js';
import { COLS } from '../../../constants.js';
import { startCountdown } from '../../tokens.js';
const S1 = 'skchr_weedy_1';
const S2 = 'skchr_weedy_2';
const S3 = 'skchr_weedy_3';
export const CANNON = 'token_10009_weedy_cannon';
/** S3's / the cannon skill's 4-1 when the data carries no grid. */
const GRID_4_1 = Object.freeze([[0, 0], [0, 1], [0, 2], [0, 3], [0, 4]]);
/** PRTS S3 备注 "液氮炮溅射范围半径1.2". */
export const NITRO_SPLASH = 1.2;
/** PRTS S2 备注 "溅射范围为0.9半径". */
export const WATER_SPLASH = 0.9;
/** The 移动创伤 buff on an enemy (one per enemy whoever applies it — PRTS: a new one only refreshes its duration). */
export const RUPTURE_KEY = 'weedy:rupture';
/** PRTS 特殊机制 移动创伤: an update whose distance difference is above this deals nothing. */
const RUPTURE_MAX_STEP = 4;
/** 工程蓄水炮's life when the token carries no `duration` talent (E2: 20 s). */
const LIFE_FALLBACK = 20;
/** Seconds between two tries of a cannon to come back (tile busy, DP short, 温蒂 off the field). */
const RETRY = 0.25;
const TAG_NITRO = 'weedy:nitro';
const bbOf = (chess, id) => skillRec(chess, id)?.bb ?? {};
/** First talent of a normalised token def whose blackboard holds `key` (token talents keep no index). */
const tokTalent = (def, key, not = null) => (def?.talents ?? []).find((t) => t && t.bb && t.bb[key] != null && (not == null || t.bb[not] == null)) ?? null;
/** Push direction along a unit's facing (knockback[dir]). */
const along = (u) => ({ x: u.fwd[1], y: u.fwd[0] });
/** On one of the 4 tiles around (周围4格). */
const beside = (a, b) => Math.abs(a.tileR - b.tileR) + Math.abs(a.tileC - b.tileC) === 1;
/**
* 移动创伤 (PRTS 特殊机制) on enemy `e`: for `duration` s every `interval` s — and once more at the end — the distance it
* moved since the last update × `perTile` as true 持续伤害 from `src` (none when that difference is > 4 tiles). A new
* application while one runs only refreshes its duration: the source and the per-tile damage stay the first one's.
*/
export function rupture(battle, src, e, { duration, perTile, interval }) {
if (!e || !e.alive || !(duration > 0) || !(perTile > 0)) return;
const cur = e.findBuff(RUPTURE_KEY);
if (cur) {
cur.timeLeft = Math.max(cur.timeLeft, duration);
cur.duration = Math.max(cur.duration, duration);
return;
}
const st = { x: e.x, y: e.y, moved: 0, acc: 0 };
const step = (u) => { st.moved += Math.hypot(u.x - st.x, u.y - st.y); st.x = u.x; st.y = u.y; };
const flush = (u) => {
const m = st.moved;
st.moved = 0;
if (!(m > 1e-9) || m > RUPTURE_MAX_STEP || !u.alive) return;
battle.dealDamage(src, u, { amount: m * perTile, type: 'true', canDodge: false, isSkill: true, tags: ['skill', 'dot', RUPTURE_KEY] });
};
const iv = interval > 0 ? interval : 0.066;
battle.addBuff(e, {
key: RUPTURE_KEY, duration, source: src, tags: ['skill'], data: st,
onTick: ({ unit, dt }) => {
step(unit);
st.acc += dt;
if (st.acc + 1e-9 >= iv) { st.acc -= iv; flush(unit); }
},
onExpire: ({ unit }) => { step(unit); flush(unit); },
});
}
/**
* One 液氮炮 of `shooter` (温蒂 or her cannon): the target of the skill range (`grid`, the shooter's facing) — `pick`
* 'path' = the shortest remaining path (温蒂), 'nearest' = nearest to the shooter (the cannon) — and every enemy within
* NITRO_SPLASH of it: atk_scale × 温蒂's ATK arts from `src`, the 移动创伤, a push of `force` along the shooter's facing.
* Returns false when no enemy can be selected there (nothing fires).
*/
export function liquidNitrogen(battle, { shooter, src, atk, sbb, grid, force, pick }) {
const keys = absoluteRangeKeys(grid ?? GRID_4_1, shooter.tileR, shooter.tileC, shooter.dir, 0);
const cands = battle.enemiesInKeys(keys, shooter, { canHitFly: true });
if (!cands.length) return false;
const key = pick === 'nearest' ? (e) => Math.hypot(e.x - shooter.x, e.y - shooter.y) : (e) => battle.remainingDistance(e);
let target = null, best = Infinity;
for (const e of cands) {
const v = key(e);
if (v < best - 1e-9 || (Math.abs(v - best) <= 1e-9 && target && e.spawnSeq < target.spawnSeq)) { best = v; target = e; }
}
const x = target.x, y = target.y;
battle.fx('frostNova', { x, y, r: NITRO_SPLASH, id: shooter.id, src: shooter.id, target: target.id, dmgType: 'arts', skill: 'weedy_3' });
const amount = atk * num(sbb.atk_scale, 1);
const rup = { duration: num(sbb.duration), perTile: num(sbb.value) * num(sbb.dist, 1), interval: num(sbb.interval, 0.066) };
const dir = along(shooter);
for (const e of battle.foesInRadius(x, y, NITRO_SPLASH, true)) {
if (!e.alive) continue;
battle.dealDamage(src, e, { amount, type: 'arts', isSkill: true, tags: ['skill', TAG_NITRO] });
if (!e.alive) continue;
rupture(battle, src, e, rup); // before the push: the push's displacement counts
battle.push(e, force, { from: shooter, dir });
}
return true;
}
/** 温蒂's deployed cannons. */
const cannonsOf = (battle, unit) => battle.allyUnits.filter((a) => a.kind === 'token' && a.defId === CANNON && a.ownerUnit === unit && a.alive && a.deployed);
/** The cannon's kit: its attack push, its skill, 蓄水炮强化 on 温蒂, its life and its redeploys. `owner` = 温蒂. */
function cannonKit(t, owner) {
const def = t.def;
const life = num(tokTalent(def, 'duration')?.bb?.duration, LIFE_FALLBACK);
const boost = tokTalent(def, 'base_force_level')?.bb ?? {}; // 蓄水炮强化: base_force_level, interval, sp
const forceUp = num(boost.base_force_level);
const every = num(boost.interval), sp = num(boost.sp);
const atkUp = num(tokTalent(def, 'atk', 'base_force_level')?.bb?.atk); // PUS-X stage 2+: 温蒂 ATK +atk beside it
const sk = def.skill ?? null;
const sbb = sk?.bb ?? {};
const AHEAD = [[0, 1], [0, 2], [0, 3]];
return {
skill: sk ? {
kind: 'instant',
onStart({ battle, unit }) {
liquidNitrogen(battle, { shooter: unit, src: owner, atk: owner.s.atk, sbb, grid: sk.rangeGrid ?? GRID_4_1, force: num(sbb.force) + forceUp, pick: 'nearest' });
},
} : null,
trait: {
priority: 'nearest',
// "能攻击单个敌人并将其小力度地推开,该位移效果仅对前方三格内的地面敌方单位生效"
afterHit(battle, unit, target) {
if (!target || !target.alive || target.side !== 'enemy' || target.isFlying) return;
const ahead = new Set(absoluteRangeKeys(AHEAD, unit.tileR, unit.tileC, unit.dir, 0));
if (!ahead.has(Math.round(target.y) * COLS + Math.round(target.x))) return;
battle.push(target, forceUp, { from: unit, dir: along(unit) });
},
},
install(battle, unit) {
battle.addBuff(unit, { key: 'trait:weedyCannon', flags: { noHeal: true }, persist: true, allowDead: true }); // 持有禁疗
// 部署后20秒自动撤退 — a countdown summon (content/tokens.js startCountdown): 无敌 [ASSUMED], 禁疗, its bar = the life left
battle.on('deploy', (ctx) => {
if (ctx.unit !== unit || !(life > 0)) return;
startCountdown(battle, unit, life);
const seq = unit.deploySeq;
battle.after(life, () => { if (unit.alive && unit.deploySeq === seq) battle.retreat(unit, { reason: 'expired', permanent: true }); }, { owner: unit });
}, { owner: unit });
// back on its tile after its redeploy time, paying its cost, while 温蒂 is on the field (the piece is kept)
battle.on('death', (ctx) => {
if (ctx.unit !== unit || battle.finished) return;
unit.removed = false;
const at = battle.time + Math.max(0, num(unit.base.respawnTime));
battle.every(RETRY, (b, sched) => {
if (unit.alive || unit.removed) { sched.cancel(); return; }
if (b.time + 1e-9 < at || !up(owner)) return;
if (b.redeploy(unit, { free: false })) sched.cancel();
}, { owner: unit });
}, { owner: unit, priority: -10 });
// 蓄水炮强化 on 温蒂: +sp SP every `interval` s beside her; PUS-X stage 2+: her ATK +atk meanwhile
let acc = 0;
const atkKey = `talent:weedy:cannonAtk:${unit.id}`;
battle.on('tick', ({ dt }) => {
const on = up(unit) && up(owner) && beside(unit, owner);
if (!on) {
acc = 0;
if (atkUp && owner.findBuff(atkKey)) battle.removeBuff(owner, atkKey);
return;
}
if (atkUp && !owner.findBuff(atkKey)) battle.addBuff(owner, { key: atkKey, mods: { atkPct: atkUp }, source: unit, tags: ['talent'] });
if (!(every > 0) || !(sp > 0)) return;
acc += dt;
while (acc + 1e-9 >= every) { acc -= every; giveSp(owner, sp); }
}, { owner: unit });
},
};
}
export default {
char_400_weedy: (bb, chess) => {
const tb = traitBb(chess); // PUS-X: value; PUS-Y: cnt / base_force_level
const b1 = bbOf(chess, S1), b2 = bbOf(chess, S2), b3 = bbOf(chess, S3);
const s3 = skillRec(chess, S3);
const blockCnt = num(tb.cnt), blockForce = num(tb.base_force_level);
/**
* PUS-Y: +base_force_level while she blocks ≥ cnt enemies — counted when the attack starts (`weedyBlocked`, set at
* `beforeAttack`: the first push of an attack frees an enemy before the second is pushed) or when S3 is cast.
*/
const bonus = (u, n = u.mem.weedyBlocked ?? u.blocking.length) => (blockForce && blockCnt > 0 && n >= blockCnt ? blockForce : 0);
return {
skills: {
[S1]: {
kind: 'instant',
attack: {
atkScale: num(b1.atk_scale, 1),
onHit({ battle, unit, target }) {
if (!target || !target.alive || target.side !== 'enemy') return;
battle.applyStatus(target, 'stun', { duration: num(b1.stun), source: unit });
battle.push(target, num(b1.force) + bonus(unit), { from: unit, dir: along(unit) });
},
},
},
[S2]: {
kind: 'toggle',
trigger: { rule: 'SP_FULL' },
mods: { atkPct: num(b2.atk), batPct: num(b2.base_attack_time) },
targeting: { rangeExtend: num(b2.ability_range_forward_extend) },
attack: {
attack: 'ranged', projectile: 'bomb', splashRadius: WATER_SPLASH, canHitFly: true, hitAllBlocked: false, maxTargets: 1,
// every enemy the shot hits is pushed once the hit is resolved (a push of the main target inside the hit would
// move the splash centre: ai.js resolveHit centres it on the target after the main hit's callbacks)
onEachHit({ unit, target }) {
if (target && target.side === 'enemy') (unit.mem.weedyWet ??= []).push(target);
},
onHit({ battle, unit }) {
const list = unit.mem.weedyWet ?? [];
unit.mem.weedyWet = [];
const f = num(b2.base_force_level) + bonus(unit);
for (const e of list) if (e.alive) battle.push(e, f, { from: unit, dir: along(unit) });
},
},
},
[S3]: {
kind: 'instant',
onStart({ battle, unit }) {
liquidNitrogen(battle, { shooter: unit, src: unit, atk: unit.s.atk, sbb: b3, grid: s3?.rangeGrid ?? GRID_4_1, force: num(b3.force) + bonus(unit, unit.blocking.length), pick: 'path' });
// "如果蓄水炮在周围4格内的话也会同样进行发射" (its own skill numbers and force, her ATK)
for (const c of cannonsOf(battle, unit)) {
if (!beside(c, unit)) continue;
const cs = c.def.skill ?? null;
const cbb = cs?.bb ?? b3;
const up1 = num(tokTalent(c.def, 'base_force_level')?.bb?.base_force_level);
liquidNitrogen(battle, { shooter: c, src: unit, atk: unit.s.atk, sbb: cbb, grid: cs?.rangeGrid ?? GRID_4_1, force: num(cbb.force) + up1, pick: 'nearest' });
}
},
},
},
talents: [
// 工程蓄水炮 (and 蓄水炮强化, the cannon's own talent): her cannon pieces run the cannon's kit — set up here, before the
// battle starts (Battle._setupUnit with a kit, as op-bgsnow.js does for its 打字机)
{ install(battle, unit) {
for (const t of battle.allyUnits) {
if (t.kind !== 'token' || t.defId !== CANNON || t.ownerUnit !== unit || t.alive || t.deployed) continue;
if (t.kit) battle.offOwner(t);
battle._setupUnit(t, cannonKit(t, unit));
}
} },
{ install() {} }, // 蓄水炮强化 — the cannon's talent (cannonKit)
],
install(battle, unit) {
if (blockForce) battle.on('beforeAttack', (ctx) => { if (ctx.attacker === unit) unit.mem.weedyBlocked = unit.blocking.length; }, { owner: unit, priority: 100 });
// PUS-X: "可以放置于远程位,并返还该次部署费用的一半" — a redeployment on a ranged tile (unit.ground false)
const back = num(tb.value);
if (back > 0) {
battle.on('deploy', (ctx) => {
if (ctx.unit !== unit || ctx.initial || unit.ground) return;
const n = unit.base.cost * back;
battle.addDp(unit.ownerId, n);
battle.fx('dp', { x: unit.x, y: unit.y, n, id: unit.id });
}, { owner: unit });
}
},
};
},
};