0.2.6-dev: merge claude/bw-l01 — host bandwidth layers 0 and 1 (no BGM download while the music is silent, WebSocket permessage-deflate, lossless WebP 玩法说明 pages, traffic counters, content-hashed asset URLs with immutable caching, brotli q5) on top of the 0.2.5 release; its note becomes DESIGN §31 in docs/history/0.2.6.md, CHANGELOG 0.2.6-dev, version 0.2.6-dev; golden: unchanged

This commit is contained in:
sganggs committed 2026-10-12 18:28:50 +08:00
commit 3d5d6b380d
47 files changed
+2325 -136

No files matched your search

+27 -4
View File
@@ -13,7 +13,9 @@
// never block or warn; everything requested before that is remembered (BGM) or dropped (SFX).
// - Channels: master → { bgm, sfx, voice } gains; volumes from settings (0..1) + mute. Tab hidden ⇒ suspend.
// - BGM: `intro` then `loop` (1 s crossfade); switching tracks fades out/in (0.8 s). The same loop URL
// keeps playing across phases (prep and combat share a track).
// keeps playing across phases (prep and combat share a track). While the music is silent — muted, or the BGM volume
// at 0 — no track is downloaded (≈ 14 MB a match): the wanted one is remembered, another one playing stops, and the
// track the screen wants then starts once the music is audible again (`setVolumes`; host bandwidth, layer 0).
// - 开战 BGM: `bgm.combatAlts` are the mode's own battle tracks (塞壬唱片 骑士之日 / 无畏者). The track is fixed per
// round, not drawn: 无畏者 through rounds 1–7 and 骑士之日 from round 8 on (`combatTrackFor`, the official
// schedule), so every client of a match hears the same one, a fight never switches track halfway through and the
@@ -68,6 +70,7 @@
import { sanitizeVoiceOverrides, voiceLangFor } from './voicePrefs.js';
import { PHASE } from '../../shared/constants.js';
import { mediaUrl } from './media.js';
import { unhashedUrl } from '../../shared/assetPaths.js';
const MAX_VOICES = 8;
const UNIT_COOLDOWN_MS = 160;
@@ -191,11 +194,14 @@ export function unitSoundClass(info) {
return 'char';
}
/** URL of the generic token death sound (b_char_tokendead): sfx.battle.tokenDie, else next to charDie. */
/**
* URL of the generic token death sound (b_char_tokendead): sfx.battle.tokenDie, else next to charDie — without charDie's
* content-hash segment, which names charDie's bytes, not this file's (the plain URL is cached for a day).
*/
function tokenDieUrl(manifest) {
const b = manifest?.audio?.sfx?.battle;
if (typeof b?.tokenDie === 'string') return b.tokenDie;
return typeof b?.charDie === 'string' && /b_char_dead\.mp3$/.test(b.charDie) ? b.charDie.replace(/b_char_dead\.mp3$/, 'b_char_tokendead.mp3') : null;
return typeof b?.charDie === 'string' && /b_char_dead\.mp3$/.test(b.charDie) ? unhashedUrl(b.charDie).replace(/b_char_dead\.mp3$/, 'b_char_tokendead.mp3') : null;
}
/**
@@ -662,6 +668,7 @@ export class AudioManager {
*/
setVolumes(v) {
const n = (x, d) => (Number.isFinite(x) ? Math.max(0, Math.min(1, x)) : d);
const wasSilent = this.bgmSilent;
this.volumes = {
bgm: n(v?.bgm, this.volumes.bgm),
sfx: n(v?.sfx, this.volumes.sfx),
@@ -670,8 +677,13 @@ export class AudioManager {
};
this._applyVolumes();
if (this.volumes.muted || this.volumes.sfx <= 0) this.stopBondCue();
// the music became audible again: download and start the track the screen wants now (none was fetched meanwhile)
if (wasSilent && !this.bgmSilent && this.ctx && this.wantBgm) { const k = this.wantBgm; this.wantBgm = null; this.playBgm(k); }
}
/** Is the music silent — muted, or the BGM volume at 0? Then no BGM is downloaded (playBgm). */
get bgmSilent() { return this.volumes.muted || this.volumes.bgm <= 0; }
/**
* The voice dub (settings 语音语言): 'jp' plays `audio.voiceJp`, anything else `audio.voice` (中文, the default). The
* line on air finishes in its own dub; the next one follows the setting.
@@ -766,12 +778,23 @@ export class AudioManager {
// ---- BGM ------------------------------------------------------------------------------------------------
/**
* Switch BGM (null stops). Same loop URL ⇒ no restart.
* Switch BGM (null stops). Same loop URL ⇒ no restart. Before the first gesture, or while the music is silent
* (`bgmSilent`), the key is only remembered (`wantBgm`) and nothing is downloaded; a different track that is still
* playing stops. `_unlock` / `setVolumes` start the remembered track once it can be heard.
* @param {string|null} key see bgmKeyFor
*/
playBgm(key) {
try {
if (!this.ctx) { this.wantBgm = key; return; }
if (this.bgmSilent) {
this.wantBgm = key;
const silentTrack = resolveBgm(this.getManifest(), key);
if (this.bgm && silentTrack && this.bgm.loopUrl === silentTrack.loop) { this.bgm.key = key; return; }
++this.bgmToken; // a start still waiting for its download stays silent too
this._fadeOutBgm();
return;
}
this.wantBgm = null;
const track = resolveBgm(this.getManifest(), key);
if (this.bgm && track && this.bgm.loopUrl === track.loop) { this.bgm.key = key; return; }
if (!track && !this.bgm) return;
+82 -4
View File
@@ -5,19 +5,23 @@
// the room code and the reconnect token replaced and the page's host cut from every URL. A secret is a whole token in
// the prose and an exact string value in the attached battle JSON, so a name never rewrites a longer id. The battle
// on screen can be attached as its BattleSpec (the b.start a browser receives: player ids, no names), which the dev
// tools replay.
// tools replay. The report's `traffic:` line sums what this page downloaded (Resource Timing `transferSize`: bytes
// on the wire, 0 for a response from the browser cache) by the categories of shared/assetPaths.js trafficCategory,
// other sites apart as `external` (host bandwidth, layer 0; the server's own counters: /healthz?traffic=1).
//
// recordError(source, err, detail?) source: 'error' (uncaught) | 'rejection' (unhandled promise) | 'sim' (a content
// error the battle isolated) | 'runner' (the local battle runner) | 'request'
// (a failed request or error push the player was shown)
// errorLog() / errorCount() / clearErrorLog()
// setBattleSource(fn) the battle runner registers how to read the battle on screen
// installTrafficMeter() / recordResourceEntry(entry) / trafficSummary() / clearTraffic() the page's download totals
// buildDiagnostics(input) the report text (pure: the state, the environment and the battle are passed in)
// diagnosticsText({ state, settings, attachBattle }) the report of this page (this browser, the battle on screen)
//
// Report keys are English on purpose: the text is read by developers and searched in issues, whatever the UI language.
import { APP_VERSION } from '../../shared/constants.js';
import { TRAFFIC_CATEGORIES, trafficCategory } from '../../shared/assetPaths.js';
export const LOG_MAX = 40;
export const STACK_LINES = 6;
@@ -87,6 +91,78 @@ export const errorLog = () => log.map((e) => ({ ...e }));
export const errorCount = () => total;
export function clearErrorLog() { log.length = 0; total = 0; dropped = 0; }
// ---- traffic: what this page downloaded ---------------------------------------------------------------------
/** @type {Map<string, { requests: number, bytes: number, cached: number }>} category → totals */
const traffic = new Map();
let trafficMeter = null;
/**
* Add one Resource Timing entry (a PerformanceResourceTiming, or the page's own navigation entry) to the totals: bytes
* on the wire (`transferSize`; headers included, 0 when the browser cache answered — counted as `cached`). A same-origin
* URL counts under trafficCategory(path), another site's under `external`; data: / blob: URLs are not downloads.
* @param {{ name?: string, transferSize?: number, decodedBodySize?: number }} entry
* @param {string} [origin] the page's origin
*/
export function recordResourceEntry(entry, origin = globalThis.location?.origin) {
if (!entry || typeof entry.name !== 'string') return;
let u;
try { u = new URL(entry.name, origin || 'http://localhost'); } catch { return; }
if (u.protocol !== 'http:' && u.protocol !== 'https:') return;
const cat = origin && u.origin !== origin ? 'external' : trafficCategory(u.pathname);
let t = traffic.get(cat);
if (!t) traffic.set(cat, (t = { requests: 0, bytes: 0, cached: 0 }));
const bytes = Number(entry.transferSize) || 0;
t.requests++;
t.bytes += bytes;
if (bytes === 0 && (Number(entry.decodedBodySize) || 0) > 0) t.cached++;
}
/**
* Start summing this page's downloads (main.js at boot): the entries already in the Resource Timing buffer, then
* every new one (a PerformanceObserver also gets the entries a full buffer drops). False where unsupported.
* @param {any} [win]
*/
export function installTrafficMeter(win = globalThis) {
if (trafficMeter) return true;
const PO = win?.PerformanceObserver;
if (typeof PO !== 'function') return false;
try {
const origin = win.location?.origin;
for (const e of win.performance?.getEntriesByType?.('navigation') || []) recordResourceEntry(e, origin);
trafficMeter = new PO((list) => { for (const e of list.getEntries()) recordResourceEntry(e, origin); });
trafficMeter.observe({ type: 'resource', buffered: true });
return true;
} catch {
trafficMeter = null;
return false;
}
}
/** The totals so far: `{ [category]: { requests, bytes, cached } }` in report order, plus `total`. */
export function trafficSummary() {
const out = {};
const total = { requests: 0, bytes: 0, cached: 0 };
for (const cat of [...TRAFFIC_CATEGORIES, 'external']) {
const t = traffic.get(cat);
if (!t) continue;
out[cat] = { ...t };
if (cat !== 'external') { total.requests += t.requests; total.bytes += t.bytes; total.cached += t.cached; }
}
out.total = total;
return out;
}
export function clearTraffic() { traffic.clear(); }
const sizeOf = (n) => (n >= 1e6 ? `${(n / 1e6).toFixed(1)} MB` : n >= 1e3 ? `${Math.round(n / 1e3)} KB` : `${n} B`);
/** `assets 9.7 MB (254, 41 cached) | … | host total 21.4 MB (869)` — the report's traffic line; null when nothing was recorded. */
export function trafficLine(summary) {
if (!summary || !summary.total || !(summary.total.requests > 0)) return null;
const parts = Object.entries(summary).filter(([k]) => k !== 'total')
.map(([k, t]) => `${k} ${sizeOf(t.bytes)} (${t.requests}${t.cached ? `, ${t.cached} cached` : ''})`);
return `${parts.join(' | ')} | host total ${sizeOf(summary.total.bytes)} (${summary.total.requests})`;
}
let battleSource = null;
/** @param {() => ({ battleId: string, fieldId?: string, kind?: string, spec: object, time?: number }|null)} fn */
export function setBattleSource(fn) { battleSource = typeof fn === 'function' ? fn : null; }
@@ -177,10 +253,10 @@ const join = (...parts) => parts.filter((p) => p != null && p !== '' && p !== fa
* error log newest first) and, with `battle`, a ```json block of its spec. Secrets of `state` are whole tokens in the
* prose and exact string values in the JSON.
* @param {{ state: any, env?: any, settings?: any, battle?: any, entries?: any[], dropped?: number, total?: number,
* now?: number, pageStart?: number, version?: string }} input
* now?: number, pageStart?: number, version?: string, traffic?: ReturnType<typeof trafficSummary> | null }} input
*/
export function buildDiagnostics(input) {
const { state = {}, env = {}, settings = null, battle = null } = input;
const { state = {}, env = {}, settings = null, battle = null, traffic: trafficTotals = null } = input;
const entries = input.entries ?? errorLog();
const now = input.now ?? Date.now();
const lines = [];
@@ -208,6 +284,8 @@ export function buildDiagnostics(input) {
if (settings) {
lines.push(`settings: ${join(`quality ${settings.quality}`, settings.damageNumbers ? 'damage numbers' : null, settings.officialDamageNumbers ? 'official damage numbers' : null, settings.muted ? 'muted' : null)}`);
}
const tl = trafficLine(trafficTotals);
if (tl) lines.push(`traffic: ${tl}`);
const shown = entries.slice().reverse();
const tot = input.total ?? total;
const drop = input.dropped ?? dropped;
@@ -265,5 +343,5 @@ export function browserEnv() {
* @param {{ state: any, settings?: any, attachBattle?: boolean }} opts
*/
export function diagnosticsText({ state, settings = null, attachBattle = false }) {
return buildDiagnostics({ state, settings, env: browserEnv(), battle: attachBattle ? currentBattle() : null });
return buildDiagnostics({ state, settings, env: browserEnv(), battle: attachBattle ? currentBattle() : null, traffic: trafficSummary() });
}
+3 -1
View File
@@ -55,7 +55,7 @@ import { installLoadoutSync, installOwnershipSync, installDiySync } from './ui/l
import { startBuildGuard } from './ui/buildGuard.js';
import { initLang, useLang, tickerText } from './ui/lang.js';
import { t, N_, translateWire } from '../../shared/i18n.js';
import { recordError } from './diag.js';
import { recordError, installTrafficMeter } from './diag.js';
const RESTORE_GRACE_MS = 1500;
const JOIN_DELAY_MS = 350;
@@ -346,6 +346,8 @@ function installGlobalErrorHandlers() {
async function boot() {
installGlobalErrorHandlers();
// what this page downloads, by category, for the 问题反馈 report's traffic line (diag.js; nothing is sent anywhere)
installTrafficMeter();
// touch / hover / fullscreen classes, zoom-gesture blocking, rotation re-layout (ui/device.js, css/devices.css)
installDeviceSupport();
// A page restored from the back/forward cache has a dead socket and a stale token choice: start over.
+7 -2
View File
@@ -11,10 +11,14 @@
//
// `server/http/media.js` resolves /media/… back to the real file under public/assets/audio and still answers with
// `Content-Type: audio/mpeg` + range support; Web Audio sniffs the container, so the URL is all the same to it.
// A served manifest's content-hashed URL keeps its segment (shared/assetPaths.js; host bandwidth, layer 1):
//
// /assets/~0123456789abcdef/audio/bgm/act1.mp3 → /media/~0123456789abcdef/bgm/act1
// A host that does not implement /media/ keeps working: `audio.js` falls back to the original URL whenever the
// /media/ response is unusable (a 404, or a 200 that is not audio at all — some static hosts answer a missing
// path with the SPA's index.html).
import { MEDIA_PREFIX, AUDIO_EXTS } from '../../shared/media.js';
import { splitHashedPath } from '../../shared/assetPaths.js';
const AUDIO_PATH = /^\/assets\/audio\/(.+)$/i;
@@ -36,7 +40,8 @@ export function mediaUrl(url, origin = globalThis.location?.origin) {
// Cross-origin (or absolute while the page origin is unknown) stays untouched: that host serves its own media.
const absolute = /^[a-z][a-z0-9+.-]*:/i.test(url) || url.startsWith('//');
if (origin ? u.origin !== origin : absolute) return url;
const m = AUDIO_PATH.exec(u.pathname);
const { hash, path: plain } = splitHashedPath(u.pathname);
const m = AUDIO_PATH.exec(plain);
if (!m) return url;
let rest = m[1];
const ext = AUDIO_EXTS.find((e) => rest.toLowerCase().endsWith(e));
@@ -44,5 +49,5 @@ export function mediaUrl(url, origin = globalThis.location?.origin) {
rest = rest.slice(0, -ext.length);
const segments = rest.split('/');
if (!rest || segments.some((s) => !s || s === '.' || s === '..' || s.startsWith('.'))) return url;
return `${MEDIA_PREFIX}${rest}${u.search}`;
return `${MEDIA_PREFIX}${hash ? `~${hash}/` : ''}${rest}${u.search}`;
}
+16 -3
View File
@@ -3,7 +3,8 @@
// downloads from the public mirror (data/assets.json → ui['guide/<key>']; GitHub issue #42) — data.js artUrls; a page
// whose picture fails to load tries its next URL. The pages are stored squashed to 1024² (both copies) and are
// displayed stretched back to 16:9. Three chapters (基础规则 / 调度手册 / 进阶图鉴), ‹ › buttons, ←/→ (A/D) keys, page
// dots, thumbnails; Esc or the backdrop closes. Adjacent pages are preloaded.
// dots, thumbnails; Esc or the backdrop closes. Only the next page is preloaded (a page is 0.9–1.5 MB; the one before
// has usually just been seen, so preloading it too doubled the download — host bandwidth, layer 0).
// With no page listed the viewer shows the official loading-screen tips (config.tips) instead, and so does a page none
// of whose copies loads (`guideStage`: data/assets.json lists the downloaded pages but the files are not on disk — a
// `git pull` and restart without setup, an older asset folder, a failed download the shrink guard kept); the chapter
@@ -66,6 +67,19 @@ export function guideStage(page, failed) {
return src ? { kind: 'image', src } : { kind: 'tips' };
}
/**
* The one page the viewer preloads while page `i` is shown: the next one (wrapping around), its first URL that has not
* failed — null without pages. The previous page is not preloaded (see the header).
* @param {Array<{ urls?: string[] }>} pages guidePages()
* @param {number} i
* @param {Set<string>} [failed]
*/
export function guidePreloadUrl(pages, i, failed) {
const n = Array.isArray(pages) ? pages.length : 0;
if (!n || n < 2) return null;
return nextArtUrl(pages[(((i + 1) % n) + n) % n]?.urls, failed);
}
/** Open/closed + current page. */
export const guideStore = createStore({ open: false, page: 0 });
@@ -136,8 +150,7 @@ export function GuideHost() {
useEffect(() => {
if (!open || !n) return;
preload(nextArtUrl(pages[(i + 1) % n]?.urls, failed));
preload(nextArtUrl(pages[(i + n - 1) % n]?.urls, failed));
preload(guidePreloadUrl(pages, i, failed));
}, [open, i, n, failed]);
if (!open) return null;