Files
ark-stronghold-protocol/public/js/main.js
T
herryyun 7c25c89992
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
实现账号绑定、持久登录与跨设备对局恢复
2026-10-14 23:22:50 +08:00

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('启动失败,请刷新页面重试');
});