0.2.3: prepare follow-up fixes and client features (#407, #408, #410, #413, #431, #435, #436, #440, #444)

Add special-target activation without bypassing attack cadence, cooperative
card preferences and emotes, skill sound banks, per-operator voice choices,
text sizing, install prompts, closed-tab recovery and unanimous setup rerolls.
Retain late deployment elapsed time and resolve echo/counter pulses without
recursive hook overflow. Independent final review remains pending.

Golden: roster-016, roster-035, roster-036, roster-045;
boss-boss_1-pair, boss-boss_3-pair, boss-boss_5-pair, boss-boss_7-pair,
hidden-boss_9-pair; solo-HARD-9-boosted: Lemuen attack cadence.
diy-132: Necras extra activation targets.
coop2-NORMAL-14-standins: cooperative purchase preference and simulation fixes.
hidden-boss_10-solo: echo pulse FIFO resolution.
See DESIGN 28.13-28.24 for assumptions, sources and contributor credit.
This commit is contained in:
sganggs committed 2026-10-09 22:55:24 +08:00
1 parent 314564c139
commit f3658a0dd4
104 files changed
+3674 -741

No files matched your search

+68
View File
@@ -141,6 +141,74 @@ function firstMatching(banks, event, abilities, ok = () => true) {
const SKILL_MODE_FILE = /_(d|h|s)\d*\.mp3$/i;
/** A bank of an operator's normal attack: none of its files belongs to a skill mode. */
export const normalModeBank = (paths) => Array.isArray(paths) && paths.length > 0 && !paths.some((p) => SKILL_MODE_FILE.test(p));
/**
* The skill mode of a sound file: 'd' / 'h' / 's' for 技能1 / 2 / 3, null for a normal-mode (`_n`) file. The mode letter
* is the official naming of a skill's own sounds — it holds for the whole chain of a skill (原声: `p_skill_lzxqlkl_s`
* 技能3发动, `p_atk_lzxqlkl_s` 技能3的攻击, `p_imp_…_s` 技能3命中): 司霆惊蛰's three skills have the activation sounds
* `_d` / `_h` / `_s` and the matching attack banks `p_atk_lzxqlkl_d1` / `_h` / `_s`, and every operator whose activation
* sounds name a mode agrees with its slot (§ pickModeAttacks).
*/
export function skillModeLetter(path) {
const m = SKILL_MODE_FILE.exec(typeof path === 'string' ? path : '');
return m ? m[1].toLowerCase() : null;
}
/** [ASSUMED] Slot fallback for operators without a named activation sound; DESIGN §28.17. */
export const SLOT_MODE_LETTER = Object.freeze({ 0: 'd', 1: 'h', 2: 's' });
/**
* The per-skill-mode bank of one unit, by mode letter: `{ d?: string[], h?: string[], s?: string[] }`.
*
* Why a second table: `pickUnitSfx` builds the operator's NORMAL attack / impact, and for that it refuses every
* skill-mode file (`_d` / `_h` / `_s`) — 银灰's S3 swing is not his normal attack. An operator whose library is all
* skill modes has therefore no normal bank at all: 司霆惊蛰's attack banks are `ON_ABILITY_ON…attack.1.2.1`
* (`p_atk_lzxqlkl_d1`, 技能1), `…attack.4` (`_h2` / `_h`, 技能2) and `…attack.7` (`_s`, 技能3) — she never has a plain
* bank, because (解放者) she only attacks while a skill runs. The official client plays that mode's own files during the
* skill, so the manifest needs them per skill index (plan.mjs writes `sfx.units[id].attacks` / `hits` = the mode's
* attack / impact sound, public/js/audio.js plays them while that skill is active).
*
* `events` picks which banks count: the attack is cast / held (`ON_ABILITY_START` preferred over `ON_ABILITY_ON`, the
* order pickUnitSfx prefers), the impact is the hit (`ON_ABILITY_HIT`). Among equals the plain ability wins over a
* numbered variant, then the numeric order. 赤刃明霄陈 S3 (the official banks, `attack.3`):
* `ON_ABILITY_START` → `p_atk_hljdswd_s` (the swing: different from her normal `p_atk_hljdswd_n`),
* `ON_ABILITY_HIT` → `p_imp_hljdswd_s` (the slash's impact: different from `p_imp_hljdswd_n`).
* @param {Map<string,string[]>|undefined} banks unit bank table (from indexAudio().unitBanks)
* @param {string[]} events official event prefixes to read
* @returns {{ d?: string[], h?: string[], s?: string[] }}
*/
export function pickModeBanks(banks, events) {
const cands = new Map(); // letter → [{ rank, rest, paths }]
if (!banks || !banks.size) return {};
for (const [name, paths] of banks) {
const ev = events.find((e) => name.startsWith(e + '.'));
if (!ev || !Array.isArray(paths) || !paths.length) continue;
const ability = name.slice(ev.length + 1).split('.')[0];
if (!/attack|combat/i.test(ability)) continue;
const letter = skillModeLetter(paths[0]);
if (!letter || paths.some((p) => skillModeLetter(p) !== letter)) continue;
const rest = name.slice(ev.length + 1 + ability.length);
const numbered = rest ? 1 : 0; // 'attack.1.2.1' / 'attack.4' vs the plain 'attack'
const rank = `${events.indexOf(ev)}${numbered}`;
if (!cands.has(letter)) cands.set(letter, []);
cands.get(letter).push({ rank, rest, paths });
}
const out = {};
for (const [letter, list] of cands) {
list.sort((a, b) => (a.rank !== b.rank ? (a.rank < b.rank ? -1 : 1) : abilityOrder(a.rest, b.rest)));
out[letter] = list[0].paths;
}
return out;
}
/** The per-skill-mode ATTACK bank of one unit (the swing / cast while that skill runs) — see pickModeBanks. */
export const pickModeAttacks = (banks) => pickModeBanks(banks, ['ON_ABILITY_START', 'ON_ABILITY_ON']);
/**
* The per-skill-mode IMPACT bank of one unit (the hit sound while that skill runs): 赤刃明霄陈's S3 slashes hit with
* `p_imp_hljdswd_s`, not her normal `p_imp_hljdswd_n`. A mode without an `ON_ABILITY_HIT` bank keeps the unit's normal
* impact (司霆惊蛰's S3 has none: only its 技能2 swings `_h` and impacts `_h`).
*/
export const pickModeHits = (banks) => pickModeBanks(banks, ['ON_ABILITY_HIT']);
/**
* Pick role → candidate sound paths for one unit.
+30 -1
View File
@@ -23,7 +23,7 @@
import { RAW, joinUrl, safeName, urlBase, urlDir } from './sources.mjs';
import { kindOf } from './formats.mjs';
import { pickUnitSfx, UI_SFX, BATTLE_SFX, resolveSpec, indexVoice, VOICE_DIRS, VOICE_BATTLE_SLOTS } from './audio.mjs';
import { pickUnitSfx, pickModeAttacks, pickModeHits, skillModeLetter, SLOT_MODE_LETTER, UI_SFX, BATTLE_SFX, resolveSpec, indexVoice, VOICE_DIRS, VOICE_BATTLE_SLOTS } from './audio.mjs';
import { literal } from './manifest.mjs';
import { EMOTE_CATALOG } from '../../shared/constants.js';
@@ -387,6 +387,19 @@ export function buildPlan({ assets07, ops03, enemies05, maps05, audio, modelsDat
born: audio.bank(`battle.ON_PROJECTILE_BORN.projectile_chr_${short}`), hit: audio.bank(`battle.ON_PROJECTILE_HIT.projectile_chr_${short}`) } });
const { roles: u, mix } = unitSounds(audio, sfx);
const skillSfx = {};
// Per-skill ATTACK / IMPACT sound (audio.mjs pickModeAttacks / pickModeHits): a skill mode has its own swing and its
// own hit sound, and neither may play as the unit's normal attack / impact (银灰, 纯烬艾雅法拉). An operator whose
// every attack belongs to a skill mode has no normal bank at all (司霆惊蛰 — 解放者, she only attacks while a skill
// runs — the report 「三技能攻击没有音效」); 赤刃明霄陈's S3 slashes swing `p_atk_hljdswd_s` and land `p_imp_hljdswd_s`
// where her normal attack uses the `_n` files (the report 「斩击音效应该和普通攻击不一样」). The mode letter of a skill
// index is read from that skill's own activation sound (`p_skill_lzxqlkl_s` → s = 技能3); without one (most operators
// carry no activation sound) we infer 技能1 / 2 / 3 = d / h / s ([ASSUMED], below). The client plays
// them while that skill is active (public/js/audio.js unit()).
const modeAttacks = pickModeAttacks(audio.unitBanks.get(id));
const modeHits = pickModeHits(audio.unitBanks.get(id));
const attacks = {};
const hits = {};
const attackMix = {}, hitMix = {};
for (const i of idx) {
const s = (o.skills || []).find((k) => k.index === i);
if (!s) { notes.push(`${id}: skill index ${i} missing in research data`); continue; }
@@ -395,10 +408,26 @@ export function buildPlan({ assets07, ops03, enemies05, maps05, audio, modelsDat
if (s.skillId) skillsById[s.skillId] = iconId;
const ss = audio.skillBanks.get(s.skillId)?.get('ON_SKILL_START');
if (ss?.length) skillSfx[String(i)] = soundLeaf(ss);
// [ASSUMED] Without a named activation mode, infer d/h/s from slot 1/2/3; see DESIGN §28.17.
const letter = skillModeLetter(ss?.[0]) ?? SLOT_MODE_LETTER[i];
if (letter && modeAttacks[letter]) {
attacks[String(i)] = soundLeaf(modeAttacks[letter]);
const m = audio.mixOf?.(modeAttacks[letter]);
if (m) attackMix[String(i)] = m;
}
if (letter && modeHits[letter]) {
hits[String(i)] = soundLeaf(modeHits[letter]);
const m = audio.mixOf?.(modeHits[letter]);
if (m) hitMix[String(i)] = m;
}
}
const primarySkill = skillSfx[String(idx[0])];
if (primarySkill) u.skill = primarySkill;
if (Object.keys(skillSfx).length > 1) u.skills = skillSfx;
if (Object.keys(attacks).length) u.attacks = attacks;
if (Object.keys(hits).length) u.hits = hits;
if (Object.keys(attackMix).length) u.attackMix = attackMix;
if (Object.keys(hitMix).length) u.hitMix = hitMix;
if (mix) u.mix = mix;
if (Object.keys(u).length) unitsSfx[id] = u;
}
+3
View File
@@ -178,6 +178,9 @@ function runOne(cfg, seed) {
}, { maxSteps: 5e6 });
rec.ms = performance.now() - t0;
rec.errors = errors.length + m.errorCount;
// Keep diagnostics in the JSON so a nonzero benchmark count is actionable.
if (errors.length) rec.errorMessages = errors;
if (m.errors.length) rec.matchErrors = m.errors;
rec.victory = !!(summary && summary.victory);
rec.roundsPassed = summary ? summary.roundsPassed : 0;
rec.hidden = !!(summary && summary.hiddenReached);