0.2.4-dev: round-29 community fixes and the official shared card pool

- Shared card pool and item stock as in the official mode (the owner's request, from the 路标档案馆 video and public notes): solo low-tier exceptions, co-op operators x2, shared item stock with special-item exceptions, normal / advanced occupancy 1 / 2, re-check on buy, 机变 exhaustion, release on consume / destroy / elimination; 整备 never exceeds stock; invariant held <= cap.
- 突袭: one taunt-first reselect right after a revive (the owner's rule); ordinary polls and redeploys keep the 0.2.3 rule; a revive on an enemy's tile stays. #475: online boss / hidden-boss Raid may land on the empty half (owner-limited scope).
- 浊心斯卡蒂 S1 x 坚守 no longer bounce redirected damage, and the two shares conserve the hit (idea of PR #464 by @HappyTanuki).
- Operator fixes adapted from PRs by @Xinglan233 (#459 哈洛德 S2 elemental trigger, #460 涤火杰西卡 shield on the four adjacent tiles, #461 家族徽章 first hit after stealth, #462 松果 S2 regrow per deploy); 克莱门莎 S2 splash 1.1 / 100% ATK and air auto-trigger (#467); 白面 copying 歌蕾蒂娅 / 余 skips the three-member condition (#473, the owner's video).
- 香槟炸弹 is not chosen by friendly auras / heals (PR #468 / #476 by @Siornya); 琳琅诗怀雅 coin balance (PR #450 by @IceCodeNew); Touch prep display (PR #451 by @YinqiMargatroid); lobby header (PR #471 by @YuriRestia); Japanese UI terms (PR #448 by @scutkane).
- AI-only boss countdown grace, render-failure cache / recovery without restarting a battle, device units kept on a same-field re-entry, DIY detail / skill previews, large-text result buttons.
- golden: battle families equal 0.2.3 except diy-135 / diy-136 (克莱门莎); all 18 release matches move through the shared pool, every flip named in docs/history/0.2.4.md §29.12; two coverage scenarios added.
This commit is contained in:
sganggs committed 2026-10-11 01:58:52 +08:00
1 parent 1db8e51023
commit 6bfc08270c
129 files changed
+3477 -1011

No files matched your search

+76 -22
View File
@@ -37,8 +37,9 @@
// in its range counts for 「范围内没有敌人」 (the owner's decision of 2026-10-08 from the official game,
// GitHub #316; asleep, untargetable and flying ones stay out: raidGroundEnemy): on a free tile of a board of its field
// (Battle.onFieldBoard: its own or a teammate's on the same field — the two-helper 联防 field and a
// pair field open both halves, the owner's decision of 2026-10-07; never a boss field's hand /
// 临时整备区 rows, never the empty other half of a lone 联防 helper or a solo boss field — community
// pair field open both halves, the owner's decision of 2026-10-07; #475 also opens an empty half
// for multiplayer boss/hidden Raid only, approved 2026-10-10; never a boss field's hand /
// 临时整备区 rows, never the empty other half of a lone 联防 helper or a single-mode boss field — community
// reports of 2026-10-06, items 40 and 16.3) its position may be deployed
// on from which its range covers that enemy (GitHub issue #51 [ASSUMED]: the first of the 8 most
// advanced that has such a tile; none → it stays and the next poll looks again, never a jump that
@@ -52,7 +53,13 @@
// ATK/HP +(base + per·L) until it leaves the field; knocked out after a jump it lies where it fell
// and comes back there (the engine's rest tile, Battle._layBody — PRTS 卫戍协议/帮助 "原地留下一个
// “倒地干员”…自动部署至该位置"; its own home when it fell on another board piece's home);
// L ≥ power_bond_stack_cnt: every operator ASPD +power_attack_speed
// L ≥ power_bond_stack_cnt: every operator ASPD +power_attack_speed.
// Ordinary polls order candidates by remaining path distance, then id (the 0.2.3 rule). The one
// reselect right after a revive — knocked out, and back before the respawn timer (不屈 / 阿戈尔 /
// 复活, or the same kill + immediate redeploy) — orders by taunt, then that distance, and jumps
// while the operator is not blocking. An enemy already standing on its tile counts: the 0.25 s
// poll runs before that tick's block pass, so the reselect refreshes contact first. A retreat,
// a move, a redeploy that waited out its timer, and Raid's own jump do not arm that reselect.
// 不屈 indomShip ground operator knocked out → p = min(1, base + per·L) immediate free redeploy where it lies
// (the engine's rest tile); tier 2: every operator on the field +sp SP
// 协防 emptyShip all operators phys/arts taken ×(1 − damage_resistance); members dealt ×damage_scale_normal
@@ -67,7 +74,9 @@
import { canTargetEnemy, enemyStealthed, extendedGrid } from '../../../targeting.js';
import { normDir, rotateOffset, localOrder, localBefore } from '../../../dir.js';
import { bodyKeys, bodyInKeys } from '../../../body.js';
import { isHpLoss } from '../../../damage.js';
import { applyRedirectCut, isHpLoss, redirectShare } from '../../../damage.js';
import { BOSS_ROW_OFFSET } from '../../../constants.js';
import { GEO } from '../../../../../shared/constants.js';
import {
num, bondRecord, buffParams, bondTier, bondLayers, isMember, isElite, isGroundOp, onField, playerOps, passiveBuff,
fxOn, N4, N8, directMods, COLS,
@@ -272,15 +281,26 @@ function raidReach(u) {
return out;
}
/** #475: multiplayer boss/hidden Raid may use an absent partner's deployment half (maintainer-approved, §29.13).
* Keep this exception local to Raid: onFieldBoard/onOwnBoard still describe actual players' deployment regions.
*/
function raidBoard(battle, r, c) {
if (battle.onFieldBoard(r, c)) return true;
return battle.modeId?.startsWith('mode_multi_') && (battle.kind === 'boss' || battle.kind === 'hidden')
&& r >= GEO.FIELD.r0 + BOSS_ROW_OFFSET && r <= GEO.FIELD.r1 + BOSS_ROW_OFFSET
&& c >= GEO.FIELD.c0 && c <= COLS - 1 - GEO.FIELD.c0;
}
/**
* Landing tile [row, col] of a jump to enemy `e`, or null: a tile from which the member's range (`reach`, raidReach)
* covers the enemy's body (a huge enemy: any tile it occupies — body.js), within RAID_SEARCH tiles (Chebyshev) of the
* enemy, inside the field rect and on a board of its field (Battle.onFieldBoard — "再部署" goes where a player of the
* field deploys: its own board or a teammate's half when both halves are taken, the two-helper 联防 field and a pair
* field — the owner's decision of 2026-10-07, DESIGN §26.1; never a boss field's hand row 0 or 临时整备区 row 1, both
* inside BOSS_RECT and buildable high ground, nor the empty other half of the one-helper 联防 map or a solo boss field;
* inside BOSS_RECT and buildable high ground, nor the empty other half of the one-helper 联防 map or a single-mode boss field;
* community reports of 2026-10-06, items 40 and 16.3: a ranged member landed on the 临时整备区 row of a solo leader
* round, and on the right half of the one-helper 联防 map), that the member's
* round, and on the right half of the one-helper 联防 map). #475 additionally opens the empty deployment half only
* in multiplayer boss/hidden battles (raidBoard, approved 2026-10-10, §29.13). The tile must be one that the member's
* position may be deployed on (grid.canStand: never the 深水区 —
* player report #3 after 0.1.0 —, and for a melee member low ground only, never a 高台: GitHub #148) and that is free
* (Battle.isReservedTile: no living unit, no knocked-out operator's body — player report F5 —, no waiting piece's tile).
@@ -305,7 +325,7 @@ function raidTile(battle, u, e, reach) {
for (let i = 0; i < reach.length; i += 2) {
const r = br - reach[i], c = bc - reach[i + 1], dr = r - er, dc = c - ec;
if (Math.abs(dr) > RAID_SEARCH || Math.abs(dc) > RAID_SEARCH) continue;
if (!battle.grid.inRect(r, c) || !battle.onFieldBoard(r, c) || !battle.grid.canStand(r, c, { ranged }) || battle.isReservedTile(r, c)) continue;
if (!battle.grid.inRect(r, c) || !raidBoard(battle, r, c) || !battle.grid.canStand(r, c, { ranged }) || battle.isReservedTile(r, c)) continue;
const t = r * COLS + c;
const p = path && battle.grid.tile(r, c).pass === 'ALL' && (path.has(t) || body.includes(t)) ? 0 : 1; // its block applies
const d = Math.max(Math.abs(dr), Math.abs(dc)) + 0.01 * (Math.abs(dr) + Math.abs(dc));
@@ -341,12 +361,12 @@ function raidStealthInRange(battle, u) {
}
/**
* Jump candidates of player `pid`, in priority order: the ground enemies of raidGroundEnemy (visible, or 隐匿; not
* flying, asleep or untargetable) — those of the player's own field (`ownerId`), the others only when it has none —,
* the most advanced first (least remaining path distance, then the earliest spawned) [ASSUMED, research 02 §3.18].
* The same list for every member of the player (the checks read the enemy, not the attacker).
* Jump candidates of player `pid`: the ground enemies of raidGroundEnemy (visible, or 隐匿; not flying, asleep or
* untargetable) — those of the player's own field (`ownerId`), the others only when it has none. Ordinary polls sort
* by remaining path distance, then stable id. `taunt` is only the one reselect after a revive (maintainer 2026-10-10):
* highest taunt, then that distance, then id. The two orders are not one shared list.
*/
function raidTargets(battle, u, pid) {
function raidTargets(battle, u, pid, { taunt = false } = {}) {
const own = [], other = [];
for (const e of battle.enemies) {
if (!raidGroundEnemy(u, e)) continue;
@@ -354,16 +374,38 @@ function raidTargets(battle, u, pid) {
}
const list = own.length ? own : other;
const dist = new Map(list.map((e) => [e, num(battle.remainingDistance ? battle.remainingDistance(e) : 0)]));
list.sort((a, b) => dist.get(a) - dist.get(b) || a.id - b.id);
list.sort((a, b) => (taunt ? num(b.s.taunt) - num(a.s.taunt) : 0) || dist.get(a) - dist.get(b) || a.id - b.id);
return list;
}
/**
* The block pass has not run yet this tick (the poll is in the scheduled phase, and `_deploy` clears `blocking`).
* Refresh contact the same way `updateEnemy` does before an enemy moves: an enemy already on this tile is blocked
* now, so a revive does not treat that tile as empty and jump away.
*/
function raidRefreshContact(battle, u) {
for (const e of battle.enemies) {
if (!e.alive || e.blockedBy || e.hidden) continue;
if (Math.abs(Math.round(e.y) - u.tileR) > 1 || Math.abs(Math.round(e.x) - u.tileC) > 1) continue;
battle._checkBlock(e);
}
}
function raidPoll(battle, st) {
const bb = st.bb[ID.raid];
const idle = num(bb.no_attack_duration, 10);
let targets = null; // the player's candidates (raidTargets), shared by its members until a jump changes the field
// Ordinary and post-revive orders stay separate: one poll can hold a member of each.
let plain = null, taunted = null;
for (const u of st.members[ID.raid]) {
if (!onField(u) || !u.canAct) continue;
// Maintainer 2026-10-10: one fresh target check after revival when unblocked, even with enemies in attack range.
// Ordinary later polls retain their original empty-range rule and distance order. A Raid jump cannot arm another check.
const revived = u.mem.raidReviveSeq === u.deploySeq;
if (revived) {
u.mem.raidReviveSeq = 0;
raidRefreshContact(battle, u);
}
if (revived && u.blocking.some((e) => e.alive && e.blockedBy === u)) continue;
const since = Math.max(u.lastAttackAt ?? -Infinity, u.deployedAt ?? -Infinity, u.mem[KEY.raid] ?? -Infinity);
// 技能就绪: a charged skill, or a passive skill that is on (GitHub #49: skills.js `ready` is false for every passive,
// so 缄默德克萨斯 / 宴 … only ever jumped on the idle trigger; the reporter's footage of the official game shows
@@ -377,10 +419,10 @@ function raidPoll(battle, st) {
const deploySkillOn = !!(sk && sk.active && (sk.kind === 'passive' || sk.spType === 'none'));
const ready = !!(sk && !sk.noSkill && ((sk.ready && !(sk.active && sk.isTimed)) || deploySkillOn));
const idleOk = battle.time - since >= idle - 1e-9;
if (!(ready || idleOk)) continue;
if (!(revived || ready || idleOk)) continue;
// 「若范围内没有敌人」: a targetable enemy, or a 隐匿 ground one (the owner's decision of 2026-10-08, raidGroundEnemy)
if (battle.enemiesInKeys(u.rangeKeys || [], u, u.profile).length || raidStealthInRange(battle, u)) continue;
const list = (targets ??= raidTargets(battle, u, st.pid));
if (!revived && (battle.enemiesInKeys(u.rangeKeys || [], u, u.profile).length || raidStealthInRange(battle, u))) continue;
const list = revived ? (taunted ??= raidTargets(battle, u, st.pid, { taunt: true })) : (plain ??= raidTargets(battle, u, st.pid));
if (!list.length) continue;
// either trigger: raidTile only offers tiles with the target in range (without that a ready skill that finds no
// target would redeploy — firing every 部署时 effect — at every poll; the idle trigger, which lacked it up to
@@ -389,8 +431,9 @@ function raidPoll(battle, st) {
for (let i = 0; i < list.length && i < RAID_TARGETS; i++) {
const tile = raidTile(battle, u, list[i], reach);
if (!tile) continue;
targets = null; // the retreat / redeploy handlers (部署时 effects) may change the enemies: the next member re-sorts
plain = taunted = null; // the retreat / redeploy handlers (部署时 effects) may change the enemies: the next member re-sorts
const res = raidRedeploy(battle, u, tile[0], tile[1]);
u.mem.raidReviveSeq = 0;
if (!res) continue;
u.mem[KEY.raid] = battle.time; // with deployedAt: the idle time starts again from the landing
if (res === 'raid') {
@@ -459,6 +502,13 @@ export function install(battle) {
if (has(ID.raid)) {
const raid = states.filter((st) => st.tiers[ID.raid] && st.members[ID.raid].size);
if (raid.length) {
battle.on('deploy', (c) => {
const u = c.unit;
// Only a revive: the previous exit was a knock-out and the operator is back before its respawn timer.
// A retreat (宴 / 史尔特尔 / 伊内丝 / 耀骑士临光), a move, a timer redeploy and the initial deploy stay on the old poll.
if (!u || c.initial || c.move || u.removeReason !== 'killed' || !(u.respawnAt > battle.time + 1e-9)) return;
if (raid.some((st) => st.members[ID.raid].has(u))) u.mem.raidReviveSeq = u.deploySeq;
});
battle.every(RAID_POLL, () => { for (const st of raid) raidPoll(battle, st); });
}
}
@@ -542,7 +592,8 @@ export function install(battle) {
if (stead) {
battle.on('hit', (c) => {
const t = c.target, dmg = c.dmg;
if (!t || t.side !== 'ally' || t.kind !== 'op' || !dmg || dmg.cancel || dmg.steadShare || dmg.steadCut || dmg.type === 'element') return;
if (!t || t.side !== 'ally' || t.kind !== 'op' || !dmg || dmg.cancel || dmg.steadShare || dmg.steadCut
|| dmg.tags?.includes('bond:stead:share') || dmg.type === 'element') return;
const st = byPid[t.ownerId];
if (!st || (st.tiers[ID.stead] ?? 0) < 2 || st.members[ID.stead].has(t)) return;
let any = false;
@@ -550,7 +601,8 @@ export function install(battle) {
if (!any) return;
const ratio = Math.max(0, Math.min(1, num(st.bb[ID.stead].damage_resistance)));
if (!(ratio > 0) || ratio >= 1) return;
dmg.mul *= 1 - ratio;
// Both redirects take their share from the pre-redirect hit (applyRedirectCut). The victim keeps the rest.
applyRedirectCut(dmg, ratio);
dmg.steadCut = ratio;
}, { priority: -20 });
}
@@ -565,14 +617,16 @@ export function install(battle) {
if (dmg.steadCut) {
const ratio = dmg.steadCut;
dmg.steadCut = 0;
const share = (num(c.amount) / (1 - ratio)) * ratio;
const share = redirectShare(num(c.amount), ratio, dmg.redirectCuts);
if (!(share > 0)) return;
const on = [];
for (const m of st.members[ID.stead]) if (onField(m)) on.push(m);
// the share is already mitigated and already carries the attacker's damage multipliers: deal it sourceless
// (the member's own damage-taken modifiers and shields still apply), never re-scaled by the attacker
for (const m of on) {
const d = battle.makeDamage({ amount: share / on.length, type: 'true', canDodge: false, tags: ['bond:stead:share'] });
// Preserve sharing ancestry across Skadi's redirect, so neither sharing effect revisits its own output.
const d = battle.makeDamage({ amount: share / on.length, type: 'true', canDodge: false,
tags: ['bond:stead:share', ...(dmg.tags?.includes('transfer') ? ['transfer'] : [])] });
d.steadShare = true;
battle.dealDamage(null, m, d);
}
+6
View File
@@ -76,6 +76,12 @@ function sameRowCount(ctx, me) {
}
function rowCondition(ctx, me) {
const { bb, bbStr } = ctx.source;
// Maintainer 2026-10-10, #473's official-game recording: Ptilopsis's copied Gladiia / Yu prep-start gifts skip
// the three-in-a-row gate. This is not a general change to copying or to either original operator's condition.
const from = ctx.source.copiedFrom;
if (from && ctx.source.garrison?.eventType === 'SERVER_PREP_START'
&& ctx.chessRecord(me?.id)?.charId === 'char_128_plosis'
&& ['char_474_glady', 'char_2026_yu'].includes(ctx.chessRecord(from.id)?.charId)) return true;
if (bbStr.conditionkey === 'character_same_row') return sameRowCount(ctx, me) >= num(bb.check_count, 0);
return true;
}
+8 -8
View File
@@ -810,30 +810,30 @@ const BY_ITEM = {
const partner = partnerOf(p);
const key = S.key('stealth');
const per = num(p.atk_per_sec), max = num(p.max_atk, 1);
const st = { bonus: 0, wasStealth: false, armed: false, seq: -1 };
const st = { bonus: 0, seq: -1 };
const TICKS = 0.25;
S.every(TICKS, () => {
if (u.deploySeq !== st.seq) { st.seq = u.deploySeq; st.bonus = 0; st.armed = false; st.wasStealth = false; }
if (u.deploySeq !== st.seq) { st.seq = u.deploySeq; st.bonus = 0; }
if (!onField(u) || !memberOf(battle, u, 'siracusaShip')) return;
const stealth = !!u.s.flags.stealth;
if (stealth && st.bonus < max) {
st.bonus = Math.min(max, st.bonus + per * TICKS);
S.buff(u, { key, mods: directMods({ atk: st.bonus }), refresh: 'replace' });
}
if (st.wasStealth && !stealth && st.bonus > 0) st.armed = true;
st.wasStealth = stealth;
});
S.on('damaged', (c) => {
if (c.source !== u || !st.armed || isProc(c.dmg) || !(c.amount > 0)) return;
st.armed = false;
// A hit may follow stealth ending before the next growth poll. Decide at the hit, and consume once before
// the combo deals synchronous damage; the ATK buff still supplies that combo's attack value.
if (c.source !== u || isProc(c.dmg) || !(c.amount > 0) || st.bonus <= 0
|| u.deploySeq !== st.seq || !onField(u) || u.s.flags.stealth) return;
st.bonus = 0;
if (partner && carries(battle, u, partner) && c.target && c.target.alive && c.target.side === 'enemy') {
battle.dealDamage(u, c.target, { amount: u.s.atk * num(p.atk_scale), type: 'true', canDodge: false, tags: procTags('crest') });
battle.fx('strike', { x: c.target.x, y: c.target.y, id: c.target.id, src: 'item:chess_item_6_11_e', key: rec.id, from: u.id });
}
st.bonus = 0;
battle.removeBuff(u, key);
});
S.on('death', (c) => { if (c.unit === u) { st.bonus = 0; st.armed = false; } });
S.on('death', (c) => { if (c.unit === u) st.bonus = 0; });
},
};
@@ -15,8 +15,10 @@ export default {
const kit = {
skill: {
kind: 'duration',
onStart({ battle, unit, skill }) {
const atk = steps[Math.min(steps.length - 1, Math.max(0, skill.activations - 1))];
onStart({ battle, unit }) {
const uses = unit.mem.pinecnS2Uses ?? 0;
unit.mem.pinecnS2Uses = uses + 1;
const atk = steps[Math.min(steps.length - 1, uses)];
battle.addBuff(unit, { key: 'skill:pinecn_atk', mods: { atkPct: atk } });
},
onEnd({ battle, unit }) { battle.removeBuff(unit, 'skill:pinecn_atk'); },
@@ -33,7 +35,10 @@ export default {
}),
talents: [{ install(battle, unit) {
battle.on('deploy', (ctx) => {
if (ctx.unit === unit) battle.addBuff(unit, { key: 'talent:pinecn_power', duration: num(t0.duration, 60), mods: { spRecoveryFlat: num(t0.sp_recovery_per_sec) } });
if (ctx.unit !== unit) return;
// PRTS 松果: count skill uses since this deployment; the runtime's activation identity stays cumulative.
unit.mem.pinecnS2Uses = 0;
battle.addBuff(unit, { key: 'talent:pinecn_power', duration: num(t0.duration, 60), mods: { spRecoveryFlat: num(t0.sp_recovery_per_sec) } });
}, { owner: unit });
} }],
};
@@ -2,6 +2,7 @@
// Conventions of the tier-6 kits: ../shared/tier6.js; kit contract and rules: ../README.md.
import { aggregateMods } from '../../../buffs.js';
import { applyRedirectCut, redirectShare } from '../../../damage.js';
import { COLS } from '../../../constants.js';
import { bardRegen } from '../../../professions.js';
import { startCountdown } from '../../tokens.js';
@@ -123,12 +124,15 @@ function skadi2(bb, chess, def) {
if (!(share > 0) || !t || t === unit || t.side !== 'ally' || t.kind === 'device' || !live(unit) || !unit.skill?.active) return;
if (d.type === 'element' || d.type === 'elemental' || d.skadiShare || d.tags?.includes('transfer') || !inCover(battle, unit, t)) return;
d.skadiShare = { share, tag: TAG }; // 同类效果取最高: one transfer per damage instance
d.mul *= 1 - share;
applyRedirectCut(d, share); // her share of the pre-redirect hit; the victim keeps the rest
}, { owner: unit, priority: -20 });
battle.on('damaged', (ctx) => {
const x = ctx.dmg?.skadiShare;
if (!x || x.tag !== TAG || !(ctx.amount > 0) || !live(unit)) return;
battle.dealDamage(ctx.source, unit, { amount: ctx.amount * x.share / Math.max(1e-6, 1 - x.share), type: 'true', canDodge: false, tags: ['transfer'] });
// Keep the earlier 坚守 redirect's marker. Rebuilding damage without it lets the same two effects bounce
// the hit forever; each existing sharing effect still gets its first application along this path.
battle.dealDamage(ctx.source, unit, { amount: redirectShare(ctx.amount, x.share, ctx.dmg.redirectCuts), type: 'true', canDodge: false,
tags: ['transfer', ...(ctx.dmg.tags?.includes('bond:stead:share') ? ['bond:stead:share'] : [])] });
}, { owner: unit });
}
// module 新生代: "自身技能期间,攻击范围内的友军获得30点物理与法术伤害减免" (flat reduction per hit)
+15 -6
View File
@@ -1,14 +1,14 @@
// 克莱门莎 (char_4231_clemnt), 本源近卫. Official CN tables 2026-10-08 (client 2.7.81),
// PRTS 克莱门莎, 2026-10-09: all skills at ranks 4/7 and both potential-sensitive talents.
// https://prts.wiki/w/克莱门莎 . No module was released with her.
// PRTS still marks several launch-day details unknown: [ASSUMED] S2's ordinary splash radius 1.5;
// carriage's unresolved 放逐 combination = cannot walk/attack/be blocked, but remains damageable;
// PRTS still marks several launch-day details unknown: [ASSUMED] carriage's unresolved 放逐 combination =
// cannot walk/attack/be blocked, but remains damageable;
// its boundary clearance 0.4, carriage updates once per sim tick, vortex ends with the skill;
// S3 first bombardment at 0.5 s, then twice at 1 s intervals, launched shots survive skill end.
// These pending confirmations are listed in DESIGN §28.1; keep each assumption local to this kit.
import { num, talentBb, skillRec, up } from '../shared/tier1.js';
import { canTargetEnemy } from '../../../targeting.js';
import { absoluteRangeKeys, canTargetEnemy } from '../../../targeting.js';
import { bodyInKeys, hitRect } from '../../../body.js';
import { dirVec } from '../../../dir.js';
import { COLS } from '../../../constants.js';
@@ -18,7 +18,7 @@ const S1 = 'skchr_clemnt_1', S2 = 'skchr_clemnt_2', S3 = 'skchr_clemnt_3';
const GROUND = Object.freeze({ canHitFly: false, groundOnly: true });
const AIR = Object.freeze({ canHitFly: true });
const SPEED = 2, DISTANCE = 3, CATCH_HALF = 0.55, VORTEX_RADIUS = 1.5;
const SPLASH_RADIUS = 1.5; // [ASSUMED] PRTS has not published S2's normal attack splash radius.
const SPLASH_RADIUS = 1.1; // PRTS 2026-10-10: collision test; four secondary targets take 100% ATK.
const FIRST_BOMB = 0.5; // [ASSUMED] Neither PRTS nor the edited official demo settles the first landing offset.
const CAST = 'clemnt:cast', CARRY = 'clemnt:carry:';
const skillBb = (raw, id) => skillRec(raw, id)?.bb ?? {};
@@ -159,11 +159,11 @@ export default {
if (kind !== 'main' || !target) return;
// Main victim plus at most four neighbours, never every unit in the radius.
// [ASSUMED] Ties among splash victims follow the engine's stable spawn order.
const near = battle.foesInRadius(target.x, target.y, SPLASH_RADIUS, true)
const near = battle.foesInRadius(target.x, target.y, SPLASH_RADIUS)
.filter((e) => e !== target && canTargetEnemy(unit, e, GROUND))
.slice(0, Math.max(0, num(b2['attack@max_target'], 5) - 1));
for (const e of near) battle.dealDamage(unit, e, {
amount: unit.s.atk * unit.s.atkScaleMul * num(b2['attack@aoe_atk_scale'], 1), type: 'phys',
amount: unit.s.atk * unit.s.atkScaleMul, type: 'phys',
isAttack: true, isSplash: true, isSkill: true, attackId, tags: ['skill', 'clemnt:splash'],
});
} },
@@ -212,6 +212,15 @@ export default {
} },
],
install(battle, unit) {
if (unit.skill?.id === S2) {
// Maintainer 2026-10-10 (#467): follow Chen S3 automation. The vortex hits air while normal attacks do not.
// [ASSUMED] No mode footage establishes a separate auto-cast shape for this new operator; use the expanded
// skill attack grid with permanent extensions, retaining shared operation cooldown/control checks.
unit.skill.addTriggerRange(() => [{
keys: absoluteRangeKeys(skillRec(raw, S2)?.rangeGrid, unit.tileR, unit.tileC, unit.dir, unit.s.baseRangeExtend || 0),
profile: AIR,
}]);
}
battle.on('elementBurst', ({ target, element }) => {
if (element !== 'erosion' || !up(unit) || !unit.skill?.active || unit.skill.id !== S3
|| !canTargetEnemy(unit, target, GROUND)) return;
+3 -1
View File
@@ -1046,7 +1046,9 @@ function manifold(bb, raw, def) {
* and a medic healed a hurt one.
*/
export function champagneHold(battle, bomb) {
battle.addBuff(bomb, { key: 'token:champagneHold', flags: { noHeal: true, healFree: true }, persist: true, allowDead: true });
// PRTS 香槟炸弹: entity type 装置, not selected by ordinary auras/talents. The internal selection barrier has no
// visible status icon; it does not change the owner's explosion or the established HP protection (#476).
battle.addBuff(bomb, { key: 'token:champagneHold', flags: { noHeal: true, healFree: true, isolated: true }, persist: true, allowDead: true });
battle.on('hit', (ctx) => { if (ctx.target === bomb) ctx.dmg.cancel = true; }, { owner: bomb, priority: 1000 });
battle.on('elementHit', (ctx) => { if (ctx.target === bomb) ctx.dmg.cancel = true; }, { owner: bomb, priority: 1000 });
battle.on('damaged', (ctx) => { if (ctx.target === bomb && bomb.alive) bomb.hp = bomb.s.maxHp; }, { owner: bomb, priority: 1000 });