feedback5 O19: 自选 kits for 老鲤, 温蒂 (with her 工程蓄水炮) and 望 (with her 棋子 and 跟子)

Three owned 6★ picks get their operator kits (kits/ops/op-lmlee.js, op-weedy.js, op-wang.js, appended to
OPERATOR_KIT_FILES): every skill at rank 4 / 7, both talents, the trait and every module at stage 1 / 3, and the kits of
the two summons (set up by the owner's talent, as 鸿雪's 打字机).

- 老鲤 (行商): MER-X 2 DP, MER-Y ATK +4 % per payment (5 stacks); 和气生财 (±14, ×2 alone; MER-Y Lv3 ±20); 有备无患 through
  the merchantPay hook (5 DP for a guard against the next 晕眩 / 冻结, counter-stun 3 s; MER-X Lv3 4 DP / 4 s); S1 / S3 fire
  at full SP (the owner's AUTO rule), S2's mark bursts per its PRTS notes, S3's push and outside-range dodge.
- 温蒂 (推击手): S1 next attack (push + stun), S2 water mode (×3.2 interval, range +1, 0.9 splash, pushes), S3 liquid
  nitrogen (1.2 splash, push, 移动创伤 1200 per tile); the cannon (20 s, back on its tile, nearest target, pushes, its
  forced skill, fires with her S3 beside her); 蓄水炮强化 SP (PUS-X Lv3 every 2 s and ATK +20 %); PUS-X refund on a 高台,
  PUS-Y +1 force while blocking two; her composed record takes a 高台 (shared/highGround.js unchanged).
- 望 (陷阱师): hits air; 棋子 activation, trigger, in-place return (2 s, cost; TRP-X 2 DP), 跟子 in the PRTS order, 料敌机先
  stacks, S1 DoT + 停顿, S2 line damage + additive slow, S3 x-6 passive and the 天下劫 overflow / bullets.

Match: the 自选 hand count of a placeable summon reads the active module's byModule deploy limit (server/match/player/
diy.js placeableTokens) — 望 with TRP-X gets 7 棋子; nobody else changes (多萝西's 13 stays at the 9 cap).
Docs: PLAYING.md §4 (温蒂 on a 高台), SIM.md §8 (陷阱师 air through the data), kits/README.md (module deploy limit).

Golden: only diy moved — diy-106 … diy-109 are now the 老鲤 / 温蒂 battles (the prototype battles they held move on),
diy-110 … diy-115 are new (望, then the prototypes, seeds per index); roster, bonds, fields, matches, standins unchanged.
Tests: test/content/op_lmlee.test.js (8), op_weedy.test.js (10), op_wang.test.js (8).
This commit is contained in:
sganggs committed 2026-10-06 05:09:27 +08:00
1 parent 996f5d20fe
commit 8627791e53
12 files changed
+2293 -101

No files matched your search

+299
View File
@@ -0,0 +1,299 @@
// 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). Potential 0 [ASSUMED: no account].
// 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';
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秒自动撤退
battle.on('deploy', (ctx) => {
if (ctx.unit !== unit || !(life > 0)) return;
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 });
}
},
};
},
};