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:
1 parent
1db8e51023
commit
6bfc08270c
129 files changed
+3477
-1011
No files matched your search
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in new issue
Block a user