diff --git a/.gitignore b/.gitignore
index 90a9bfe..208d8f3 100644
--- a/.gitignore
+++ b/.gitignore
@@ -35,3 +35,9 @@ Thumbs.db
/public/configuration/client-mode.json
/public/configuration/adsense.json
/public/configuration/hotlooks.json
+
+# Game assets are served by an external server (emulator/CMS), not by Vite.
+# Never recreate these as symlinks inside public/ — chokidar follows them and
+# the dev server takes minutes to start with 100k+ files under public/.
+/public/nitro-assets
+/public/swf
diff --git a/index.html b/index.html
index 1c4fa2e..2d0b3f6 100644
--- a/index.html
+++ b/index.html
@@ -1 +1,12 @@
-
+
+
+
+
+
+ Nitro
+
+
+
+
+
+
diff --git a/package.json b/package.json
index 119bdb5..aaf169a 100644
--- a/package.json
+++ b/package.json
@@ -58,6 +58,7 @@
"postcss": "^8.5.12",
"postcss-nested": "^7.0.2",
"sass": "^1.99.0",
+ "sirv": "^3.0.2",
"tailwindcss": "^4.2.4",
"typescript": "^6.0.3",
"typescript-eslint": "^8.59.1",
diff --git a/src/App.tsx b/src/App.tsx
index e54defc..0a77cbf 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -76,18 +76,63 @@ export const App: FC<{}> = props =>
const rendererPromiseRef = useRef>(null);
const gameInitPromiseRef = useRef | null>(null);
const bootstrapDoneRef = useRef(false);
+ const lastPrepareTriggerRef = useRef(null);
const tickersStartedRef = useRef(false);
const heartbeatIntervalRef = useRef(null);
const rememberRotateIntervalRef = useRef(null);
+ const isReadyRef = useRef(false);
+ const reconnectInProgressRef = useRef(false);
+
+ const clearStoredCredentials = useCallback(() =>
+ {
+ ClearRememberLogin();
+ try { delete (window as any).NitroConfig?.['sso.ticket']; } catch {}
+ try { GetConfiguration().setValue('sso.ticket', ''); } catch {}
+ // Drop `?sso=` from the URL too — otherwise the next reload re-applies
+ // the same already-consumed ticket via bootstrap.ts and we loop right
+ // back into "Session expired" without ever showing the login form.
+ try
+ {
+ const url = new URL(window.location.href);
+
+ if(url.searchParams.has('sso'))
+ {
+ url.searchParams.delete('sso');
+ window.history.replaceState({}, '', url.toString());
+ }
+ }
+ catch {}
+ }, []);
+
+ const fallbackToLogin = useCallback(() =>
+ {
+ // Using console.warn (not NitroLogger.log) on purpose: NitroLogger
+ // is gated on LOG_DEBUG, which only flips to true once startWarmup's
+ // GetConfiguration().init() completes. Auth-failure paths fire before
+ // that, so NitroLogger swallows their messages silently.
+ console.warn('[App] fallbackToLogin — surfacing login form, credentials cleared');
+ // Wipe whatever credential the server just rejected so the form is
+ // pristine and the next attempt isn't sabotaged by the same stale data.
+ clearStoredCredentials();
+ setHomeUrl('');
+ setErrorMessage('');
+ setIsReady(false);
+ setShowLogin(true);
+ setIsEnteringHotel(false);
+ }, [ clearStoredCredentials ]);
+
const showSessionExpired = useCallback(() =>
{
+ console.warn('[App] showSessionExpired — diagnostic shown (mid-game close)');
+ clearStoredCredentials();
+
const baseUrl = window.location.origin + '/';
setHomeUrl(baseUrl);
setErrorMessage('Your session has expired.\nPlease log in again to enter the hotel.');
setIsReady(false);
setShowLogin(false);
setIsEnteringHotel(false);
- }, []);
+ }, [ clearStoredCredentials ]);
const applySsoTicket = useCallback((ssoTicket: string) =>
{
@@ -109,10 +154,20 @@ export const App: FC<{}> = props =>
{
const remembered = GetRememberLogin();
- if(!remembered) return '';
- if(!remembered.token?.length && remembered.ssoTicket?.length) return remembered.ssoTicket;
+ console.warn('[App] tryRememberLogin start', {
+ hasRemembered: !!remembered,
+ hasToken: !!remembered?.token?.length,
+ hasStoredSso: !!remembered?.ssoTicket?.length
+ });
- let allowSsoFallback = true;
+ if(!remembered?.token?.length)
+ {
+ // No remember token means we'd be reusing a one-shot ssoTicket that
+ // the server already consumed. Force the login screen instead.
+ if(remembered) ClearRememberLogin();
+ console.warn('[App] tryRememberLogin → no token, returning empty');
+ return '';
+ }
try
{
@@ -139,24 +194,28 @@ export const App: FC<{}> = props =>
const ssoTicket = typeof payload.ssoTicket === 'string' ? payload.ssoTicket : (typeof payload.sso === 'string' ? payload.sso : '');
+ console.warn('[App] tryRememberLogin → remember endpoint replied', {
+ status: response.status,
+ ok: response.ok,
+ gotSsoTicket: !!ssoTicket
+ });
+
if(response.ok && ssoTicket)
{
StoreRememberLoginFromPayload(payload, typeof payload.username === 'string' ? payload.username : remembered.username, ssoTicket);
return ssoTicket;
}
-
- if(response.status === 400 || response.status === 401 || response.status === 403)
- {
- allowSsoFallback = false;
- ClearRememberLogin();
- }
}
catch(error)
{
- NitroLogger.error('[LoginScreen] Remember login failed', error);
+ console.warn('[App] tryRememberLogin → fetch threw', error);
}
- if(allowSsoFallback && remembered.ssoTicket?.length) return remembered.ssoTicket;
+ // Any failure (rejected token, bad payload, network error) — drop the
+ // stored credentials. Never fall back to the cached ssoTicket: it's
+ // one-shot and reusing it leads straight to "Session expired".
+ ClearRememberLogin();
+ console.warn('[App] tryRememberLogin → cleared remember, returning empty');
return '';
}, []);
@@ -204,8 +263,51 @@ export const App: FC<{}> = props =>
}
}, []);
- // Listen for socket closed events (code 1000 "Bye" - server rejected SSO)
- useNitroEvent(NitroEventType.SOCKET_CLOSED, showSessionExpired);
+ // Mirror isReady into a ref so the socket handlers below can read the
+ // freshest value without needing to re-subscribe on every state change.
+ useEffect(() => { isReadyRef.current = isReady; }, [ isReady ]);
+
+ // Track whether a reconnect cycle is active. The renderer dispatches
+ // SOCKET_RECONNECTING when it starts retrying after an abnormal close
+ // (code != 1000/1001). On exhausted retries it fires SOCKET_RECONNECT_FAILED
+ // *and* a final SOCKET_CLOSED — we keep the flag set through that pair
+ // so ReconnectView's own overlay owns the UX and we don't double-render.
+ useNitroEvent(NitroEventType.SOCKET_RECONNECTING, () => { reconnectInProgressRef.current = true; });
+ useNitroEvent(NitroEventType.SOCKET_REAUTHENTICATED, () => { reconnectInProgressRef.current = false; });
+
+ useNitroEvent(NitroEventType.SOCKET_CLOSED, () =>
+ {
+ // Three distinct close scenarios converge here:
+ //
+ // 1. !isReady — initial handshake just failed (server replied
+ // with "Bye" / code 1000 to a bad SSO ticket). The user never
+ // had a session. Surface the login form instead of the
+ // misleading "Session expired" diagnostic.
+ //
+ // 2. isReady && reconnect in progress — ReconnectView already
+ // owns the UX (its overlay shows attempts and the "Session
+ // expired" message on RECONNECT_FAILED). Stay out of its way.
+ //
+ // 3. isReady && no reconnect — instant server kick mid-game
+ // (code 1000 from the server side). No reconnect path will
+ // run. Show the legacy session-expired diagnostic so the
+ // user knows to reload.
+ console.warn('[App] SOCKET_CLOSED fired', {
+ isReady: isReadyRef.current,
+ reconnectInProgress: reconnectInProgressRef.current
+ });
+
+ if(!isReadyRef.current)
+ {
+ console.warn('[App] Socket closed before authentication completed — falling back to login');
+ fallbackToLogin();
+ return;
+ }
+
+ if(reconnectInProgressRef.current) return;
+
+ showSessionExpired();
+ });
useMessageEvent(LoadGameUrlEvent, event =>
{
@@ -317,11 +419,19 @@ export const App: FC<{}> = props =>
}, []);
const onSessionExpired = useEffectEvent(() => showSessionExpired());
+ const onInitFailure = useEffectEvent(() => fallbackToLogin());
useEffect(() =>
{
const prepare = async (width: number, height: number) =>
{
+ console.warn('[App] prepare() start', {
+ hasNitroConfig: !!window.NitroConfig,
+ ssoTicketInConfig: !!window.NitroConfig?.['sso.ticket'],
+ hasRememberLocal: !!GetRememberLogin(),
+ urlSso: new URLSearchParams(window.location.search).get('sso')
+ });
+
try
{
if(!window.NitroConfig) throw new Error('NitroConfig is not defined!');
@@ -346,6 +456,13 @@ export const App: FC<{}> = props =>
const rawLoginEnabled = GetConfiguration().getValue('login.screen.enabled', false);
const loginScreenEnabled = rawLoginEnabled === true || rawLoginEnabled === 'true' || rawLoginEnabled === 1;
+ console.warn('[App] no SSO path — login gate', {
+ configInitError: configInitError ? String((configInitError as Error)?.message ?? configInitError) : null,
+ rawLoginEnabled,
+ rawLoginEnabledType: typeof rawLoginEnabled,
+ loginScreenEnabled
+ });
+
if(configInitError)
{
NitroLogger.error('[LoginScreen] Failed to load renderer-config.json — cannot resolve login.screen.enabled', configInitError);
@@ -432,12 +549,27 @@ export const App: FC<{}> = props =>
}
catch(err)
{
- NitroLogger.error(err);
- setIsEnteringHotel(false);
- onSessionExpired();
+ NitroLogger.error('[App] Initialization failed — falling back to login', err);
+ // Anything thrown out of the post-auth chain (renderer init,
+ // asset download, GetCommunication().init()) is an init/connect
+ // failure, not session expiration. The credential we used is
+ // suspect — drop it and present the login form so the user
+ // can retry instead of getting stuck on a stale "Session expired".
+ onInitFailure();
}
};
+ // React Strict Mode in dev runs every effect twice (mount → cleanup → mount).
+ // `prepare()` is full of one-shot side effects (renderer init, websocket
+ // connect, NitroConfig mutation) — calling it twice with the same trigger
+ // value causes the second pass to race with the first and clobber state
+ // (e.g. the second pass falls through to onSessionExpired while the first
+ // had just set showLogin=true). Guard by trigger value: skip duplicate
+ // runs at the same trigger, but still re-run when handleAuthenticated
+ // bumps prepareTrigger after a successful login.
+ if(lastPrepareTriggerRef.current === prepareTrigger) return;
+ lastPrepareTriggerRef.current = prepareTrigger;
+
const { width, height } = getViewportDimensions();
prepare(width, height);
@@ -455,7 +587,13 @@ export const App: FC<{}> = props =>
0 } message={ errorMessage } homeUrl={ homeUrl } /> }
{ !isReady && showLogin && }
{ isReady && }
-
+ { /* Reconnect overlay must NOT render before we've actually entered
+ the hotel — otherwise the renderer's auto-retry on an initial
+ handshake failure (e.g. emulator unreachable) would cover the
+ login form with "Reconnecting..." → "Session expired" and the
+ user wouldn't be able to interact with the form we just put up
+ via fallbackToLogin. */ }
+ { isReady && }
);
diff --git a/src/bootstrap.ts b/src/bootstrap.ts
index 3aeda2f..fe1eb9a 100644
--- a/src/bootstrap.ts
+++ b/src/bootstrap.ts
@@ -1,3 +1,4 @@
+import { GetConfiguration } from '@nitrots/configuration';
import { configFileUrl, getClientMode, installSecureFetch } from './secure-assets';
const ensureMobileViewport = () =>
@@ -15,7 +16,6 @@ const ensureMobileViewport = () =>
};
ensureMobileViewport();
-installSecureFetch();
const setBootDebug = (message: string) =>
{
@@ -30,8 +30,6 @@ const setBootDebug = (message: string) =>
{}
};
-setBootDebug('boot: secure fetch installed');
-
const deployBaseUrl = (): string =>
{
try
@@ -89,6 +87,9 @@ const loadClientMode = async () =>
await loadClientMode();
+installSecureFetch();
+setBootDebug('boot: secure fetch installed');
+
const search = new URLSearchParams(window.location.search);
const clientMode = getClientMode();
@@ -107,6 +108,21 @@ const clientMode = getClientMode();
setBootDebug('boot: NitroConfig assigned');
+// Load renderer-config.json + ui-config.json BEFORE rendering React. Otherwise
+// the first paint triggers a flood of "Missing configuration key" warnings for
+// every key components read synchronously (asset.url, login.endpoint, …) until
+// prepare()'s deferred init() finally lands. Doing it here makes the config
+// already populated by the time index.tsx mounts .
+try
+{
+ await GetConfiguration().init();
+ setBootDebug('boot: configuration init done');
+}
+catch(error)
+{
+ setBootDebug(`boot: configuration init failed ${ error?.message || error }`);
+}
+
import('./index')
.then(() => setBootDebug('boot: app bundle imported'))
.catch(error =>
diff --git a/src/secure-assets.ts b/src/secure-assets.ts
index 7f7c170..e2fc71e 100644
--- a/src/secure-assets.ts
+++ b/src/secure-assets.ts
@@ -123,15 +123,19 @@ const REKEY_ENDPOINTS = new Set([
'/api/auth/logout'
]);
+let clientModeCache: NitroClientMode | null = null;
+
export const getClientMode = (): NitroClientMode =>
{
+ if(clientModeCache) return clientModeCache;
+
try
{
const configured = (window as any).__nitroClientMode;
if(configured && typeof configured === 'object')
{
- return {
+ clientModeCache = {
distObfuscationEnabled: configured.distObfuscationEnabled !== false,
secureAssetsEnabled: configured.secureAssetsEnabled !== false,
secureApiEnabled: configured.secureApiEnabled !== false,
@@ -139,12 +143,14 @@ export const getClientMode = (): NitroClientMode =>
plainConfigBaseUrl: typeof configured.plainConfigBaseUrl === 'string' ? configured.plainConfigBaseUrl : '',
plainGamedataBaseUrl: typeof configured.plainGamedataBaseUrl === 'string' ? configured.plainGamedataBaseUrl : ''
};
+
+ return clientModeCache;
}
}
catch
{}
- return { ...CLIENT_MODE_DEFAULTS };
+ return CLIENT_MODE_DEFAULTS;
};
const bytesToBase64 = (bytes: ArrayBuffer): string =>
@@ -489,6 +495,14 @@ export const installSecureFetch = (): void =>
{
if(installed) return;
+ const mode = getClientMode();
+
+ if(!mode.secureAssetsEnabled && !mode.secureApiEnabled)
+ {
+ installed = true;
+ return;
+ }
+
installed = true;
const nativeFetch = window.fetch.bind(window);
diff --git a/vite.config.mjs b/vite.config.mjs
index 29fe0fa..56ed2c6 100644
--- a/vite.config.mjs
+++ b/vite.config.mjs
@@ -2,11 +2,56 @@ import react from '@vitejs/plugin-react';
import { existsSync } from 'fs';
import { resolve } from 'path';
import { defineConfig } from 'vite';
+import sirv from 'sirv';
const legacyRendererRoot = resolve(__dirname, '..', 'renderer');
const currentRendererRoot = resolve(__dirname, '..', 'Nitro_Render_V3');
const rendererRoot = existsSync(currentRendererRoot) ? currentRendererRoot : legacyRendererRoot;
+// Game assets live outside the repo, in a sibling directory next to Nitro-V3.
+// They are NOT placed under public/ on purpose: with ~177k files a symlink
+// under public/ makes chokidar try to install a watcher on each one and the
+// dev server takes minutes to start on Windows. Serving them with a
+// dedicated sirv middleware (below) bypasses chokidar entirely.
+const nitroFilesRoot = resolve(__dirname, '..', 'Nitro-Files');
+const nitroAssetsRoot = resolve(nitroFilesRoot, 'nitro-assets');
+const swfRoot = resolve(nitroFilesRoot, 'swf');
+
+const nitroAssetsServer = () => ({
+ name: 'nitro-assets-serve',
+ configureServer(server)
+ {
+ if(existsSync(nitroAssetsRoot))
+ {
+ server.middlewares.use('/nitro-assets', sirv(nitroAssetsRoot, { dev: true, etag: true, maxAge: 0 }));
+ }
+ else
+ {
+ server.config.logger.warn(`[nitro-assets-serve] ${ nitroAssetsRoot } not found — /nitro-assets/* requests will 404.`);
+ }
+
+ if(existsSync(swfRoot))
+ {
+ server.middlewares.use('/swf', sirv(swfRoot, { dev: true, etag: true, maxAge: 0 }));
+ }
+ else
+ {
+ server.config.logger.warn(`[nitro-assets-serve] ${ swfRoot } not found — /swf/* requests will 404.`);
+ }
+ },
+ configurePreviewServer(server)
+ {
+ if(existsSync(nitroAssetsRoot))
+ {
+ server.middlewares.use('/nitro-assets', sirv(nitroAssetsRoot, { dev: false, etag: true }));
+ }
+ if(existsSync(swfRoot))
+ {
+ server.middlewares.use('/swf', sirv(swfRoot, { dev: false, etag: true }));
+ }
+ }
+});
+
if(!existsSync(rendererRoot))
{
// Fail fast with a useful message instead of waiting for Rolldown to
@@ -36,7 +81,8 @@ export default defineConfig({
[ 'babel-plugin-react-compiler', ReactCompilerConfig ]
]
}
- })
+ }),
+ nitroAssetsServer()
],
server: {
fs: {
@@ -47,7 +93,7 @@ export default defineConfig({
},
proxy: {
'/api': {
- target: process.env.AUTH_PROXY_TARGET || 'http://192.168.0.181:2096',
+ target: process.env.AUTH_PROXY_TARGET || 'http://localhost:2096',
changeOrigin: true,
}
}
diff --git a/yarn.lock b/yarn.lock
index cd86ce1..80a5588 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -701,6 +701,11 @@
"@parcel/watcher-win32-ia32" "2.5.6"
"@parcel/watcher-win32-x64" "2.5.6"
+"@polka/url@^1.0.0-next.24":
+ version "1.0.0-next.29"
+ resolved "https://registry.yarnpkg.com/@polka/url/-/url-1.0.0-next.29.tgz#5a40109a1ab5f84d6fd8fc928b19f367cbe7e7b1"
+ integrity sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==
+
"@radix-ui/number@1.1.1":
version "1.1.1"
resolved "https://registry.yarnpkg.com/@radix-ui/number/-/number-1.1.1.tgz#7b2c9225fbf1b126539551f5985769d0048d9090"
@@ -3096,6 +3101,11 @@ motion-utils@^12.36.0:
resolved "https://registry.yarnpkg.com/motion-utils/-/motion-utils-12.36.0.tgz#cff2df2a28c3fe53a3de7e0103ba7f73ff7d77a7"
integrity sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg==
+mrmime@^2.0.0:
+ version "2.0.1"
+ resolved "https://registry.yarnpkg.com/mrmime/-/mrmime-2.0.1.tgz#bc3e87f7987853a54c9850eeb1f1078cd44adddc"
+ integrity sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==
+
ms@^2.1.3:
version "2.1.3"
resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2"
@@ -3665,6 +3675,15 @@ siginfo@^2.0.0:
resolved "https://registry.yarnpkg.com/siginfo/-/siginfo-2.0.0.tgz#32e76c70b79724e3bb567cb9d543eb858ccfaf30"
integrity sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==
+sirv@^3.0.2:
+ version "3.0.2"
+ resolved "https://registry.yarnpkg.com/sirv/-/sirv-3.0.2.tgz#f775fccf10e22a40832684848d636346f41cd970"
+ integrity sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==
+ dependencies:
+ "@polka/url" "^1.0.0-next.24"
+ mrmime "^2.0.0"
+ totalist "^3.0.0"
+
"source-map-js@>=0.6.2 <2.0.0", source-map-js@^1.2.1:
version "1.2.1"
resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46"
@@ -3826,6 +3845,11 @@ tldts@^7.0.5:
dependencies:
tldts-core "^7.0.30"
+totalist@^3.0.0:
+ version "3.0.1"
+ resolved "https://registry.yarnpkg.com/totalist/-/totalist-3.0.1.tgz#ba3a3d600c915b1a97872348f79c127475f6acf8"
+ integrity sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==
+
tough-cookie@^6.0.1:
version "6.0.1"
resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-6.0.1.tgz#a495f833836609ed983c19bc65639cfbceb54c76"