fix: correct community-reported combat and strategy draft behavior

Guard lethal stuns against a replacement deployment; keep bard auras running
through stun; require an active Aegir bond for Clementia's strategy; allow
Compass SP to bypass recovery locks; and refresh Hidden Core gun mitigation
against current gun/spring companions. Set multiplayer strategy picks to 50s
and describe renderer fallback without diagnosing unsupported hardware.

Golden moves (full 285-scenario regeneration):
- roster-008: inactive Aegir no longer gains 18 strategy layers.
- boss-boss_4-solo: bard regeneration continues through stun.
- hidden-boss_9-pair: mitigation covers first hits/current companions.
- standin-07: hammer stun cannot follow an immediate revival deployment.
No ending-reason changes. Sources and report-by-report findings: DESIGN 28.25.
This commit is contained in:
sganggs committed 2026-10-10 00:45:41 +08:00
1 parent 77cd29f07a
commit 708caf7d06
37 files changed
+277 -103

No files matched your search

+1 -1
View File
@@ -568,7 +568,7 @@ const recJson = (rec) => { try { return rec ? JSON.stringify(rec) : ''; } catch
/**
* Bonds an IN_BATTLE layer gain of this player can name: its bond snapshot (every bond its lineup counts), plus bonds
* its band, its effects (机变 cards, 驻守 …), its units and their items mention (content grants layers to those —
* e.g. 克莱门莎's <阿戈尔>, requireActive: false). Anything else is a forged gain.
* including referenced bonds outside the lineup snapshot). Anything else is a forged gain.
*/
function layerBondsOf(p, gd) {
const out = new Set(Object.keys(p.bonds && typeof p.bonds === 'object' ? p.bonds : {}));
+2 -2
View File
@@ -26,6 +26,6 @@ export const DELAYS = Object.freeze({
* Seconds of one turn of the co-op strategy draft (user playtest #4 item 4: the old 12 s per turn — research 06 §724,
* itself [ASSUMED] — inside the 50 s step was far too little and counted apart from the header's 50 s). [ASSUMED]: the
* official data only gives the whole BAND_CHECK step (autoChessData.enterStepList: 50 s, hint 15 s); the turn clock is
* the remake's. It is also the step's only countdown (m.public.deadline = draft.turnDeadline). × timerScale.
* the remake's (50 s per turn per the owner's 2026-10-10 official-play report). It is also the step's only countdown (m.public.deadline = draft.turnDeadline). × timerScale.
*/
export const BAND_TURN_SECONDS = 30;
export const BAND_TURN_SECONDS = 50;
+3 -1
View File
@@ -906,6 +906,8 @@ function enemyAttack(b, e, prevCd) {
// — the 'atk' event (kind `e.profile.shot`, drawn by the content's own fx), cooldown, pause, the 'attack' hook — and
// the content's 'attack' handler deals its damage
const deferred = !!(e.profile && e.profile.deferHit);
// A lethal strike may immediately redeploy its target. Post-attack effects still belong to the struck deployment.
const targetDeployments = new Map(targets.map((t) => [t, t.deploySeq]));
for (const t of targets) {
b._ev(['atk', e.id, t.id, deferred ? (e.profile.shot || 'none') : rangedShot ? 'enemy' : 'none']);
if (deferred) continue;
@@ -919,7 +921,7 @@ function enemyAttack(b, e, prevCd) {
b.addProjectile({ from: e, target: t, speed: PROJECTILE_SPEEDS.enemy, visual: 'enemy', source: e, onHit: (c) => hit(c.target, true) });
} else hit(t);
}
if (b._hooks.attack) b.emit('attack', { attacker: e, targets, isSkill: false });
if (b._hooks.attack) b.emit('attack', { attacker: e, targets, targetDeployments, isSkill: false });
e.atkCd = e.s.interval;
// stands for the rest of its attack clip (attackStand; the wind-up was stood before the strike) — every enemy, blocked
// or not: PRTS 状态机, an enemy's ATTACK / COMBAT state "攻击结束后回退到MOVE状态" and checks only 异常状态 every frame
+2 -2
View File
@@ -84,7 +84,7 @@ const BY_KEY = {
}, { priority: PRIO_BAND_REVIVE });
},
// 克莱门莎 崇高牺牲
// 克莱门莎 崇高牺牲: PRTS requires the 阿戈尔 bond to be active.
act1autochess_band13_buff(battle, ps, p, bandId) {
const bond = typeof p.bond_id === 'string' && p.bond_id ? p.bond_id : null;
if (!bond) return;
@@ -93,7 +93,7 @@ const BY_KEY = {
const u = c.unit;
if (c.reason !== 'killed' || !isOp(u) || u.ownerId !== ps.playerId || !unitBonds(u).includes(bond)) return;
const n = byLevel ? tierOf(u) : Math.max(1, Math.floor(num(p.value, 1)));
if (gainLayers(battle, { playerId: ps.playerId, bonds: bond, n, requireActive: false, source: u, reason: 'band' }) > 0) {
if (gainLayers(battle, { playerId: ps.playerId, bonds: bond, n, requireActive: true, source: u, reason: 'band' }) > 0) {
fxOn(battle, 'layer', u, keyOf(bandId), bandId, { bond, n });
}
});
+10 -7
View File
@@ -621,13 +621,16 @@ function kitGun(ab, e, b) {
},
];
if (hidden) {
list.push({
iv: 0.25,
tick(b2, e2) { // 【未尽的告解】 physical / arts damage taken ×4.damage_scale while the 碎铳之簧 it came with remain
const springs = b2.enemies.some((o) => o.alive && isSpring(o));
if (springs) { const m = T(ab, '4.damage_scale') ?? 1; b2.addBuff(e2, { key: 'boss:confession', duration: 0.35, refresh: 'replace', mods: { physTakenMul: m, artsTakenMul: m } }); }
},
});
// PRTS 未尽的告解: another gun OR spring keeps the reduction. Refresh on hit as well so the first hit and
// a hit immediately after the last companion dies see the current condition, not a stale polling result.
const confession = (b2, e2) => {
const other = b2.enemies.some((o) => o !== e2 && o.alive && (isGun(o) || isSpring(o)));
if (other) {
const m = T(ab, '4.damage_scale') ?? 1;
b2.addBuff(e2, { key: 'boss:confession', duration: 0.35, refresh: 'replace', mods: { physTakenMul: m, artsTakenMul: m } });
} else b2.removeBuff(e2, 'boss:confession');
};
list.push({ iv: 0.25, tick: confession, hitIn(c, b2, e2) { confession(b2, e2); } });
if (s3) list.push({
iv: s3.bb.interval ?? 1,
tick(b2, e2) { // 【盲信之誓】 links: phys per second on operators standing on a line ("无视无法选择、迷彩": 起飞 too)
+4 -1
View File
@@ -138,7 +138,10 @@ const nthAttackStatus = (n, key, dur, sil = true) => ({
attack(c, b, e, a) {
a.n++;
if (a.n % n) return;
for (const t of c.targets) if (t.alive && dur > 0) b.applyStatus(t, key, { duration: dur, source: e });
for (const t of c.targets) {
if (!t.alive || !(dur > 0) || (c.targetDeployments && c.targetDeployments.get(t) !== t.deploySeq)) continue;
b.applyStatus(t, key, { duration: dur, source: e });
}
},
});
+3 -3
View File
@@ -735,7 +735,7 @@ const BY_ITEM = {
}
});
},
// 黄沙罗盘: 初始技力 +init_sp each deploy; (萨尔贡) first skill end +sp; + 萨尔贡浓茶: each cast all 萨尔贡 +addition_sp
// 黄沙罗盘 (PRTS: all its SP ignores 阻回): 初始技力 +init_sp each deploy; (萨尔贡) first skill end +sp; + 萨尔贡浓茶: each cast all 萨尔贡 +addition_sp
chess_item_6_07_e(battle, u, rec, S) {
const p = bp(rec, 'act1autochess_equip_acarm103_global_buff');
if (!p) return;
@@ -749,14 +749,14 @@ const BY_ITEM = {
S.on('skillEnd', (c) => {
if (c.unit !== u || firstDone || c.reason === 'death' || !memberOf(battle, u, 'sargonShip')) return;
firstDone = true;
if (u.skill && u.alive) u.skill.gainSp(num(p.sp), 'item');
if (u.skill && u.alive) u.skill.gainSp(num(p.sp), 'item', false, { ignoreLock: true });
});
S.on('skillStart', (c) => {
if (c.unit !== u || !partner || !memberOf(battle, u, 'sargonShip') || !carries(battle, u, partner)) return;
const n = num(p.addition_sp);
if (!(n > 0)) return;
for (const a of battle.allyUnits) {
if (a.kind === 'op' && a.ownerId === u.ownerId && onField(a) && a.skill && memberOf(battle, a, 'sargonShip')) a.skill.gainSp(n, 'item');
if (a.kind === 'op' && a.ownerId === u.ownerId && onField(a) && a.skill && memberOf(battle, a, 'sargonShip')) a.skill.gainSp(n, 'item', false, { ignoreLock: true });
}
});
},
@@ -82,7 +82,7 @@ function skadi2(bb, chess, def) {
if (unit.skill) unit.skill.addTriggerRange(() => seaborns(battle, unit));
let n = 0;
battle.every(0.5, () => {
if (!unit.canAct) return;
if (!live(unit)) return; // the continuous trait (including its skill conversion) survives stun
for (let i = unit.buffs.length - 1; i >= 0; i--) if (isInspire(unit.buffs[i])) battle.removeBuff(unit, unit.buffs[i]);
n++;
const toks = seaborns(battle, unit);
+1 -1
View File
@@ -19,7 +19,7 @@ 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 FIRST_BOMB = 0.5; // [ASSUMED] PRTS lists the landing delay as unknown.
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 ?? {};
const tile = (u) => [Math.round(u.y), Math.round(u.x)];
+2 -1
View File
@@ -430,7 +430,8 @@ export function bardRegen(battle, bard, ally, value, duration = BARD_REGEN_DUR)
const installBard = (battle, unit) => {
battle.every(BARD_REGEN_IV, () => {
if (!unit.canAct) return;
// Continuous regeneration is an aura, so stun does not stop it.
if (!unit.alive || !unit.deployed || unit.hidden) return;
const v = unit.s.atk * (unit.profile.auraRatio ?? 0.1);
for (const ally of battle.alliesInGrid(unit)) bardRegen(battle, unit, ally, v);
}, { owner: unit });
+4 -3
View File
@@ -322,11 +322,12 @@ export class SkillRuntime {
}
/** Add SP (fires the `spGain` hook). Ignored while a duration/ammo/toggle skill runs (its bar shows the skill). */
gainSp(amount, reason = 'time', silent = false) {
// ignoreLock is reserved for explicitly documented exceptions (黄沙罗盘), not ordinary SP gifts.
gainSp(amount, reason = 'time', silent = false, { ignoreLock = false } = {}) {
if (this.noSkill || this.kind === 'passive' || !(amount > 0)) return 0;
if (this.active && this.isTimed && reason !== 'init') return 0;
if (this.active && this.isTimed && reason !== 'init' && !ignoreLock) return 0;
// 阻回 (the operators' 凋亡 burst, damage.js): "停止并阻止任意形式的技力回复" — no SP of any kind (time, attack, hurt, gifts)
if (reason !== 'init' && this.unit.s.flags.noSp) return 0;
if (reason !== 'init' && !ignoreLock && this.unit.s.flags.noSp) return 0;
let cost = this.spCost;
if (this.charges >= this.maxCharges && this.sp >= cost) return 0;
let amt = amount;