feedback3: merge WH — ground operators are melee-position, 叙拉古 rolls on every normal hit, off bonds show as disabled, prep-end items merge next prep

This commit is contained in:
sganggs committed 2026-10-04 21:26:10 +08:00
commit 1005754bf3
28 files changed
+746 -98

No files matched your search

+8 -2
View File
@@ -139,7 +139,7 @@ import { RealScheduler } from './scheduler.js';
import { SharedPool, drawDisabledBonds } from './pool.js';
import { PlayerState } from './PlayerState.js';
import { buildDeployMap, boardOrder, pieceDir } from './board.js';
import { bondList } from './bondsMeta.js';
import { bondList, offBondCounts } from './bondsMeta.js';
import { EffectDispatcher, getDefaultRegistry } from './effectsMeta.js';
import { generateDraft, applyCard, cardView, bountyBattles, isMultiRoundBounty } from './choices.js';
import { setupMatchWaves, buildNormalWave, buildBossWave, bountySpawns, withBounties, previewOf, weightedPick } from './waves.js';
@@ -892,7 +892,8 @@ export class Match {
// this round's in-battle gains included once the COMBAT phase ended (PlayerState.bondsView); [] once eliminated —
// nobody can watch an eliminated player (g.watch refuses them, they have no field) and the result screen reads
// m.result's own bonds, so their layers would only cost every m.public bytes for the rest of the match
bonds: ps.alive ? bondList(this.gd, ps.bondsView()) : [],
// (the mode-off bonds with members included, `off: true`, as in m.private — bondsMeta.offBondCounts)
bonds: ps.alive ? bondList(this.gd, ps.bondsView(), { off: offBondCounts(this.gd, ps) }) : [],
fieldId: this.fieldOf(ps),
status: this.statusOf(ps),
autoplay: ps.autoplay,
@@ -1657,6 +1658,11 @@ export class Match {
const alive = this.alivePlayers();
for (const ps of alive) {
ps.ready = false;
// Items gained as the previous prep ended waited unmerged (acquireItem deferMerge). Merge them now, before
// this prep's onPrepStart grants and before the player acts — not in endPrep, which runs in the same prep
// that granted them and would take an equipped copy off for the fight about to start.
ps.checkItemMerges();
ps.recompute();
this.dispatch(ps, 'onPrepStart', { round: this.round });
ps.recompute();
}
+9 -4
View File
@@ -75,7 +75,7 @@ import { checkLoadout, resolveLoadout } from '../../shared/protocol.js';
import { FIELD, tileKey, parseKey, inField, canPlace, positionClass, boardOrder, freeSlot, pieceDir, parseDir, mergeTile, ownerRangeKeys } from './board.js';
import { attackRangeGrid, loadoutRecord, resolveRecordLoadout } from '../../shared/loadoutRecord.js';
import { offsetTile } from '../sim/dir.js';
import { computeBonds, bondList, bondSnapshot, activatedLayers, bondsWithGains } from './bondsMeta.js';
import { computeBonds, bondList, bondSnapshot, activatedLayers, bondsWithGains, offBondCounts } from './bondsMeta.js';
import { itemKey } from './gamedata.js';
import { bountyText } from './choices.js';
@@ -712,12 +712,16 @@ export class PlayerState {
/**
* Acquire an item (buy, supply card, grant). Merges with an identical normal copy (hand/temp/equipped) into the
* golden item (to the hand). Returns the owned piece or null.
* `deferMerge` (effectsMeta, while onPrepEnd is on the stack): stow it — hand, else temp — and do not merge in this
* call, even when an identical normal copy is already owned. The next prep's start runs checkItemMerges. Nothing
* already equipped is taken off for the fight about to start. Hand and temp both full keeps the 「整备区已满,获得的装备已销毁」
* outcome. [ASSUMED] every item granted at 休整期结束, not only 维多利亚's 战栗维式重锤 (owner's decision 2026-10-04).
*/
acquireItem(itemId, { source = 'grant', toTemp = false, silent = false } = {}) {
acquireItem(itemId, { source = 'grant', toTemp = false, silent = false, deferMerge = false } = {}) {
const rec = this.gd.item(itemId);
if (!rec) return null;
let piece = this.newPiece('item', itemId);
if (this.completesItemMerge(itemId)) {
if (!deferMerge && this.completesItemMerge(itemId)) {
piece = this._mergeItem(itemId, piece);
if (!piece) return null;
} else if (!this.stow(piece, { allowTemp: true, toTemp })) {
@@ -1648,7 +1652,8 @@ export class PlayerState {
board,
deployCap: this.deployCap,
deployCount: this.deployCount,
bonds: bondList(this.gd, this.bondsView(), { full: true }),
// + the mode-off bonds it has members of (`off: true`, the strip's grey 本局禁用 discs — bondsMeta.offBondCounts)
bonds: bondList(this.gd, this.bondsView(), { full: true, off: offBondCounts(this.gd, this) }),
effects: this.effectsView(),
nextEnemies: this.m.nextEnemiesFor(this),
// DESIGN §16: the effective operator loadout ({ [baseChessId]: { skill, module } }; chess not listed use defaults)
+84 -33
View File
@@ -16,7 +16,10 @@
// 助力 (deputShip): tier 1 needs `thresholds[0]` distinct operators; the upper tiers count operators that differ
// in name OR elite state (PRTS 修正).
// 独行 (soloShip, count_threshold_downward): active iff 1 ≤ count ≤ maxCount (distinct 独行 operators).
// Bonds in the mode's static inactive list (FUNNY) never activate and are omitted.
// Bonds in the mode's static inactive list (FUNNY) never activate and are omitted from computeBonds (the battle
// input never sees them); the views list the ones the player has members of as `off: true`
// (offBondCounts → bondList: count only, never active, after the others) so the strip can say
// 本局禁用 (community reports 「投资人…不生效」 / 「…不会触发斯卡蒂与异德的突袭」, 0.1.3).
// `tier` = number of thresholds reached (downward: 1 when active); `active = tier ≥ 1`.
// Layers (`ps.layers[bondId]`) persist the whole match; they are reported for every bond but only matter while active.
@@ -48,17 +51,13 @@ export function pieceBonds(gd, piece) {
}
/**
* Compute every bond's state for a player. `harmony` (only present when it applies) = the +1 that 调和 added to `count`.
* @param {import('./gamedata.js').GameData} gd
* @param {{ board: Map<string, any>, hand: Array<any>, layers: Record<string, number>, bondCountBonus?: Record<string, number> }} ps
* @returns {Record<string, { count: number, active: boolean, tier: number, layers: number, harmony?: number }>}
* Who carries which bond (computeBonds' and offBondCounts' shared first step): bondId → Set(baseId) on the board / in
* the hand (the 5 temporary slots never count), bondId → Set(baseId|golden) on the board, and the elite chess on the board.
*/
export function computeBonds(gd, ps) {
function membership(gd, ps) {
const boardChess = [];
for (const p of ps.board.values()) if (p && p.kind === 'chess') boardChess.push(p);
const handChess = ps.hand.filter((p) => p && p.kind === 'chess');
/** bondId → Set(baseId) on board / hand; bondId → Set(baseId|golden) on board */
const onBoard = new Map();
const onBoardVariant = new Map();
const inHand = new Map();
@@ -72,14 +71,43 @@ export function computeBonds(gd, ps) {
const base = gd.baseIdOf(p.id);
for (const b of pieceBonds(gd, p)) add(inHand, b, base);
}
const goldenOnBoard = boardChess.filter((p) => gd.isGolden(p.id)).length;
return { onBoard, onBoardVariant, inHand, goldenOnBoard: boardChess.filter((p) => gd.isGolden(p.id)).length };
}
/** A bond's member count by its counting mode (before 调和's +1), with the effects' per-bond count bonus. */
function rawCount(gd, id, mem, ps) {
const bond = gd.bond(id);
let count;
if (bond.countMode === 'BOARD_ALL_CHESS' || bond.thresholdTemplate === 'count_threshold_upward_golden') {
count = mem.goldenOnBoard;
} else if (bond.countMode === 'BOARD_AND_DECK') {
const s = new Set([...(mem.onBoard.get(id) || []), ...(mem.inHand.get(id) || [])]);
count = s.size;
} else {
count = (mem.onBoard.get(id) || new Set()).size;
}
const bonus = ps.bondCountBonus || {};
return Math.max(0, count + (Number.isInteger(bonus[id]) ? bonus[id] : 0));
}
const layersIn = (ps, id) => {
const v = ps.layers && ps.layers[id];
return Number.isFinite(v) && v > 0 ? v : 0;
};
/**
* Compute every bond's state for a player. `harmony` (only present when it applies) = the +1 that 调和 added to `count`.
* @param {import('./gamedata.js').GameData} gd
* @param {{ board: Map<string, any>, hand: Array<any>, layers: Record<string, number>, bondCountBonus?: Record<string, number> }} ps
* @returns {Record<string, { count: number, active: boolean, tier: number, layers: number, harmony?: number }>}
*/
export function computeBonds(gd, ps) {
const mem = membership(gd, ps);
const { onBoard, onBoardVariant } = mem;
/** @type {Record<string, { count: number, active: boolean, tier: number, layers: number, harmony?: number }>} */
const out = {};
const layersOf = (id) => {
const v = ps.layers && ps.layers[id];
return Number.isFinite(v) && v > 0 ? v : 0;
};
const layersOf = (id) => layersIn(ps, id);
const bonus = ps.bondCountBonus || {};
const bonusOf = (id) => (Number.isInteger(bonus[id]) ? bonus[id] : 0);
@@ -87,17 +115,7 @@ export function computeBonds(gd, ps) {
const raw = {};
for (const id of gd.bondIds) {
if (gd.modeInactiveBonds.has(id)) continue;
const bond = gd.bond(id);
let count;
if (bond.countMode === 'BOARD_ALL_CHESS' || bond.thresholdTemplate === 'count_threshold_upward_golden') {
count = goldenOnBoard;
} else if (bond.countMode === 'BOARD_AND_DECK') {
const s = new Set([...(onBoard.get(id) || []), ...(inHand.get(id) || [])]);
count = s.size;
} else {
count = (onBoard.get(id) || new Set()).size;
}
raw[id] = Math.max(0, count + bonusOf(id));
raw[id] = rawCount(gd, id, mem, ps);
}
// 调和: +1 to core bonds with ≥ 1 real board member while it is active
const harmony = gd.bond(HARMONY_BOND);
@@ -121,6 +139,28 @@ export function computeBonds(gd, ps) {
return out;
}
/**
* The bonds this mode never activates (the static inactive list — 标准模拟's 拉特兰 阿戈尔 卡西米尔 灵巧 奥术 奇迹 投资人 突袭
* 独行 绝技) that the player has members of, counted like computeBonds would (投资人 / 奇迹 with the hand): { [bondId]:
* { count, layers } }, null when the mode switches nothing off. Only the views use it (bondList `off`) — the strip shows
* such a bond as a grey 本局禁用 disc instead of leaving the player to wonder why it does nothing (0.1.3).
* @param {import('./gamedata.js').GameData} gd
* @param {{ board: Map<string, any>, hand: Array<any>, layers: Record<string, number>, bondCountBonus?: Record<string, number> }} ps
* @returns {Record<string, { count: number, layers: number }>|null}
*/
export function offBondCounts(gd, ps) {
if (!gd.modeInactiveBonds || !gd.modeInactiveBonds.size) return null;
const mem = membership(gd, ps);
let out = null;
for (const id of gd.bondIds) {
if (!gd.modeInactiveBonds.has(id)) continue;
const count = rawCount(gd, id, mem, ps);
if (!(count > 0)) continue;
(out ??= {})[id] = { count, layers: layersIn(ps, id) };
}
return out;
}
/** Ascending member-count thresholds of a bond (fallback: activeCount). */
export function thresholdsOf(bond) {
if (Array.isArray(bond.thresholds) && bond.thresholds.length) return bond.thresholds.filter((n) => Number.isFinite(n)).sort((a, b) => a - b);
@@ -149,26 +189,37 @@ export function activatedLayers(bonds) {
/**
* View list for m.private / m.public: bonds with members or layers, active first, then layers desc, then data order.
* An entry whose count includes 调和's +1 carries `harmony: 1` (both views; absent otherwise).
* An entry whose count includes 调和's +1 carries `harmony: 1` (both views; absent otherwise). `off` (offBondCounts): the
* mode-off bonds the player has members of follow, in data order, as `{ bondId, count, active: false, tier: 0, layers,
* off: true }` — the strip's grey 本局禁用 discs; never in computeBonds, so never in the battle input.
* @param {import('./gamedata.js').GameData} gd
* @param {ReturnType<typeof computeBonds>} bonds
* @param {{ full?: boolean }} [opts] full → include thresholds/countsHand (m.private)
* @param {{ full?: boolean, off?: ReturnType<typeof offBondCounts> }} [opts] full → include thresholds/countsHand (m.private)
*/
export function bondList(gd, bonds, { full = false } = {}) {
export function bondList(gd, bonds, { full = false, off = null } = {}) {
const order = new Map(gd.bondIds.map((id, i) => [id, i]));
const list = [];
const extras = (e) => {
if (!full) return e;
const bond = gd.bond(e.bondId);
e.thresholds = bond ? thresholdsOf(bond) : [];
e.countsHand = !!(bond && bond.countMode === 'BOARD_AND_DECK');
return e;
};
for (const [bondId, b] of Object.entries(bonds || {})) {
if (!(b.count > 0 || b.layers > 0 || b.active)) continue;
const e = { bondId, count: b.count, active: b.active, tier: b.tier, layers: b.layers };
if (b.harmony > 0) e.harmony = b.harmony;
if (full) {
const bond = gd.bond(bondId);
e.thresholds = bond ? thresholdsOf(bond) : [];
e.countsHand = !!(bond && bond.countMode === 'BOARD_AND_DECK');
}
list.push(e);
list.push(extras(e));
}
list.sort((a, b) => (b.active - a.active) || (b.layers - a.layers) || ((order.get(a.bondId) ?? 99) - (order.get(b.bondId) ?? 99)));
if (off) {
for (const bondId of gd.bondIds) {
const o = off[bondId];
if (!o || !(o.count > 0) || (bonds && bonds[bondId])) continue;
list.push(extras({ bondId, count: o.count, active: false, tier: 0, layers: Number.isFinite(o.layers) && o.layers > 0 ? o.layers : 0, off: true }));
}
}
return list;
}
+11 -1
View File
@@ -196,6 +196,11 @@ export class EffectDispatcher {
return ev;
}
this.depth++;
// An item granted while 休整期结束 is on the stack (this hook, or onGain re-entering it — 维多利亚 pays the next
// milestone from the grant's onGain) is stowed and not merged until the next prep start. [ASSUMED] every such
// item, not only the 战栗维式重锤 (owner's decision 2026-10-04: "an item that arrives at 休整期结束").
const deferItems = hook === 'onPrepEnd';
if (deferItems) ps._deferItemMerge = (ps._deferItemMerge || 0) + 1;
try {
const reg = this.registry;
// 0. onPrice: the priced chess's own 特质 first — 购买价格为N defines the price every other modifier acts on
@@ -236,6 +241,7 @@ export class EffectDispatcher {
if (h) this._call(ps, ref.key, h, hook, { kind: 'effect', key: ref.key, ref }, ev);
}
} finally {
if (deferItems) ps._deferItemMerge--;
this.depth--;
}
return ev;
@@ -511,7 +517,11 @@ export function makeCtx(m, ps, source, hook, ev = null) {
},
grantItem: (itemId, opts = {}) => {
if (!ps.alive || !gd.item(itemId)) return null;
const p = ps.acquireItem(itemId, { source: opts.source || source.key || 'effect', toTemp: !!opts.toTemp });
const p = ps.acquireItem(itemId, {
source: opts.source || source.key || 'effect',
toTemp: !!opts.toTemp,
deferMerge: ps._deferItemMerge > 0,
});
return p ? view(p) : null;
},
/** Random chess id from the shared pool (copy-weighted). opts: { maxTier, tier, bond, filter(id) } */
+1 -1
View File
@@ -129,7 +129,7 @@ const BY_KEY = {
});
},
// 休谟斯 回收利用
// 休谟斯 回收利用 ("地面干员技能结束时…": a melee-position operator on any tile — support isGroundOp)
act1autochess_band18_buff(battle, ps, p, bandId) {
const sp = num(p.sp);
if (!(sp > 0)) return;
+2
View File
@@ -441,6 +441,8 @@ export function install(battle) {
// next time she leaves the field (knocked out, withdrawn) she is back at once and free (`u.mem.indomFreeDeploy`)
// [ASSUMED: one such deployment at a time, spent by her next deployment whatever brings it]. Tier 2 (+sp SP to every
// operator on the field) stays a knock-out effect: its own line ("地面干员被击倒时使场上所有干员技力+5") was not corrected.
// Both lines take a 地面干员 = a melee-position operator on any tile (support isGroundOp: 歌蕾蒂娅 on a 高台 counts, a
// ranged operator on a melee tile does not — community report 「不屈盟约效果高台干员也错误的吃到了」, 0.1.3).
if (has(ID.indom)) {
const INDOM_EXITS = new Set(['killed', 'retreat', 'merchant']);
const stOf = (u) => { const st = isGroundOp(u) ? byPid[u.ownerId] : null; return st && st.tiers[ID.indom] ? st : null; };
+28 -3
View File
@@ -19,8 +19,8 @@
// 5: the first max_free_respawn_cnt members by position (the devour's order, fixed at battle
// start) each redeploy at once (free) on their first knock-out
// 叙拉古 siracusaShip every member deployment: ASPD +(base + per·L) for (base_duration + per·L) s; 6: 隐匿 for the same
// time, and while hidden / end_duration s after, attacks proc (PRD, nominal `prob`) base_damage +
// damage_per_stack·L true damage + fear `fear` s
// time, and while hidden / end_duration s after, every 普通伤害 hit (siracusaRolls) procs (PRD,
// nominal `prob`) base_damage + damage_per_stack·L true damage + fear `fear` s
// 卡西米尔 kazimierzShip every deployment of one of the player's operators (initial ones included): members ATK
// +atk_when_born, total ≤ base_max_atk_when_born + max_atk_when_born_per_stack·L; 6: blocking
// members pulse damage_atk_scale×ATK true damage + stun around them every damage_interval s,
@@ -476,6 +476,29 @@ function installEgir(battle, pid, bb, members) {
// =====================================================================================================================
// 叙拉古
/**
* Does a member's damage instance try the tier-6 proc? PRTS 盟约记录 叙拉古: "※仅在造成普通伤害时尝试造成来源为干员自身的
* 真实附加伤害和恐惧", "每次造成普通伤害时尝试触发" — 普通伤害 is the attack type NORMAL (PRTS 伤害分类: the default type,
* group damage included unless it is 溅射), not "a normal attack" (community report 「叙拉古盟约真伤概率数值没有正确递增
* 成长」, GitHub #79: it rolled on normal attacks only). So skill hits roll — 德克萨斯 剑雨, 缄默德克萨斯's bursts and
* sword rain, 阿罗玛 S1 / S2, 忍冬 S1 / S2, 伺夜 S3 — and so do 荒芜拉普兰德's S3 drone attacks (PRTS 备注 "不属于普通攻击/
* 技能直接伤害": neither an attack nor skill damage, still 普通伤害). Not: 溅射 (`isSplash`); 持续伤害 (tags dot / periodic:
* 荒芜拉普兰德's S3 pulse, PRTS 备注 "持续法术伤害"; 缄默德克萨斯 S1, "伤害分类为法术持续伤害"); 附加伤害 (tag `addition`:
* 拉普兰德's module, 忍冬's 追凶 — PRTS "法术附加伤害"; item procs and the bonds' riders, own proc included [ASSUMED: 附加,
* PRTS gives no class]); element damage; 无来源 damage (坚守's thorns); 流失.
*/
function siracusaRolls(dmg) {
if (!dmg || dmg.isSplash || dmg.sourceless || dmg.type === 'element' || dmg.type === 'elemental') return false;
const tags = dmg.tags;
if (!Array.isArray(tags)) return true;
for (let i = 0; i < tags.length; i++) {
const t = tags[i];
if (t === 'dot' || t === 'periodic' || t === 'addition' || t === 'item' || t === 'hpLoss') return false;
if (typeof t === 'string' && t.startsWith('bond:')) return false;
}
return true;
}
function installSiracusa(battle, pid, bb, members) {
const memberSet = new Set(members);
const six = reached(battle, pid, 'siracusaShip', bb.power_bond_char_cnt);
@@ -497,7 +520,7 @@ function installSiracusa(battle, pid, bb, members) {
const st = { n: 0 };
const onDmg = (ctx) => {
const u = ctx.source, t = ctx.target, dmg = ctx.dmg;
if (!dmg || !dmg.isAttack || dmg.isSplash || !isEnemyTarget(t) || ownTag(dmg, 'bond:siracusa')) return;
if (!isEnemyTarget(t) || !siracusaRolls(dmg)) return;
const hidden = u.s.flags.stealth || battle.time <= num(u.mem.siraStealthEnd, -Infinity) + end + 1e-9;
if (!hidden) return;
st.n++;
@@ -609,6 +632,8 @@ export function payHammers(ctx) {
ctx.setCounter(VICTORIA_COUNTER, paid + 1);
for (let i = 0; i < count; i++) {
const id = ctx.rollItem({ pool: p.pool });
// onPrepEnd: grantItem stows this and does not merge until the next prep start, so a second copy does not
// take an equipped hammer off for the fight (effectsMeta _deferItemMerge). [ASSUMED] every item that hook grants.
if (id && ctx.grantItem(id, { source: 'bond:victoriaShip' })) granted++;
}
}
+1 -1
View File
@@ -299,7 +299,7 @@ function hammerAcquire(battle, rt, u) {
const m = hammerMul(battle, rt, u, hs, 'burn');
if (m > 0) battle.dealDamage(u, c.target, { type: 'element', element: 'burn', amount: c.amount * num(p.damage_scale) * m, tags: procTags('hammer_burn') });
});
// 战栗: ground carrier, on attack prob × m ⇒ 战栗 disarmed_duration s
// 战栗: 地面干员 carrier (melee position, any tile — support isGroundOp), on attack prob × m ⇒ 战栗 disarmed_duration s
S.on('attack', (c) => {
if (c.attacker !== u || !isGroundOp(u)) return;
const p = hammerParams(hs, 'tremble');
+3 -2
View File
@@ -40,7 +40,7 @@ function onDefaultSkill(chess) {
* `liftoff` (no ground enemy blocked — Battle._blockerFor; 对地规避 — targeting.js evadesGround: no ground enemy selects
* her, while what selects nobody still lands — 无视无法选择 abilities, direct picks, flying units' blasts, 无来源 damage, the
* ticks of a debuff already on her) and `blockFly` (blocks flyers at the air radius). She stays a ground unit on her tile (PRTS 行动方式 "起飞的
* 干员仍然是地面单位": `unit.ground` unchanged — 隐德来希's 血镰, 地面干员 bonds / items still count her).
* 干员仍然是地面单位": `unit.ground` unchanged — 隐德来希's 血镰 still counts her; 地面干员 bonds / items read her position).
*/
const LIFTOFF_FLAGS = Object.freeze({ blockFly: true, liftoff: true });
/** 蒂比 take-off: the ground enemies she blocked walk on. */
@@ -721,7 +721,8 @@ export default {
if (num(t.duration) > 0) trait.onHitStatus = { key: 'silence', duration: num(t.duration) };
if (num(tb.atk_scale_m) > 0) {
trait.afterHit = (battle, u, target) => {
if (target && target.alive && target.side === 'enemy') battle.dealDamage(u, target, { amount: u.s.atk * num(tb.atk_scale_m), type: 'arts', tags: ['module'] });
// PRTS 特性备注 "造成预计算的法术附加伤害": 附加伤害 (tag addition — no 叙拉古 6 roll)
if (target && target.alive && target.side === 'enemy') battle.dealDamage(u, target, { amount: u.s.atk * num(tb.atk_scale_m), type: 'arts', tags: ['module', 'addition'] });
};
}
return {
+2 -1
View File
@@ -1389,7 +1389,8 @@ const KITS = {
if (t == null) { marks.set(e.id, battle.time); return; }
if (battle.time - t <= num(t0.interval, 10) + 1e-9 && e.alive) {
busy = true;
try { battle.dealDamage(unit, e, { amount: unit.s.atk * num(t0.atk_scale), type: 'arts', canDodge: false, tags: ['talent', 'vulpisHunt'] }); } finally { busy = false; }
// PRTS 备注 "伤害类型为法术附加伤害": tag addition (no 叙拉古 6 roll)
try { battle.dealDamage(unit, e, { amount: unit.s.atk * num(t0.atk_scale), type: 'arts', canDodge: false, tags: ['talent', 'vulpisHunt', 'addition'] }); } finally { busy = false; }
}
}, { owner: unit });
battle.on('kill', (ctx) => { if (ctx.killer === unit && unit.skill?.active) unit.mem.vulpisKill = true; }, { owner: unit });
+5 -3
View File
@@ -2972,11 +2972,13 @@ function whitw2(bb, chess, def) {
// 信息 驭械术师 "浮游单元攻击不同目标…时,上述的伤害立刻恢复至初始值"). That damage is arts and neither a normal attack
// nor skill damage (PRTS S3 备注 "该技能释放的浮游单元造成的伤害不属于普通攻击/技能直接伤害", which for this skill
// overrides the branch note "通过技能释放的浮游单元造成技能直接伤害": no 'attack' hook, isAttack / isSkill false — the
// 叙拉古 6 assassin proc and the on-attack items skip it), and 缴械 does not stop it (PRTS 驭械术师 "…不受缴械类效果
// on-attack items skip it; it is still 普通伤害, so the 叙拉古 6 proc rolls on it, bonds/core.js siracusaRolls), and
// 缴械 does not stop it (PRTS 驭械术师 "…不受缴械类效果
// 制约"); [ASSUMED] nor do her stun, freeze or silence (PRTS names only 缴械) — the skill ticks on and so do the drones.
// Around every drone (attack@range_radius): move speed attack@move_speed and, once per second, attack@magic_atk_scale
// × ATK arts (不叠加: one hit per enemy whatever the number of drones); [ASSUMED] that area hit keeps `isSkill` (a skill
// DoT — the 备注 speaks of 直接伤害). A knocked-out / withdrawn wolf (onEnd cleared the drones mid-tick, e.g. from a
// DoT — the 备注 speaks of 直接伤害) and is 持续伤害 (tag dot: PRTS 备注 "持续法术伤害" — no 叙拉古 6 roll, 锡人's
// 凋敝魂灵 raises it). A knocked-out / withdrawn wolf (onEnd cleared the drones mid-tick, e.g. from a
// kill hook) deals nothing more in that tick.
skill: {
kind: 'duration',
@@ -3009,7 +3011,7 @@ function whitw2(bb, chess, def) {
unit.mem.droneAcc -= 1;
for (const e of near) {
if (gone()) return;
if (e.alive) battle.dealDamage(unit, e, { amount: unit.s.atk * dmgScale, type: 'arts', isSkill: true, tags: ['skill', 'drone'] });
if (e.alive) battle.dealDamage(unit, e, { amount: unit.s.atk * dmgScale, type: 'arts', isSkill: true, tags: ['skill', 'drone', 'dot'] });
}
// (a drone on its target already pulses with each of its attacks)
for (const d of D) if (d.phase !== 'lock') battle.fx('drone', { x: d.x, y: d.y, id: unit.id });
+8 -2
View File
@@ -151,8 +151,14 @@ export function tierOf(u) {
if (u.ownerUnit) return tierOf(u.ownerUnit);
return 1;
}
/** Operator standing on a ground (LOW, not elevated) tile — "地面干员" (engine flag set at deployment). */
export const isGroundOp = (u) => isOp(u) && u.ground === true;
/**
* "地面干员": a melee-position (地面位, deploy class MELEE) operator, whatever tile it stands on — the official vocabulary
* names the deploy class, not the tile (game data: this mode's map card 「可以部署高台干员和地面干员的地面」, 「可将高台干员
* 部署于其上」, 「所有地面干员阻挡数+2」; PRTS corrected 琴柳's 地面干员 to 地面位干员). Read by 不屈, the 战栗维式重锤 proc and
* 休谟斯 回收利用 (community report 「不屈盟约效果高台干员也错误的吃到了」, 0.1.3). The tile (`unit.ground`) stays what the
* enemies' 地面单位 targeting reads.
*/
export const isGroundOp = (u) => isOp(u) && u.def?.position === 'MELEE';
/** Base (normal) chess id of an operator (normal and elite share it). */
export const baseChessId = (u) => String(u?.def?.baseId ?? u?.defId ?? '').replace(/_b$/, '_a');