CI / test (ubuntu-latest, node 22) (push) Failing after 33m10s
CI / test (ubuntu-latest, node 24) (push) Failing after 34m41s
CI / tooling (lint, imports, typecheck) (push) Failing after 36m11s
CI / test (windows-latest, node 22) (push) Canceled after 0s
CI / test (windows-latest, node 24) (push) Canceled after 0s
419 lines
20 KiB
JavaScript
419 lines
20 KiB
JavaScript
// Client entry: boot (fonts, identity, socket), net → store wiring, router, global overlays.
|
|
//
|
|
// Router (derived from the store, no URL routes):
|
|
// not entered → Title
|
|
// m.public.phase ≠ LOBBY → Game (screens/game.js: briefing / band draft / match / result by phase)
|
|
// room.inMatch → Game (match starting, m.public on its way)
|
|
// in a room → Room
|
|
// otherwise → Lobby
|
|
// Deep link `?room=CODE`: remembered at boot, auto-joined once the player has entered and the
|
|
// session is online (after a short grace period in case the server restores a room on resume).
|
|
// Reloading a tab that already passed the title re-enters after checking its current role binding and
|
|
// resumes the server session with the saved token; stale room/match state is dropped if the
|
|
// server does not re-push it within RESTORE_GRACE_MS after `welcome`. Boot waits for
|
|
// `identity.init()` (which token this tab may use without stealing another live tab's session)
|
|
// before the first connect; a page restored from the back/forward cache reloads.
|
|
// A `welcome` with a NEW playerId (the server restarted / the session expired) while a room or match was on screen:
|
|
// drop stale match state (store.js sessionResetNotice); an unauthenticated transport returns to the title for checked entry.
|
|
//
|
|
// Shared modules are imported relatively ('../../shared/…' resolves to /shared/… in the browser).
|
|
// Multi-device support (ui/device.js + css/devices.css): feature classes on <html>, no page zoom, safe areas, rotation
|
|
// re-layout; ui/compat.js polyfills are imported before anything else.
|
|
// 干员调配 (DESIGN §16): an overlay over any route (<LoadoutHost/>, opened from lobby / room / briefing); its loadout is
|
|
// kept in sync with the server by installLoadoutSync (room.loadout after every welcome and edit), its 干员持有 tab's
|
|
// not-owned list (0.2.0 补位) by installOwnershipSync (room.ownership, likewise), the 自选编队 picks (0.2.0 DIY) by
|
|
// installDiySync (room.diy, likewise; it also keeps welcome.diyKitted for the picker).
|
|
// Game data: every text of the game is static data (/data/*.json) downloaded once per page; the in-match files are
|
|
// warmed in the background as soon as the player is in a room (warmGameData), before the match needs them.
|
|
// Language (ui/lang.js, docs/I18N.md): chosen before the first render (initLang); App re-renders on a switch (useLang).
|
|
// Server texts are translated on arrival: m.toast / m.ticker frames (msgid + params, or the text as a msgid; a
|
|
// config.broadcasts line from its id + args), error codes (ui/toasts.js describeError) and room.closed reasons.
|
|
|
|
// Polyfills first (older Safari / Firefox ESR): every module evaluated after this one sees them.
|
|
import './ui/compat.js';
|
|
import { render } from '../vendor/preact.module.js';
|
|
import { useErrorBoundary } from '../vendor/hooks.module.js';
|
|
import { html, UiHosts, Button, MicroLabel, closeAllDialogs } from './ui/components.js';
|
|
import { ConnectionBanner } from './ui/connBanner.js';
|
|
import { ToastHost, toast, toastError, describeError } from './ui/toasts.js';
|
|
import { net, identity, NetError } from './net.js';
|
|
import { store, useStore, emptyMatch, selectRoute, sessionResetNotice, isSpectating } from './store.js';
|
|
import { data } from './data.js';
|
|
import { GAME_FILES } from './ui/gameComponents.js';
|
|
import { TitleScreen, sanitizeName } from './screens/title.js';
|
|
import { acceptAccountWelcome, restoreBoundEntry } from './skland/gate.js';
|
|
import { LobbyScreen, rememberRoom, parseRoomParam } from './screens/lobby.js';
|
|
import { RoomScreen } from './screens/room.js';
|
|
import { GameScreen } from './screens/game.js';
|
|
import { audio, installAudio } from './audio.js';
|
|
import { settingsStore } from './ui/settings.js';
|
|
import { GuideHost } from './ui/guide.js';
|
|
import { installDeviceSupport } from './ui/device.js';
|
|
import { LoadoutHost } from './screens/loadout.js';
|
|
import { StatsHost } from './screens/stats.js';
|
|
import { recordResult, installStatsRecorder } from './ui/stats.js';
|
|
import { installLoadoutSync, installOwnershipSync, installDiySync } from './ui/loadoutSync.js';
|
|
import { startBuildGuard } from './ui/buildGuard.js';
|
|
import { initLang, useLang, tickerText } from './ui/lang.js';
|
|
import { t, N_, translateWire } from '../../shared/i18n.js';
|
|
import { recordError, installTrafficMeter } from './diag.js';
|
|
|
|
const RESTORE_GRACE_MS = 1500;
|
|
const JOIN_DELAY_MS = 350;
|
|
const TICKER_KEEP = 20;
|
|
const EMOTE_KEEP = 20;
|
|
|
|
const SCREENS = { title: TitleScreen, lobby: LobbyScreen, room: RoomScreen, game: GameScreen };
|
|
|
|
/** Copy of a server message without transport fields. */
|
|
function payload(msg) {
|
|
const { t, rid, ...rest } = msg; // eslint-disable-line no-unused-vars
|
|
return rest;
|
|
}
|
|
|
|
function clearRoomParam() {
|
|
try {
|
|
const url = new URL(location.href);
|
|
if (!url.searchParams.has('room')) return;
|
|
url.searchParams.delete('room');
|
|
history.replaceState(history.state, '', url.pathname + (url.search || '') + url.hash);
|
|
} catch { /* ignore */ }
|
|
}
|
|
|
|
// ---- net → store wiring ---------------------------------------------------------------------------
|
|
|
|
let seq = 0;
|
|
let welcomeAt = 0;
|
|
let roomStateAt = 0;
|
|
let matchAt = 0;
|
|
let restoreTimer = null;
|
|
let joinTimer = null;
|
|
let joinInFlight = false;
|
|
|
|
function clearPendingJoin() {
|
|
store.patch('ui', { pendingJoin: null });
|
|
clearRoomParam();
|
|
}
|
|
|
|
/** Auto-join the deep-linked room once entered + online (idempotent). */
|
|
function schedulePendingJoin() {
|
|
clearTimeout(joinTimer);
|
|
joinTimer = setTimeout(async () => {
|
|
const s = store.get();
|
|
const code = s.ui.pendingJoin;
|
|
if (!code || joinInFlight || !s.session.entered || net.status !== 'online') return;
|
|
if (s.room) {
|
|
if (s.room.code !== code) toast(t('你已在其他同盟中,请先离开当前同盟'), 'warn');
|
|
clearPendingJoin();
|
|
return;
|
|
}
|
|
joinInFlight = true;
|
|
try {
|
|
await net.request('room.join', { code });
|
|
} catch (err) {
|
|
toastError(err);
|
|
} finally {
|
|
joinInFlight = false;
|
|
clearPendingJoin();
|
|
}
|
|
}, JOIN_DELAY_MS);
|
|
}
|
|
|
|
/** End the post-resume "syncing" state early once the server re-pushed what we were showing. */
|
|
function maybeFinishRestore() {
|
|
const s = store.get();
|
|
if (!s.ui.restoring) return;
|
|
const roomOk = !s.room || roomStateAt >= welcomeAt;
|
|
const matchOk = !s.match.public || matchAt >= welcomeAt || !s.room?.inMatch;
|
|
if (roomOk && matchOk) {
|
|
clearTimeout(restoreTimer);
|
|
store.patch('ui', { restoring: false });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Leave whatever room / match was on screen for the lobby with nothing stale left behind: open imperative dialogs,
|
|
* the post-resume restore timer, ticker lines and emote bubbles of the old match (the battle runner and the 暂离 flag
|
|
* follow the store themselves).
|
|
*/
|
|
function backToLobby() {
|
|
identity.rememberMatch(null);
|
|
clearTimeout(restoreTimer);
|
|
const s = store.get();
|
|
if (s.room || s.match.public) closeAllDialogs();
|
|
store.set({ room: null, match: emptyMatch(), ticker: [], emotes: [] });
|
|
store.patch('ui', { restoring: false });
|
|
}
|
|
|
|
function onWelcome(msg) {
|
|
const accountAccepted = acceptAccountWelcome(msg);
|
|
identity.saveToken(msg.token);
|
|
identity.rememberMatch(null);
|
|
const prev = store.get();
|
|
const prevId = prev.me.playerId;
|
|
const name = typeof msg.name === 'string' && msg.name ? msg.name : prev.me.name;
|
|
identity.saveName(name);
|
|
store.set({ me: { playerId: msg.playerId ?? null, name, token: typeof msg.token === 'string' ? msg.token : null } });
|
|
welcomeAt = Date.now();
|
|
|
|
if (prevId != null && prevId !== msg.playerId) {
|
|
// A brand-new server session (the server restarted — crashed / killed, so no room.closed arrived — or this session
|
|
// expired on it): clear the old snapshot. A login-only transport stays at the title until checked account entry.
|
|
const notice = accountAccepted ? sessionResetNotice(prev, msg.playerId) : null;
|
|
backToLobby();
|
|
if (notice) toast(t(notice), 'warn', { ttl: 7000 });
|
|
} else if (prev.room || prev.match.public) {
|
|
// Resumed session: the server re-pushes room/match state; drop whatever it doesn't.
|
|
store.patch('ui', { restoring: true });
|
|
clearTimeout(restoreTimer);
|
|
restoreTimer = setTimeout(() => {
|
|
const s = store.get();
|
|
const patch = {};
|
|
if (s.room && roomStateAt < welcomeAt) patch.room = null;
|
|
if (s.match.public && matchAt < welcomeAt) patch.match = emptyMatch();
|
|
store.set(patch);
|
|
store.patch('ui', { restoring: false });
|
|
}, RESTORE_GRACE_MS);
|
|
}
|
|
schedulePendingJoin();
|
|
}
|
|
|
|
function onRoomState(msg) {
|
|
const room = payload(msg);
|
|
roomStateAt = Date.now();
|
|
const myId = store.get().me.playerId;
|
|
const seats = Array.isArray(room.seats) ? room.seats : [];
|
|
if (myId != null && seats.length && !seats.some((s) => s && s.playerId === myId) && !isSpectating(room, myId)) {
|
|
// We are no longer seated (kicked / left elsewhere) — neither in a player seat nor a spectator seat.
|
|
if (store.get().room) toast(t('你已不在该同盟中'), 'warn');
|
|
store.set({ room: null, match: emptyMatch() });
|
|
return;
|
|
}
|
|
const prevRoom = store.get().room;
|
|
// A (new) match starts: forget the previous match's state so stale results never show.
|
|
if (room.inMatch && !(prevRoom && prevRoom.inMatch && prevRoom.code === room.code)) store.set({ match: emptyMatch() });
|
|
store.set({ room });
|
|
identity.rememberMatch(room.inMatch && seats.some((seat) => seat?.playerId === myId)
|
|
? { name: store.get().me.name, code: room.code || '' } : null);
|
|
if (room.mode === 'coop' && typeof room.code === 'string') rememberRoom(room.code);
|
|
maybeFinishRestore();
|
|
}
|
|
|
|
const CLOSE_REASON = {
|
|
// 'timeout' = this player was removed after staying disconnected past the lobby grace (server/lobby.js)
|
|
host_left: N_('创建者已离开,同盟已解散'), timeout: N_('由于长时间断开连接,你已离开同盟'), empty: N_('同盟已解散'),
|
|
kicked: N_('你已被移出同盟'), ended: N_('模拟已结束'), expired: N_('同盟已过期'), shutdown: N_('服务器维护中,同盟已关闭'),
|
|
};
|
|
|
|
function wireNet() {
|
|
net.on('status', (snap) => {
|
|
const cur = store.get().connection;
|
|
store.set({
|
|
connection: {
|
|
status: snap.status, ping: snap.ping, attempt: snap.attempt, retryAt: snap.retryAt,
|
|
lastError: snap.lastError, everOnline: cur.everOnline || snap.status === 'online',
|
|
},
|
|
});
|
|
});
|
|
net.on('clock', (c) => store.set({ clock: { offset: c.offset, rtt: c.rtt, synced: c.synced } }));
|
|
net.on('welcome', onWelcome);
|
|
net.on('helloError', (err) => {
|
|
if (err.code === 'SESSION_IN_USE') identity.rejectToken();
|
|
toastError(err);
|
|
});
|
|
net.on('replaced', () => toast(t('该身份已在其他页面登录,本页已断开'), 'warn', { ttl: 6000 }));
|
|
net.on('unhandledError', (err) => toastError(err));
|
|
net.on('room.state', onRoomState);
|
|
net.on('room.closed', (msg) => {
|
|
backToLobby();
|
|
const known = Object.hasOwn(CLOSE_REASON, String(msg.reason)) ? CLOSE_REASON[msg.reason] : null;
|
|
toast(known ? t(known) : typeof msg.reason === 'string' && msg.reason.length < 60 ? t('同盟已关闭:{reason}', { reason: msg.reason }) : t('同盟已关闭'), 'warn');
|
|
});
|
|
net.on('m.public', (msg) => { matchAt = Date.now(); store.patch('match', { public: payload(msg) }); maybeFinishRestore(); });
|
|
net.on('m.private', (msg) => { matchAt = Date.now(); store.patch('match', { private: payload(msg) }); });
|
|
net.on('m.layer', (msg) => {
|
|
const s = store.get();
|
|
if (selectRoute(s) === 'game' && msg.playerId === s.me?.playerId) audio.layerGain(msg.bondId, msg.add);
|
|
});
|
|
net.on('m.field', (msg) => store.patch('match', { field: payload(msg) }));
|
|
net.on('m.result', (msg) => {
|
|
const res = payload(msg);
|
|
store.patch('match', { result: res });
|
|
// 本机统计 (PR #323): every arrival, including the lobby's result replay after a reconnect / reload —
|
|
// replays dedupe by content id inside recordResult (spectator seats' copies build no record at all)
|
|
recordResult(res, { myId: store.get().me.playerId, roomMode: store.get().room?.mode ?? null, now: Date.now() });
|
|
});
|
|
net.on('m.toast', (msg) => {
|
|
const kind = ['info', 'success', 'warn', 'error'].includes(msg.kind) ? msg.kind : 'info';
|
|
// msgid + params (server ≥ 0.2.0) or the text itself as a msgid, in the current language
|
|
toast(translateWire(msg), kind);
|
|
});
|
|
net.on('m.ticker', (msg) => {
|
|
if (typeof msg.text !== 'string') return;
|
|
const text = tickerText(msg);
|
|
// type, player + the round it came in: a BOSS_HIT line is dropped once its boss round is over and superseded by the
|
|
// same player's next one (ui/ticker.js tickerLineLive / tickerSupersedes)
|
|
const type = typeof msg.type === 'string' ? msg.type : null;
|
|
const playerId = typeof msg.playerId === 'string' ? msg.playerId : null;
|
|
// its broadcast priority: the strip plays the highest first (ui/ticker.js enqueueTickerLines)
|
|
const priority = Number.isFinite(msg.priority) ? msg.priority : 0;
|
|
store.set((s) => ({ ticker: [...s.ticker.slice(-(TICKER_KEEP - 1)), { id: ++seq, text, at: Date.now(), type, playerId, round: s.match?.public?.round ?? null, priority }] }));
|
|
});
|
|
net.on('m.emote', (msg) => {
|
|
store.set((s) => ({ emotes: [...s.emotes.slice(-(EMOTE_KEEP - 1)), { seq: ++seq, playerId: msg.playerId, id: msg.id, at: Date.now() }] }));
|
|
});
|
|
|
|
// Entering (title → lobby) while already online also needs the deep-link join.
|
|
store.subscribe((s, prev) => {
|
|
if (s.session.entered && !prev.session.entered) schedulePendingJoin();
|
|
// in a room (co-op or solo, also a resumed one) a match is near: its data starts downloading
|
|
if (s.room && !prev.room) warmGameData();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Download every data file of the match UI (gameComponents GAME_FILES: operators, skills, bonds, items, enemies, 特质 …)
|
|
* in the background once the player is in a room — a match is near (the lobby alone never downloads them). The game's
|
|
* texts are static data loaded once per page — never fetched during a match — and the match screen waits for these
|
|
* files, so with them warmed it opens at once and no text ever appears late (user playtest #3 item 9). Idempotent (the
|
|
* data store shares each file's promise).
|
|
*/
|
|
function warmGameData() {
|
|
const go = () => { data.loadAll(GAME_FILES).catch(() => {}); };
|
|
if (typeof globalThis.requestIdleCallback === 'function') globalThis.requestIdleCallback(go, { timeout: 2500 });
|
|
else setTimeout(go, 600);
|
|
}
|
|
|
|
// ---- UI chrome (the connection banner lives in ui/connBanner.js) -----------------------------------
|
|
|
|
function ScreenCrashed({ error, reset }) {
|
|
return html`<div class="screen crash">
|
|
<div class="crash__box brackets">
|
|
<${MicroLabel} tone="mint">SYSTEM FAULT<//>
|
|
<h2>${t('界面发生错误')}</h2>
|
|
<p class="t-lo">${String(error?.message || error).slice(0, 200)}</p>
|
|
<${Button} variant="primary" icon="refresh" onClick=${reset}>${t('重新加载界面')}<//>
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
function App() {
|
|
const route = useStore(selectRoute);
|
|
useLang(); // a language switch re-renders the whole tree in place
|
|
const [error, resetError] = useErrorBoundary((err) => console.error('[ui] screen crashed', err));
|
|
const Screen = SCREENS[route] || LobbyScreen;
|
|
return html`<div class="app-root">
|
|
<div class="app-bg" aria-hidden="true"></div>
|
|
${error ? html`<${ScreenCrashed} error=${error} reset=${resetError} />` : html`<${Screen} key=${route} />`}
|
|
<${ConnectionBanner} continueSession=${() => restoreBoundEntry(sanitizeName)} />
|
|
<${ToastHost} />
|
|
<${UiHosts} />
|
|
<${GuideHost} />
|
|
<${LoadoutHost} />
|
|
<${StatsHost} />
|
|
</div>`;
|
|
}
|
|
|
|
// ---- boot -----------------------------------------------------------------------------------------
|
|
|
|
async function waitForFonts(ms) {
|
|
const fonts = document.fonts;
|
|
if (!fonts || typeof fonts.load !== 'function') return;
|
|
const loads = [
|
|
fonts.load('900 1em "Noto Sans SC"', '卫戍协议盟约'), // i18n-ignore (font sample)
|
|
fonts.load('700 1em "Noto Sans SC"', '开始'), // i18n-ignore (font sample)
|
|
fonts.load('700 1em Bender', '0123456789'),
|
|
fonts.load('700 1em Rajdhani', '0123456789'),
|
|
].map((p) => p.catch(() => null));
|
|
await Promise.race([Promise.all(loads), new Promise((r) => setTimeout(r, ms))]);
|
|
}
|
|
|
|
function installGlobalErrorHandlers() {
|
|
window.addEventListener('unhandledrejection', (ev) => {
|
|
const err = ev.reason;
|
|
// Media autoplay/abort rejections (audio.play() before a user gesture, interrupted loads) are
|
|
// expected browser behaviour, not app errors: log quietly, never toast.
|
|
if (err && (err.name === 'NotAllowedError' || err.name === 'AbortError')) { console.warn('[app] ignored rejection', err.name); return; }
|
|
console.error('[app] unhandled rejection', err);
|
|
recordError('rejection', err);
|
|
if (err instanceof NetError) toastError(err);
|
|
else toast(t('发生意外错误:{error}', { error: describeError(err) }).slice(0, 120), 'error');
|
|
});
|
|
window.addEventListener('error', (ev) => {
|
|
if (!(ev instanceof ErrorEvent)) return; // resource load errors are not script errors
|
|
console.error('[app] uncaught error', ev.error || ev.message);
|
|
recordError('error', ev.error || ev.message, ev.error ? null : `${ev.filename || '?'}:${ev.lineno || 0}:${ev.colno || 0}`);
|
|
});
|
|
}
|
|
|
|
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.
|
|
window.addEventListener('pageshow', (ev) => { if (ev.persisted) location.reload(); });
|
|
// Pick this tab's reconnect token (asks other live tabs; ≤150 ms) while fonts load.
|
|
const identityReady = identity.init();
|
|
|
|
const pendingJoin = parseRoomParam(location.search);
|
|
const savedName = sanitizeName(identity.loadName());
|
|
const entered = identity.wasEntered() && !!savedName;
|
|
store.set((s) => ({
|
|
me: { ...s.me, name: savedName },
|
|
session: { entered: false },
|
|
ui: { ...s.ui, pendingJoin },
|
|
}));
|
|
|
|
wireNet();
|
|
installStatsRecorder(store); // follows the match on screen, so a 放弃模拟 can be recorded (ui/stats.js)
|
|
installLoadoutSync({ net });
|
|
installOwnershipSync({ net });
|
|
installDiySync({ net });
|
|
net.attachBrowserHooks();
|
|
// Audio: unlock on first gesture, BGM follows the route / match phase (js/audio.js).
|
|
installAudio({ getManifest: () => data.get('assets'), subscribe: store.subscribe, getState: store.get, selectRoute, settings: settingsStore.get() });
|
|
data.load('assets').catch(() => {});
|
|
// Warm the data cache in the background (missing files are tolerated).
|
|
data.loadAll('config').catch(() => {});
|
|
// Optional local-client art manifest (emotes, tutorial pages, official UI sprites; DESIGN §13).
|
|
data.load('local').catch(() => {});
|
|
|
|
const connectWhenReady = identityReady.then((token) => {
|
|
if (entered && token) return restoreBoundEntry(sanitizeName, { automatic: true });
|
|
identity.setEntered(false);
|
|
net.connect();
|
|
});
|
|
// the language (and its UI translations) before the first render: no Chinese flash for an English player
|
|
const langReady = initLang().catch((err) => console.warn('[app] language setup failed', err));
|
|
await Promise.all([waitForFonts(1200), connectWhenReady, langReady]);
|
|
const root = document.getElementById('app');
|
|
render(html`<${App} />`, root);
|
|
|
|
const splash = document.getElementById('boot');
|
|
if (splash) {
|
|
splash.classList.add('is-done');
|
|
setTimeout(() => splash.remove(), 300);
|
|
}
|
|
globalThis.__SP__ = { store, net, data, version: 1 };
|
|
// A page keeps the modules it imported at load time for its whole lifetime, so a deploy cannot reach an open tab
|
|
// (ui/buildGuard.js): watch `/healthz.build`. Outside a match the page reloads itself; during a match the guard says
|
|
// so instead (the connection banner offers 刷新页面) and reloads once the match — settlement screen included — is over,
|
|
// so a running game is never thrown away.
|
|
try {
|
|
startBuildGuard({
|
|
inMatch: () => selectRoute(store.get()) === 'game',
|
|
onStale: ({ waiting }) => { if (waiting) store.patch('ui', { buildStale: true }); },
|
|
});
|
|
} catch (err) {
|
|
console.warn('[app] build guard failed to start', err);
|
|
}
|
|
}
|
|
|
|
boot().catch((err) => {
|
|
console.error('[app] boot failed', err);
|
|
const el = document.getElementById('boot-err');
|
|
if (el) el.textContent = t('启动失败,请刷新页面重试');
|
|
});
|