0.2.5-dev: community round 30 and follow-ups

- Stock: fixed trait gifts (缪尔赛思 / 变形者集群 变形同构体, 耶拉 不融冰, 蜜蜡 / 卡涅利安 迅捷作战粮, 诗怀雅 盟约之币) still occupy stock but are granted past the cap; an operator card already shown in the shop or a reward can be taken after the pool runs out and overdraws (GitHub #498, the owner's decision); item buys and item rewards keep the stock check. 洛洛 / 焰尾 sub-pools use the measured weights (PR #497 by @2321Robin; the occupancy idea of PR #496 by @2321Robin re-implemented differently).
- Boss pairs are drawn at random once per match and fixed to battle start; the Hidden Core reuses them; a leaver does not split a full pair (the owner's decision).
- 阿戈尔 in 联防 no longer re-applies the opening devour HP loss or a false revive; Touch's team medic stays for the match after its holder leaves (owner-approved, official lifetime unverified).
- Auto-skill ranges: 司霆惊蛰 S2, 斩业星熊 S3 against air, 予愿安洁莉娜 S3; 自在's post-rebirth invulnerability is also untargetable (#510); Horn / Clementia / catapult / 忍冬 first hit (PR #502 by @Xinglan233); 不融冰 / tear gas trigger on real damage.
- Bond layer-up sound in round start / draft / prep / battle (#482): one channel, a new cue replaces the old, no extra cooldown; a prep update that adds layers and activates a bond plays one cue.
- Client: multi-swing fix (#506), refresh single-flight (PR #507 by @robll-v1), LP tower keeps the settled value with a −N tick (PR #504 by @robll-v1), export with only 潜能 / 练度 changes (PR #503 by @Xinglan233), spectator rosters list DIY members (PR #505 by @Xinglan233), banned members beside bond tooltips (PR #494 by @YuriRestia), recovery action above the away overlay (PR #511 by @RainoutOvO), the /dev/sim-check.html consistency page (PR #492 by @Cloudnyco), Korean (PR #490 by @D-Roote) and English Victoria wording (PR #499 by @YuriRestia).
- golden: 45 of 287 scenarios move against 0.2.4 (roster 17, bonds 3, fields 6, matches 17, standins 2), each attributed in docs/history/0.2.5.md §30.9 / §30.16.
This commit is contained in:
sganggs committed 2026-10-12 14:07:08 +08:00
1 parent bc50cac01f
commit 221e6b8dd2
134 files changed
+4590 -1201

No files matched your search

+2 -2
View File
@@ -31,8 +31,8 @@
// elite or not) stand on the field get ATK +atk
// act2autochess_band3_buff 伊奥莱塔 统御号令 at combat start n = elite operators on the field ⇒ every elite operator
// ATK +atk_per_cnt × n, HP +max_hp_per_cnt × n (all 直接乘算)
// auto_chess_change_map Touch 外勤医疗 "所有玩家场地上出现一名<预备干员-医疗>": while any alive player of the match
// holds band_amedic, every player's field gets the map character of the
// auto_chess_change_map Touch 外勤医疗 "所有玩家场地上出现一名<预备干员-医疗>": once selected in this match, its
// retained team grant gives every player's field the map character of the
// variant whose common_condition holds for that player's elites on the field
// (condition_golden_chess_le cnt → 预备干员-医疗, _ge cnt → Touch), spawned at
// combat start on its stage slot (tokens.spawnMapChar)
+5 -1
View File
@@ -457,7 +457,11 @@ function devour(battle, pid, bb, members) {
// start, resolves nothing (`knocked`: it is off the field)
if (knocked(t)) continue;
S.fxOn(battle, 'devour', t, 'bond:egirShip', 'devour', { from: m.id });
if (amount > 0) battle.loseHp(t, mitigate(amount, 'phys', t.s), { source: m, tags: ['bond:egir:devour'] });
// The maintainer's 2026-10-11 联防 carry decision: reconstruct marks/attributes, but do not apply a second
// opening HP loss (or trigger deaths/revives) to an operator restored from the previous field. Battle.start
// applies its exact HP ratio and SP after the opening hooks. Fresh units and all other battle kinds still pay.
const restored = battle.kind === 'unite' && Number.isFinite(t.carry?.hpPct) && t.carry.hpPct > 0;
if (amount > 0 && !restored) battle.loseHp(t, mitigate(amount, 'phys', t.s), { source: m, tags: ['bond:egir:devour'] });
if (!layered.has(t)) {
layered.add(t);
S.gainLayers(battle, { playerId: pid, bonds: 'egirShip', n: S.tierOf(t), source: m, reason: 'bond' });
+4 -2
View File
@@ -337,10 +337,11 @@ function rebirthCleanse(b, e) {
* (no move / attack / skill).
* `onKo(b, e, a)` runs at once (self-destruct, freeing prisoners …), `during(b, e, a, elapsed)` every tick of it; then the
* enemy stands up with `hpRatio` of its max HP, `onReborn(b, e, a)` switches the form and `invincible` s of 无敌 follow.
* `invincibleUntargetable` opts a verified enemy into hostile selection immunity for that same window (Xi, #510).
* `a.state`: undefined → 'reborn' → 'form2'. The 'telegraph' fx of the knock-out carries `form: 'reborn'` and the 'revive'
* fx `form: 'form2'`: models with those clip sets (render/units.js FORMS — 锏, 扎罗, “复仇者”, 杰斯顿) play them.
*/
function reborn({ dur = 0, hpRatio = 1, invincible = 0, onKo = null, during = null, onReborn = null, key = 'ab:reborn' } = {}) {
function reborn({ dur = 0, hpRatio = 1, invincible = 0, invincibleUntargetable = false, onKo = null, during = null, onReborn = null, key = 'ab:reborn' } = {}) {
const finish = (b, e, a) => {
if (!e.alive || a.state !== 'reborn') return;
a.state = 'form2';
@@ -348,7 +349,8 @@ function reborn({ dur = 0, hpRatio = 1, invincible = 0, onKo = null, during = nu
e.profile.noAttack = a.noAtk;
if (onReborn) safe(b, e, () => onReborn(b, e, a));
e.hp = Math.max(1, e.s.maxHp * Math.max(0, Math.min(1, hpRatio)));
if (invincible > 0) b.addBuff(e, { key: `${key}:inv`, duration: invincible, visible: true, flags: { invulnerable: true } });
if (invincible > 0) b.addBuff(e, { key: `${key}:inv`, duration: invincible, visible: true,
flags: { invulnerable: true, ...(invincibleUntargetable ? { untargetable: true } : {}) } });
if (e.route) e.route.pts = null;
e.atkCd = 0;
rebirthCooldowns(e);
+3
View File
@@ -473,6 +473,9 @@ function kitXi(ab) {
};
const re = reborn({
dur: T(ab, 'reborn.duration') ?? 0, invincible: T(ab, 'reborn.invincible') ?? 0,
// #510: Xi's post-rebirth 无敌 also prevents hostile selection (PRTS “自在” / 无敌).
// Explicit opt-in: the engine's general invulnerable flag remains damage protection only.
invincibleUntargetable: true,
onKo(b, e) { b.removeBuff(e, 'ab:xiShield'); },
onReborn(b, e) {
P.form2 = true;
+3 -1
View File
@@ -412,7 +412,9 @@ export const SPECIAL_KITS = Object.freeze({
// report of 2026-10-06, item 24); it picks by 仇恨值, not its blocker first
spawn(b, e) { e.profile.blockFree = true; },
dealt(c, b, e) {
const n = T(ab, 'Attack.attack@times') ?? 1, r = T(ab, 'Attack.attack@projectile_range') ?? 0;
// PRTS 自制投石机: normal stones splash in radius 1.5. The talent's projectile_range (0.5) belongs to
// the separate 入戏 random-offset attack, not its ordinary volley in this mode.
const n = T(ab, 'Attack.attack@times') ?? 1, r = 1.5;
const t = c.target;
for (let i = 1; i < n; i++) hurt(b, e, t, e.s.atk, 'phys');
// the stone's splash ("碰撞无视迷彩"): an area selection — no unblocking 隐匿 ally
+1 -1
View File
@@ -239,7 +239,7 @@ H.SERVER_GAIN_EQUIP = {
run(ctx) {
const { bb, bbStr } = ctx.source;
if (!ctx.gd.item(bbStr.chess)) return;
for (let i = 0; i < Math.min(10, num(bb.count, 1)); i++) ctx.grantItem(bbStr.chess);
for (let i = 0; i < Math.min(10, num(bb.count, 1)); i++) ctx.grantItem(bbStr.chess, { requirePool: false });
},
};
+15 -5
View File
@@ -33,7 +33,7 @@
// 0.2.0 both revived in place (`fatal` savers at −101 / −110): no 被击倒时 / 部署时 effect fired (community report
// 「像砾和瑕光这种死亡和部署的叠层效果,如果有艾芒加德的3次复活似乎是无法触发」).
// 骑士戒律's free in-skill undying runs early (20). Flat damage reduction 'hit' −10 (after the other damage modifiers).
// Proc damage dealt by items carries the tag 'item' and never re-triggers item procs.
// Proc damage dealt by items carries the tag 'item'; damage-producing item riders exclude it to avoid recursion.
//
// 蒸汽之心 (Victoria carrier) — LIVE model: the hammer types (灼燃/坚固/加速/战栗维式重锤) carried by the owner's operators
// that are on the field right now (cached ≤ 0.5 s, dropped whenever an operator deploys or leaves) are granted to the
@@ -389,6 +389,15 @@ function onAttackEnemies(S, u, fn) {
});
}
/** Actual carrier damage to enemies, including DoT/skill/splash. Element gauge fill and HP loss are not damage. */
function onDamageEnemies(S, u, fn) {
S.on('damaged', (c) => {
if (c.source !== u || c.type === 'element' || isHpLoss(c.dmg) || !(c.amount > 0)
|| !c.target || c.target.side !== 'enemy' || !hasHp(c.target)) return;
fn(c.target, c);
});
}
/** Generic buff-key behaviours (shared by several items). */
const BY_BUFF = {
// 源石溶剂: the text's "每秒流失 damage 点生命值" is officially 每秒受到 damage 点真实伤害 (PRTS 盟约记录 修正 "并非流失", 备注
@@ -636,18 +645,19 @@ const BY_ITEM = {
fxOn(battle, 'revive', u, 'item:chess_item_4_12_e', rec.id, { left: max - used });
}, PRIO_RESPAWN); // a knock-out: after every 不死 (the hammer's lock), whatever the equip order (header)
},
// 催泪瓦斯: on attack prob ⇒ 1 麻痹 stack
// PRTS 盟约记录 corrects both items' "攻击时" to "造成伤害时" (#489); no attack event is required.
// 催泪瓦斯: on damage prob ⇒ 1 麻痹 stack
chess_item_5_01_e(battle, u, rec, S) {
const p = bp(rec, 'act1autochess_equip_acarm058_global_buff');
const pr = p ? num(p.prob) : 0;
if (pr > 0) onAttackEnemies(S, u, (t) => { if (chance(battle, pr)) battle.applyStatus(t, 'palsy', { value: 1, source: u }); });
if (pr > 0) onDamageEnemies(S, u, (t) => { if (chance(battle, pr)) battle.applyStatus(t, 'palsy', { value: 1, source: u }); });
},
// 谢拉格不融冰: on attack prob ⇒ 寒冷 `cold` s
// 谢拉格不融冰: on damage prob ⇒ 寒冷 `cold` s
chess_item_5_02_e(battle, u, rec, S) {
const p = bp(rec, 'act1vautochess_equip_acarm003_global_buff');
if (!p) return;
const pr = num(p.prob), d = num(p.cold);
if (pr > 0 && d > 0) onAttackEnemies(S, u, (t) => { if (chance(battle, pr)) battle.applyStatus(t, 'cold', { duration: d, source: u }); });
if (pr > 0 && d > 0) onDamageEnemies(S, u, (t) => { if (chance(battle, pr)) battle.applyStatus(t, 'cold', { duration: d, source: u }); });
},
// 双模机械臂: physical/arts damage becomes 弱点伤害 (whichever type the target resists less)
chess_item_5_03_e(battle, u, rec, S) {
@@ -94,8 +94,9 @@ export default {
battle.on('damaged', (ctx) => {
const e = ctx.target;
if (busy || ctx.source !== unit || e.side !== 'enemy' || ctx.type === 'element' || ctx.dmg?.tags?.includes('vulpisHunt')) return;
const t = marks.get(e.id);
if (t == null) { marks.set(e.id, battle.time); return; }
let t = marks.get(e.id);
// PRTS talent remarks: the first damage instance also receives the arts addition.
if (t == null) { t = battle.time; marks.set(e.id, t); }
if (battle.time - t <= num(t0.interval, 10) + 1e-9 && e.alive) {
busy = true;
// PRTS 备注 "伤害类型为法术附加伤害": tag addition (no 叙拉古 6 roll)
@@ -50,7 +50,7 @@ export default {
}),
skchr_horn_2: () => ({
kind: 'ammo', ammo: s2Ammo,
attack: { atkScale: num(bb['attack@s2.atk_scale'], 1) },
attack: { atkScale: num(bb['attack@s2.atk_scale'], 1), fortressMeleeSplash: true },
onStart({ unit }) { unit.mem.hornS2Over = false; },
onEnd({ unit }) { unit.mem.hornS2Over = false; },
}),
+2
View File
@@ -229,6 +229,8 @@ export default {
},
[S3]: {
kind: 'ammo',
// The skill's initial attack area includes the eight surrounding tiles as well as its 3-9 grid.
trigger: { rule: 'ACTIVE_RANGE', grid: g39x4 },
ammo: Math.max(1, Math.floor(num(b3['attack@trigger_time'], 1))),
mods: { atkPct: num(b3.atk) }, flags: LIFTOFF,
targeting: s3Targeting,
+1 -1
View File
@@ -222,7 +222,7 @@ export default {
}]);
}
battle.on('elementBurst', ({ target, element }) => {
if (element !== 'erosion' || !up(unit) || !unit.skill?.active || unit.skill.id !== S3
if (element !== 'erosion' || target?.side !== 'enemy' || !up(unit) || !unit.skill?.active || unit.skill.id !== S3
|| !canTargetEnemy(unit, target, GROUND)) return;
unit.mem.clemntBombs?.marks.set(target, battle.time + num(b3.ep_break_duration, 1));
}, { owner: unit });
+6 -1
View File
@@ -53,7 +53,7 @@
// 帮助 lists the 上半 row that did as removed in 下半, so in a fight the skill simply runs its `duration`.
import { num, talentBb, traitBb, skillRec, up, byEnemyAttack } from '../shared/tier1.js';
import { canTargetEnemy } from '../../../targeting.js';
import { absoluteRangeKeys, canTargetEnemy } from '../../../targeting.js';
import { bodyInRadius } from '../../../body.js';
import { hasHp } from '../../../damage.js';
import { COLS } from '../../../constants.js';
@@ -256,6 +256,11 @@ export default {
} },
],
install(battle, unit) {
// S3 attacks air as well: its expanded auto-cast area must not inherit the inactive ground-only profile.
if (unit.skill?.id === S3) unit.skill.addTriggerRange(() => [{
keys: absoluteRangeKeys(skillRec(chess, S3)?.rangeGrid ?? X2, unit.tileR, unit.tileC, unit.dir, unit.s.baseRangeExtend || 0),
profile: { canHitFly: true },
}]);
// S1 恶业苦果: every enemy damage instance she takes (the official hsgma2_s_1 on ON_TAKE_DAMAGE, not its attacks only:
// tier1 byEnemyAttack) ⇒ atk_scale × ATK arts back to its source
battle.on('damaged', (c) => {
+11
View File
@@ -294,6 +294,17 @@ export default {
],
install(battle, unit) {
const id = unit.skill?.id;
if (id === S2) {
// The owner's larger-range auto-cast rule also covers this terrain-dependent range (no static rangeId).
// Keep SEARCH when the actual running area does not strictly contain her initial range.
unit.skill.addTriggerRange(() => {
const extend = unit.s.baseRangeExtend || 0;
const keys = absoluteRangeKeys(floodGrid(battle, unit), unit.tileR, unit.tileC, unit.dir, extend);
const initial = absoluteRangeKeys(unit.def.rangeGrid, unit.tileR, unit.tileC, unit.dir, extend);
const set = new Set(keys);
return set.size > initial.length && initial.every(k => set.has(k)) ? [{ keys, profile: AIR }] : [];
});
}
if (id === S1) {
// 充能耗尽前特性不重置: the trait resets the ramp on skillEnd (priority 0) — keep it while a charge remains
battle.on('skillEnd', (c) => {
+1 -1
View File
@@ -129,7 +129,7 @@ export function contentInfo(battle, pid) {
const ci = player(battle, pid)?.input?.contentInfo;
return ci && typeof ci === 'object' ? ci : {};
}
/** Band ids of every alive player of the match (own first); falls back to this battle's players. */
/** Alive players' bands (own first), plus a retained Touch team grant; falls back to this battle's players. */
export function matchBands(battle, pid) {
const mb = contentInfo(battle, pid).matchBands;
if (Array.isArray(mb) && mb.length) return mb;
+6 -2
View File
@@ -6,7 +6,7 @@
// handUnits pieces in the 整备区 (hand, 10 slots; operators, items and summon stacks),
// handChess operators in the hand,
// roundStats { refreshes, buys, sells, spent, gainedChess, arts } (this round's prep, ps.round),
// matchBands band ids of every alive player (this player first),
// matchBands band ids of every alive player (this player first), plus a retained Touch team grant,
// teammates [{ playerId, bandId }] of the other alive players }
// Global handlers run first in every dispatch, so band / bond / garrison / item handlers of the same onBattleStart can
// read or extend `ev.input.contentInfo`.
@@ -26,6 +26,10 @@ export function registerSupportMeta(registry) {
const hand = ctx.hand();
const rs = ctx.roundStats();
const mates = ctx.teammates();
const matchBands = [ctx.bandId(), ...mates.map(t => t.bandId())].filter(Boolean);
// [ASSUMED] 外勤医疗 is a team grant for this match, not an aura revoked when its holder is eliminated.
// Other team strategies and the teammate contexts still require a living player.
if (ctx.teamMedicGranted?.() && !matchBands.includes('band_amedic')) matchBands.push('band_amedic');
ev.input.contentInfo = {
round: ctx.round,
kind: ev.kind ?? null,
@@ -37,7 +41,7 @@ export function registerSupportMeta(registry) {
refreshes: rs.refreshes | 0, buys: rs.buys | 0, sells: rs.sells | 0, spent: rs.spent | 0,
gainedChess: rs.gainedChess | 0, arts: rs.arts | 0,
},
matchBands: [ctx.bandId(), ...mates.map((t) => t.bandId())].filter(Boolean),
matchBands,
teammates: mates.map((t) => ({ playerId: t.playerId, bandId: t.bandId() })),
};
},