diff --git a/components/game/GameFlowManager.tsx b/components/game/GameFlowManager.tsx index 4466f40..75cf803 100644 --- a/components/game/GameFlowManager.tsx +++ b/components/game/GameFlowManager.tsx @@ -113,7 +113,7 @@ type GamePhase = 'menu' | 'sign-in' | 'username-setup' | 'character-select' | 'g * All player progress is stored ON-CHAIN via the contract. */ export default function GameFlowManager() { - const { address, isConnected, realAddress, isGuest, isMiniPay, connect } = useWallet() + const { address, isConnected, realAddress, isGuest, isMiniPay, walletReady, connect } = useWallet() const { playerProfile, isLoading: isLoadingProfile, @@ -535,6 +535,35 @@ export default function GameFlowManager() { // Skippable, per the owner's choice: a player who declines plays as a guest // exactly as before. Declining is remembered, so it asks once and not again. const [gateDone, setGateDone] = useState(false) + + // ── THE MINIPAY FLASH, AND WHY WAITING WAS THE WRONG FIX ────────────────── + // + // OWNER, testing inside MiniPay: *"masih sempet muncul halaman loginnya, + // meskipun sekedip langsung hilang."* He is right that it matters — a screen + // between the player and the game is exactly what MiniPay's listing rules + // reject, and "only for a moment" is not a defence. + // + // The cause was timing, not logic. `isMiniPay` is read from window.ethereum + // inside WagmiIsland, and lib/Web3Providers.tsx mounts that island on + // requestIdleCallback with a 1500ms ceiling — so the bridge served its + // default `isMiniPay: false` to a MiniPay player for up to a second and a + // half, and shouldOfferSignIn() answered "yes, ask them". + // + // The obvious fix — hold the gate until `walletReady` — was written, measured + // and thrown away. It works, but it costs the thing this screen exists for: + // + // inside MiniPay gate never appeared (0 of 165 polls over 5s) ✓ + // outside map at 50ms, gate at 1272ms ✗ + // + // A login that lands on top of a map the player has already been looking at + // for a second IS the bug he reported the first time. So the wait is gone and + // the guarantee moved into lib/privyGateFlag.ts, which reads MiniPay straight + // off window.ethereum and needs nothing mounted — correct on the very first + // render, and re-checked for a beat afterwards in case injection is late. + // + // `walletReady` stays in the destructure above deliberately unused by this + // rule: shouldOfferSignIn()'s own `!isMiniPay` is the third guard, and it + // arrives when wagmi does. const showPrivyGate = privyGateOn && !gateDone && shouldOfferSignIn() // The identity work itself lives in lib/useAccountActions — Settings needs diff --git a/lib/privyGateFlag.ts b/lib/privyGateFlag.ts index b161901..595dbf2 100644 --- a/lib/privyGateFlag.ts +++ b/lib/privyGateFlag.ts @@ -40,7 +40,33 @@ export function privyAppId(): string | null { return typeof id === 'string' && id.trim() ? id.trim() : null } +/** + * MiniPay, read straight off the injected provider. + * + * A DELIBERATE second copy of a rule that already lives at the call site, and + * safe to duplicate because it is one-way: it can only ever turn the gate OFF. + * If the two ever disagree, the disagreement costs a MiniPay player nothing and + * a non-MiniPay player one screen they were going to see anyway. + * + * It exists because the authoritative check goes through wagmi, and wagmi is + * mounted on an idle callback with a 1500ms ceiling (lib/Web3Providers.tsx). + * This one needs nothing mounted — MiniPay injects window.ethereum before the + * page's own scripts run — so it is right from the very first render, which is + * precisely the window in which the gate was flashing. + */ +function isMiniPayNow(): boolean { + if (typeof window === 'undefined') return false + try { + return !!(window as unknown as { ethereum?: { isMiniPay?: boolean } }).ethereum?.isMiniPay + } catch { + return false + } +} + export function readPrivyGateFlag(): boolean { + // Before anything else, including the query-string override. There is no + // argument for a URL that can force a login screen into MiniPay. + if (isMiniPayNow()) return false // No app id means Privy cannot initialise. Fall back rather than render a // login screen that can never log anyone in. if (!privyAppId()) return false @@ -65,6 +91,27 @@ export function readPrivyGateFlag(): boolean { */ export function usePrivyGateFlag(): boolean { const [on, setOn] = useState(false) - useEffect(() => { setOn(readPrivyGateFlag()) }, []) + useEffect(() => { + setOn(readPrivyGateFlag()) + // ── Re-checked for a beat, then never again ────────────────────────────── + // + // The gate renders on the first paint, which is the whole point: waiting + // for wagmi put the world map on screen 1.2s BEFORE the login, which is the + // bug this feature was reported for in the first place. + // + // Rendering that early means trusting a synchronous read of + // window.ethereum, and the one way that read can be wrong is a provider + // injected a moment late. So it is re-run for 1.5s — the same ceiling + // lib/Web3Providers.tsx gives wagmi — and any MiniPay that turns up inside + // that window switches the gate off within a frame instead of leaving it up + // until wagmi mounts. It only ever flips ON→OFF; nothing here can turn the + // gate on for somebody it was off for. + let n = 0 + const id = setInterval(() => { + if (!readPrivyGateFlag()) { setOn(false); clearInterval(id) } + if (++n >= 15) clearInterval(id) + }, 100) + return () => clearInterval(id) + }, []) return on } diff --git a/scripts/test-privy-gate.js b/scripts/test-privy-gate.js index f5b5459..a3059e7 100644 --- a/scripts/test-privy-gate.js +++ b/scripts/test-privy-gate.js @@ -65,6 +65,32 @@ ok('and the component refuses to mount the provider without one', // carrying a second condition, so `!isMiniPay` cannot drift out of it. ok('the gate is gated on shouldOfferSignIn(), not a second copy of the rule', /const showPrivyGate = privyGateOn && !gateDone && shouldOfferSignIn\(\)/.test(flow)) + +// ── THE MINIPAY FLASH ─────────────────────────────────────────────────────── +// +// The rule was right and the TIMING was wrong. `isMiniPay` comes from wagmi, +// and wagmi mounts on requestIdleCallback with a 1500ms ceiling — so inside +// MiniPay the bridge answered `isMiniPay: false` for up to a second and a half +// and the gate rendered. Owner: *"masih sempet muncul halaman loginnya."* +// +// Holding the gate until wagmi reported was written, measured and thrown away: +// it kept MiniPay clean but put the map on screen at 50ms and the login at +// 1272ms, which is the ORIGINAL complaint wearing a different hat. The +// guarantee lives in a synchronous read instead, so the gate still renders on +// the first paint. +ok('the gate does NOT wait for wagmi — that fix cost the placement', + !/gateArmed/.test(flow)) +ok('and the flag reads MiniPay straight off window.ethereum, needing nothing mounted', + /if \(isMiniPayNow\(\)\) return false/.test(flag) && + /ethereum\?: \{ isMiniPay\?: boolean \}/.test(flag)) +// Injection a moment late is the one way a synchronous read can be wrong, so +// the flag is re-read for the same 1500ms ceiling wagmi gets — one way only. +ok('and it keeps re-checking briefly, in case the provider is injected late', + /setInterval\(\(\) => \{/.test(flag) && /if \(!readPrivyGateFlag\(\)\) \{ setOn\(false\)/.test(flag)) + +// The query override must not be an escape hatch INTO MiniPay. +ok('and no ?privy=1 can force a login screen into MiniPay', + flag.indexOf('if (isMiniPayNow()) return false') < flag.indexOf("get('privy')")) ok('and shouldOfferSignIn still starts with !isMiniPay', /!isMiniPay && !realAddress && !getStoredAuthAddress\(\) && !hasSkippedSignIn\(\)/.test(flow)) ok('the SDK is dynamically imported, never in the initial chunk',