Files
ark-stronghold-protocol/server/sim/content/kits/ops/chess_char_3_04-swire2.js
T
sganggs e733171b13 feedback7 R22-OSIM: 香槟炸弹 no longer goes off on an unrevealed 隐匿 enemy (nor a sleeping one) — it selects like the other contact traps; golden: bond-yanShip-high, coop2-HARD-4 move
Community report relayed by the owner: 「有人反馈香槟炸到没有破隐的隐匿敌人了」. Reproduced: a 隐匿 walker crossing
琳琅诗怀雅's bombs set them off (both kits checked hidden / flying / untargetable only). The rule: PRTS 作战机制 §隐匿
"隐匿效果使得获得该效果的单位无法被任何敌方的能力索敌选中…要选中隐匿的目标,则该单位自身需拥有无视隐匿的效果"; 异常效果 lists
隐匿 and 无敌 among the 无法选择 effects (沉睡 = "无敌且无法行动", gamedata_const ba.sleep). Neither the 香槟炸弹 page nor
琳琅诗怀雅's S2 carries a 无视隐匿 / 无视可选性 note, and the other contact traps of the sim (多萝西's 共振装置, 望's 棋子)
already select through canTargetEnemy.

Now both kits go off on a ground enemy canTargetEnemy accepts (tokens.js CHAMPAGNE_TRIGGER): a 隐匿 enemy sets one off
once it is revealed (反隐) or blocked (its 隐匿 lifts), a sleeping one once it wakes (until now the bomb was spent on it
for no damage). Placement is unchanged (it already aimed at selectable enemies only).

Golden: bond-yanShip-high and coop2-HARD-4 (bombs under 隐匿 enemies of rounds 10–11 — 隐形弩手组长, 访问团强攻冠军,
重弩突袭者 — and sleeping ones of round 7 — 宿主士兵, 粉碎攻坚手 — now wait).

Tests: test/content/feedback7-champagne-stealth.test.js (2; both fail on the parent).
2026-10-08 20:15:17 +08:00

289 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/chess_char_3_04-swire2.js — 琳琅诗怀雅 (char_1033_swire2) kit, tier 3.
// Conventions of the tier-3 kits: ../shared/tier3.js; kit contract and rules: ../README.md.
import { COLS } from '../../../constants.js';
import { bodyOnTile } from '../../../body.js';
import {
num, defOf, talentBb, moduleTalentBb, selectedId, altSkills, alive, gridKeys, fx, copyGrid, textNum, freeTile,
groundTile, enemiesOn,
} from '../shared/tier3.js';
import { powi } from '../../../detmath.js';
import { champagneHold, CHAMPAGNE_TRIGGER } from '../../tokens.js';
import { canTargetEnemy } from '../../../targeting.js';
/** 琳琅诗怀雅 S3's coin range (PRTS 备注 "前方范围2-4"; range_table "2-4", facing right). */
const SWIRE2_COIN_GRID = Object.freeze([[1, 1], [0, 0], [0, 1], [0, 2], [-1, 1]]);
/**
* 琳琅诗怀雅 S2 “见面礼”'s placement range: range_table "x-6" — her tile and the two tiles beyond it in each of the four
* directions (symmetric: her facing does not matter). PRTS 备注 "香槟炸弹放置范围:x-6"; the client charpack (char_1033_swire2,
* mode S2) gives its SpawnToken selector `_rangeId` "x-6". The skill's own rangeId (1-1) is her attack range, not this.
*/
const SWIRE2_BOMB_GRID = Object.freeze([[2, 0], [1, 0], [0, -2], [0, -1], [0, 0], [0, 1], [0, 2], [-1, 0], [-2, 0]]);
/** Merchant trait with a callback on every successful DP payment (琳琅诗怀雅 大买家). Same rules as professions.js. */
function merchantInstall(onPay) {
return (battle, unit) => {
const iv = unit.profile.merchantInterval ?? 3;
const cost = unit.profile.merchantCost ?? 3;
battle.every(iv, () => {
if (!alive(unit)) return;
const pl = battle.getPlayer(unit.ownerId);
if (!pl) return;
if (pl.dp >= cost) {
battle.addDp(unit.ownerId, -cost);
onPay(battle, unit, cost);
battle.emit('merchantPay', { unit, cost });
} else battle.retreat(unit, { reason: 'merchant' });
}, { owner: unit });
};
}
export default {
// ---- 3_04 琳琅诗怀雅 · 行商 — S2 “见面礼” (passive): with a coin and a free tile of range x-6 her attack's turn
// throws a champagne bomb instead (onto an enemy's tile when one stands there; installS2);
// 大买家: coin at skill start + coin & ATK stack per trait payment; 破财消灾: DP-paid revive (cost doubles)
// S1 仗义疏财 (passive, 2 coins): a coin heals the most injured ally (< 70 % HP) of the 3×3 around her — herself
// included — for attack@heal_scale × ATK — on her attack, or with no enemy to attack on her own attack timer (owner's decision
// 2026-10-04, against the official 「下一次攻击会为…」; installS1). S3 千金一掷 (持续时间无限): attacks hit twice, kills give a coin;
// closing it spends every coin on random ground enemies of range 2-4 in front and those she blocks (atk_scale phys +
// a small push, radial despite the text's 向前 — PRTS 备注 "推开效果为径向推动"; client charpack char_1033_swire2:
// the RandomGold ability (Skill_3_End) carries swire2_s_3[knockback] of template knockback[relative]; 地面敌方单位,
// 弹道不可对空). It does not close itself when the purse is full (PRTS 卫戍协议/帮助 「通常不会自动关闭技能」; the skill
// text is 「可随时主动关闭」). Owner 2026-10-04: at the cap (金币上限为10) she shoots once an enemy is in the skill's
// attack range (the 1-tile range, not the coin-mark 2-4 grid). Below the cap, or with nobody there, it stays open.
// 精锐 module MER-Y: ATK +4 % per trait payment (≤ 5 stacks).
chess_char_3_04_a: (bb, chess, def) => {
const d = defOf(chess, def);
const t0 = talentBb(d, 0), t1 = talentBb(d, 1);
const sel = selectedId(chess, d);
const S1 = 'skchr_swire2_1', S2 = 'skchr_swire2_2', S3 = 'skchr_swire2_3';
// "携带此技能时金币上限为N": the SELECTED skill's cap
const coinMax = num(bb.sp, 3);
const coinCost = Math.abs(num(bb['attack@sp'], -1)) || 1;
const modTal = moduleTalentBb(chess);
const scale = num(bb.atk_scale, 1);
const slug = num(bb.sluggish, 2);
const tokenId = chess?.skill?.overrideTokenKey ?? (d.tokens || []).find((t) => /gdtrap/.test(String(t))) ?? 'token_10031_swire2_gdtrap';
const skillGrid = copyGrid(d.skill?.rangeGrid);
const addCoins = (battle, unit, n) => {
const before = unit.mem.coins ?? 0;
unit.mem.coins = Math.min(coinMax, before + n);
if (unit.mem.coins > before) fx(battle, 'coin', unit, { n: unit.mem.coins });
};
const bombKit = (owner, switchT) => ({
skill: null,
trait: { noAttack: true },
install(battle, bomb) {
champagneHold(battle, bomb); // 禁疗, no HP loss, never gone because of its HP (the owner's decision D2 of 2026-10-08)
battle.on('tick', () => {
if (!bomb.alive || !bomb.deployed) return;
for (const e of battle.enemies) {
// a ground enemy it may select — no 隐匿 one that is neither revealed nor blocked (tokens.js CHAMPAGNE_TRIGGER)
if (!canTargetEnemy(bomb, e, CHAMPAGNE_TRIGGER)) continue;
if (!bodyOnTile(e, bomb.tileR, bomb.tileC)) continue;
// first enemy touching it; after switchT s on the field the bomb deals its damage one extra time
const hits = battle.time - bomb.deployedAt >= switchT - 1e-9 ? 2 : 1;
for (let i = 0; i < hits && e.alive; i++) {
battle.dealDamage(owner, e, { amount: owner.s.atk * scale, type: 'phys', isSkill: true, canDodge: false, tags: ['skill', 'trap'] });
}
if (e.alive) battle.applyStatus(e, 'sluggish', { duration: slug, source: owner });
// `consumed`: the bomb is used up by its own blast — clients play its impact sound, not a death sound
fx(battle, 'explode', bomb, { src: owner.id, target: e.id, hits, consumed: true });
battle.retreat(bomb, { reason: 'expired', permanent: true });
break;
}
}, { owner: bomb });
},
});
const healRatio = textNum(d.skill?.description, /血量不足(\d+)%/, 70) / 100;
/**
* 仗义疏财 — official text 「消耗一枚金币,下一次攻击会为周围八格内血量不足70%的一名友方单位恢复相当于攻击力40%的生命」: the heal rides on an
* attack, so with no enemy around she never healed (community report #5 「琳琅诗怀雅1技能不会主动奶身边受伤的干员」). Owner's
* decision 2026-10-04 (a deliberate deviation, like §21.29's 重装 casts): she heals an injured ally beside her whether
* she attacks or not. Same target (the lowest HP ratio below 70 % of the 3×3 around her, no 禁疗 / 孤立 unit, no
* device), coin and heal_scale × ATK. Cadence [ASSUMED]: at most one heal per attack cycle (her attack interval, ASPD
* included) — while she attacks, on the attack as before; when her last attack attempt found no target, on her own
* timer (the 'tick' hook runs after the attacks, so an attack due in the same tick takes it).
* 「周围八格」 includes herself: the client charpack (char_1033_swire2, mode S1, ability HealAlly) selects with range
* x-4 — the 3×3 with her own tile — ally side, `_excludeOwner` 0, max HP ratio 0.7, one target. Until 0.2.0 she was
* left out and never healed herself (community report of 2026-10-06 「琳琅诗怀雅不会治疗自己」).
*/
const installS1 = (battle, unit) => {
const hs = num(bb['attack@heal_scale'], num(bb.heal_scale, 0));
let nextAt = -Infinity;
const healTarget = () => {
let best = null;
for (const a of battle.allyUnits) {
if (!alive(a) || a.hidden || a.kind === 'device' || a.hpRatio >= healRatio) continue;
if (a !== unit && (a.s.flags.noHeal || a.profile?.noHeal)) continue; // 禁疗 / 孤立: never a heal target
if (a === unit && a.s.flags.healFree) continue; // her own 禁疗 (HEAL_FREE) stops a heal of herself too
if (Math.max(Math.abs(a.tileR - unit.tileR), Math.abs(a.tileC - unit.tileC)) > 1) continue; // x-4: the 3×3, her tile too
if (!best || a.hpRatio < best.hpRatio || (a.hpRatio === best.hpRatio && a.deploySeq < best.deploySeq)) best = a;
}
return best;
};
const tryHeal = () => {
if (!alive(unit) || (unit.mem.coins ?? 0) < coinCost || !(hs > 0) || battle.time < nextAt - 1e-9) return;
const best = healTarget();
if (!best) return;
unit.mem.coins -= coinCost;
nextAt = battle.time + unit.s.interval;
battle.heal(unit, best, unit.s.atk * hs);
fx(battle, 'coin', unit, { n: unit.mem.coins, heal: best.id, skill: 'swire2_1' });
};
battle.on('attack', (ctx) => { if (ctx.attacker === unit) tryHeal(); }, { owner: unit, priority: -10 });
battle.on('tick', () => { if (unit.deployed && unit.canAct && !unit.trait?.hadTarget) tryHeal(); }, { owner: unit });
};
/**
* “见面礼” — 「消耗一枚金币在范围内一个可放置且可通行的地面放置香槟炸弹」. The client charpack (char_1033_swire2, mode S2)
* makes her attack a priority composite: first the SpawnToken ability (animation Skill_2) whose trigger needs BOTH a
* tile of its selector (range x-6; melee-buildable, walkable, low ground — the tile options of the selector) and her
* coin buff (swire2_can_use_gold), else her normal attack (which needs a target). So a bomb takes her attack's turn,
* enemies or not: with no enemy around she still spends her coins on the free tiles of x-6 (PRTS 备注 「否则随机放置香槟
* 至可部署的地块」), and only a full x-6 (or no coin) lets her coins pile up to the cap. Tile (PRTS 备注 「放置香槟动画进行
* 前若放置范围内存在敌人,则按攻击索敌规律放置香槟至其所在地块」; the selector's `_targetMotion` WALK): the ground enemies
* standing on a free tile of x-6, in her attack's target order (blocked first, …, nearest the goal) — the first one's
* tile; with none, a random free tile of x-6. A free tile: inside the field, nobody on it, no knocked-out operator's
* tile (freeTile; an enemy on it does not count), walkable ground a melee unit may stand on (groundTile).
* [ASSUMED] timing: the throw is instant and costs her attack one tick (PRTS 备注 「每次放置香槟时若正在攻击敌人,会短暂
* 延长攻击抬手间隔」 — the composite clears its cooldown when the throw ends; the length of the Skill_2 animation is in
* no table): `canAttack` holds her attack back while a throw is due and the 'tick' hook (after the attacks) throws
* when her attack is ready. Until 0.2.0 every attack also dropped a bomb on her 1-1 range, at random.
*/
const bombTiles = (battle, unit) => {
const out = [];
for (const k of gridKeys(SWIRE2_BOMB_GRID, unit)) {
const r = (k / COLS) | 0, c = k % COLS;
if (freeTile(battle, r, c) && groundTile(battle, r, c)) out.push(k);
}
return out;
};
// (`bombHold`: a refused spawn — a tile freeTile let through — never holds her attack back for longer than one cycle)
const hasCoin = (battle, unit) => (unit.mem.coins ?? 0) >= coinCost && !(battle.time < (unit.mem.bombHold ?? -1));
const throwDue = (battle, unit) => hasCoin(battle, unit) && bombTiles(battle, unit).length > 0;
const installS2 = (battle, unit) => {
const switchT = num(battle.tokenDef(tokenId, unit)?.skill?.bb?.duration_switch, 3);
const ground = { ...unit.profile, canHitFly: false };
battle.on('tick', () => {
if (!alive(unit) || !unit.canAct || unit.atkCd > 1e-9 || unit.s.flags.disarm || !hasCoin(battle, unit)) return;
const keys = bombTiles(battle, unit);
if (!keys.length) return;
const free = new Set(keys);
let key = null;
for (const e of enemiesOn(battle, unit, keys, 0, ground)) {
const k = Math.round(e.y) * COLS + Math.round(e.x);
if (!e.isFlying && free.has(k)) { key = k; break; }
}
if (key == null) key = battle.rng.pick(keys);
const bomb = battle.spawnToken(unit, tokenId, (key / COLS) | 0, key % COLS, { untargetable: true, kit: bombKit(unit, switchT) });
if (!bomb) { unit.mem.bombHold = battle.time + unit.s.interval; return; }
unit.mem.coins -= coinCost;
fx(battle, 'summon', bomb, { src: unit.id, token: tokenId, coins: unit.mem.coins });
}, { owner: unit });
};
const installS3 = (battle, unit) => { // 千金一掷: "击倒敌人时获得一枚金币"
battle.on('kill', (ctx) => {
if (ctx.killer === unit && ctx.victim.side === 'enemy' && unit.skill?.active) addCoins(battle, unit, 1);
}, { owner: unit });
};
return {
skill: { kind: 'passive' },
skills: altSkills(chess, d, bb, {
[S1]: () => ({ kind: 'passive' }), // (coins → heals: installS1)
[S3]: (s) => {
const cash = num(s.bb.atk_scale, 1), force = num(s.bb.force, 0);
// the 【金币标记】 targets: ground enemies on range 2-4 in front of her (range_table "2-4") and every unit she blocks
const coinMarks = (battle, unit) => {
const list = enemiesOn(battle, unit, gridKeys(SWIRE2_COIN_GRID, unit), 0, { ...unit.profile, canHitFly: false });
for (const e of unit.blocking || []) if (e.alive && !e.isFlying && !list.includes(e)) list.push(e);
return list;
};
const purseCap = Math.max(1, Math.floor(num(s.bb.sp, 10)));
return {
kind: 'toggle',
attack: { hits: 2 },
// Owner 2026-10-04: at the cap, shoot once a ground enemy is in the skill attack range (data rangeGrid,
// the same 1-tile range as her attack). The coin marks (前方 2-4) are who the burst pays, not the trigger.
// Nobody in that range: stay open and keep the coins. Below the cap: stay open.
onTick({ battle, unit, skill }) {
if (!skill.active || (unit.mem.coins ?? 0) + 1e-9 < purseCap) return;
const keys = gridKeys(skillGrid ?? unit.rangeGrid, unit);
if (enemiesOn(battle, unit, keys, 0, { ...unit.profile, canHitFly: false }).length) skill.end('manual');
},
onEnd({ battle, unit, reason }) {
if (reason !== 'manual' || !unit.alive) return;
const n = unit.mem.coins ?? 0;
unit.mem.coins = 0;
let spent = 0;
// PRTS 备注: closing it "立即对前方范围2-4内的地面敌方单位与自身阻挡的所有单位施加【金币标记】", the coins going to
// marked units at random — 弹道(不可对空): air units (FLY, 近地悬浮, 浮空) are never paid. Until 0.1.1 the
// coins went to her attack range (1-1) instead of range 2-4.
const marked = coinMarks(battle, unit);
for (let i = 0; i < n; i++) {
const e = battle.rng.pick(marked.filter((x) => x.alive && !x.isFlying));
if (!e) break;
spent++;
battle.dealDamage(unit, e, { amount: unit.s.atk * cash, type: 'phys', isSkill: true, tags: ['skill', 'swire2Cash'] });
// "将目标小力地向前推开" — PRTS 备注 "金币弹道…推开效果为径向推动", client knockback[relative]: a radial push
// away from her centre (not along her direction, so no 45° / 0.25-tile 特殊修正), official 力度 − 重量
// distance (Battle.push)
if (e.alive) battle.push(e, force, { from: unit });
}
fx(battle, 'coin', unit, { n: 0, spent, skill: 'swire2_3' });
},
};
},
}),
trait: {
// S2: a throw due takes her attack's turn (installS2)
...(sel === S2 || sel == null ? { canAttack: (battle, unit) => !throwDue(battle, unit) } : {}),
install: merchantInstall((battle, unit) => {
// MER-Y "每次特性消耗费用时攻击力+4%,最多可以叠加5次" (any time, not only during the skill)
if (modTal && num(modTal.atk) > 0) {
battle.addBuff(unit, { key: 'trait:swire2_module', refresh: 'stack', stacks: 1, maxStacks: Math.max(1, num(modTal.max_stack_cnt, 5)), mods: { atkPct: num(modTal.atk) } });
}
if (!unit.skill?.active) return; // "技能期间"
addCoins(battle, unit, num(t0.trait_sp, 1));
battle.addBuff(unit, { key: 'talent:swire2_buyer', refresh: 'stack', stacks: 1, maxStacks: Math.max(1, num(t0.max_stack_cnt, 8)), mods: { atkPct: num(t0.atk) } });
}),
},
install(battle, unit) {
if (sel === S1) { installS1(battle, unit); return; }
if (sel === S3) { installS3(battle, unit); return; }
if (sel !== S2 && sel != null) return;
installS2(battle, unit);
},
talents: [
{ install(battle, unit) { // 大买家: "开启技能时获得1枚金币" (a passive starts at every deployment, S3 when cast)
battle.on('deploy', (ctx) => {
if (ctx.unit !== unit) return;
unit.mem.coins = 0;
if (unit.skill?.kind === 'passive') addCoins(battle, unit, num(t0.sp, 1));
}, { owner: unit });
battle.on('skillStart', (ctx) => { if (ctx.unit === unit && unit.skill?.kind !== 'passive') addCoins(battle, unit, num(t0.sp, 1)); }, { owner: unit });
} },
{ install(battle, unit) { // 破财消灾
// the doubling cost restarts at 5 with every deployment: PRTS 备注 「再部署时重置本天赋费用消耗」 — the client buff
// swire2_t_2 keeps the doubled `cost` in its own blackboard, built anew when she is deployed (a battle start, 联防
// included — a new battle —, a redeploy, a 突袭 再部署). Until 0.2.0 a redeploy kept the doubled cost (community
// report of 2026-10-06 「正常对局中死亡之后再部署复活费用也应该重置」)
battle.on('deploy', (ctx) => { if (ctx.unit === unit) unit.mem.saveCount = 0; }, { owner: unit });
battle.on('fatal', (ctx) => {
if (ctx.unit !== unit || ctx.prevented) return;
const n = unit.mem.saveCount ?? 0;
const cost = Math.abs(num(t1.cost, -5)) * powi(num(t1.cost_multi, 2), n);
const pl = battle.getPlayer(unit.ownerId);
if (!pl || pl.dp + 1e-9 < cost) return;
battle.addDp(unit.ownerId, -cost);
unit.mem.saveCount = n + 1;
ctx.prevented = true;
unit.hp = Math.max(1, unit.s.maxHp * num(t1.hp_ratio, 0.7));
fx(battle, 'revive', unit, { cost });
}, { owner: unit });
} },
],
};
},
};