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.