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).
289 lines
19 KiB
JavaScript
289 lines
19 KiB
JavaScript
// 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 });
|
||
} },
|
||
],
|
||
};
|
||
},
|
||
};
|