Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions app/game/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,60 @@ import '../../styles/game.css'
// itself is already using (see lib/useContractPlayer.ts).
// · The Celo RPC is only reached after wagmi mounts on idle, which is well
// after the map has painted — nothing waiting on it is being waited for.
// ─── The veil: 0.5s of world map that should not have been there ────────────
//
// OWNER: *"terus kejar yg 0.5 detik itu."*
//
// Outside MiniPay the map was visible under the gate for about half a second,
// and no amount of React could fix it: the map is in the PRERENDERED HTML, so
// it paints before a single line of JavaScript has run. That is deliberate and
// worth keeping — it is what /game's LCP optimisation buys, and MiniPay players
// (who never see the gate) get the whole benefit.
//
// So the fix is the only thing that can run earlier than React: an inline
// script in the document itself. If the gate is going to appear, it marks the
// document before the first paint and CSS hides the home screen. If it is not,
// it does nothing at all and the map paints exactly as it does today.
//
// It marks <html> rather than appending an overlay, and that is not a style
// preference — the overlay version was written and measured first. Next's
// hydration reconciles the children of <body> and threw the node away at 261ms,
// long before the gate was ready to cover anything, so the map was on screen
// from 323ms to 882ms. An attribute React never rendered is not part of that
// reconciliation and survives.
//
// ── THIS IS A THIRD COPY OF THE MINIPAY RULE, AND THAT WAS A DECISION ───────
//
// The rule already lives in shouldOfferSignIn() and in privyGateFlag. Copying
// it again is a real cost, accepted because the copy is ONE-WAY: every branch
// below can only decide "do not veil". A drift between the copies costs a
// non-MiniPay player half a second of map — never the reverse, and MiniPay
// cannot be reached by it at all.
//
// The dead-man's timer matters as much as the conditions: if React never
// arrives — a chunk 404, a JS error — the sheet lifts by itself after 3s rather
// than leaving somebody staring at a black screen forever.
const GATE_VEIL = `(function(){try{
if(window.ethereum&&window.ethereum.isMiniPay)return;
var q=null;try{q=new URLSearchParams(location.search).get('privy')}catch(e){}
if(q==='0')return;
if(!__APP_ID__)return;
if(q!=='1'&&__ENV_ON__!=='1')return;
if(localStorage.getItem('nullstate-auth-address'))return;
if(localStorage.getItem('nullstate-signin-skipped')==='1')return;
var h=document.documentElement;
h.setAttribute('data-ns-gate','1');
setTimeout(function(){h.removeAttribute('data-ns-gate')},3000);
}catch(e){}})();`

export default function GameLayout({ children }: { children: React.ReactNode }) {
const veil = GATE_VEIL
.replace('__APP_ID__', JSON.stringify(process.env.NEXT_PUBLIC_PRIVY_APP_ID || ''))
.replace('__ENV_ON__', JSON.stringify(process.env.NEXT_PUBLIC_PRIVY_GATE || ''))
return (
<Web3Providers>
{/* Before {children}, so it runs before the map markup is even parsed. */}
<script dangerouslySetInnerHTML={{ __html: veil }} />
{children}
</Web3Providers>
)
Expand Down
12 changes: 11 additions & 1 deletion components/game/GameFlowManager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { readHighestAct, recordHighestAct, stashCampaignResume, takeCampaignResu
import MainMenu from './MainMenu'
import WorldMapHub from './WorldMapHub'
import { useWorldMapHubFlag } from '@/lib/worldMapHubFlag'
import { usePrivyGateFlag } from '@/lib/privyGateFlag'
import { usePrivyGateFlag, liftGateVeil } from '@/lib/privyGateFlag'
import { markCampaignComplete } from '@/lib/campaignComplete'
import NewGameConfirmModal from './NewGameConfirmModal'
import WelcomeGiftModal from './WelcomeGiftModal'
Expand Down Expand Up @@ -566,6 +566,16 @@ export default function GameFlowManager() {
// arrives when wagmi does.
const showPrivyGate = privyGateOn && !gateDone && shouldOfferSignIn()

// The one case the inline script cannot decide for itself: it can read
// localStorage and window.ethereum, but not whether an injected wallet is
// about to connect — that needs wagmi. So if the sheet went down and the gate
// turns out not to be coming, lift it as soon as the wallet situation is
// known. `walletReady` is that moment. (The script's own 3s timer is the
// backstop for a React that never arrives at all.)
useEffect(() => {
if (walletReady && !showPrivyGate) liftGateVeil()
}, [walletReady, showPrivyGate])

// The identity work itself lives in lib/useAccountActions — Settings needs
// the same three actions, and the migrate-then-store ORDER is too easy to get
// wrong in a second copy. What stays here is only the part this screen owns:
Expand Down
2 changes: 1 addition & 1 deletion components/game/MainMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ export default function MainMenu({
const hasSave = !!playerProfile?.isRegistered

return (
<div className="fixed inset-0 z-50 flex flex-col items-center overflow-y-auto ns-fade-in">
<div className="ns-home-root fixed inset-0 z-50 flex flex-col items-center overflow-y-auto ns-fade-in">
{/* Blurred dungeon background (pre-processed asset, no live CSS blur) */}
<div
className="fixed inset-0 pointer-events-none"
Expand Down
8 changes: 7 additions & 1 deletion components/game/PrivyGate.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import { useCallback, useEffect, useRef, useState } from 'react'
import { PrivyProvider, usePrivy, useLoginWithOAuth } from '@privy-io/react-auth'
import { privyAppId } from '@/lib/privyGateFlag'
import { privyAppId, liftGateVeil } from '@/lib/privyGateFlag'
import { GoogleMark, MailMark, WalletMark } from './SignInMarks'
import '@/styles/signin.css'

Expand Down Expand Up @@ -62,6 +62,12 @@ function GateInner({ onAuthenticated, onSkip }: PrivyGateProps) {
const [busy, setBusy] = useState<Method | null>(null)
const adopted = useRef(false)

// This screen is its own full-screen backdrop, so the sheet the inline script
// laid over the prerendered map has nothing left to hide. Lifting it here
// rather than in the layout is what makes the handover seamless: the sheet
// goes only once something is definitely drawn on top of it.
useEffect(() => { liftGateVeil() }, [])

// Privy restores an existing session asynchronously. Someone who already
// linked an account must not be asked again — that is the same rule the
// Firebase path follows via getStoredAuthAddress().
Expand Down
2 changes: 1 addition & 1 deletion components/game/WorldMapHub.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -319,7 +319,7 @@ export default function WorldMapHub({
}

return (
<div className="fixed inset-0 z-50 overflow-hidden ns-fade-in" style={{ background: '#060b09' }}>
<div className="ns-home-root fixed inset-0 z-50 overflow-hidden ns-fade-in" style={{ background: '#060b09' }}>
{/* Map layer — keeps the art's own aspect ratio so node markers stay glued
to the painted doors regardless of screen size (see .ns-hub-map). */}
<div className="ns-hub-map">
Expand Down
16 changes: 16 additions & 0 deletions lib/privyGateFlag.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,22 @@ export function readPrivyGateFlag(): boolean {
* loads nothing: the gate appears a frame later on the rare deployment that
* enables it, and never flickers on the normal path.
*/
/**
* Clears the mark the inline script in app/game/layout.tsx puts on <html> to
* hide the prerendered home screen. Safe to call any number of times.
*
* Two callers, and both are needed. PrivyGate lifts it once the gate has
* actually painted — the gate is its own full-screen backdrop, so the sheet has
* nothing left to hide. GameFlowManager lifts it the moment it turns out the
* gate is NOT coming after all, which is the case the script cannot decide for
* itself: whether an injected wallet is about to connect is not knowable until
* wagmi has mounted.
*/
export function liftGateVeil() {
if (typeof document === 'undefined') return
document.documentElement.removeAttribute('data-ns-gate')
}

export function usePrivyGateFlag(): boolean {
const [on, setOn] = useState(false)
useEffect(() => {
Expand Down
40 changes: 40 additions & 0 deletions scripts/test-privy-gate.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ async function serve() {
const flag = fs.readFileSync(path.join(__dirname, '..', 'lib', 'privyGateFlag.ts'), 'utf8')
const flow = fs.readFileSync(path.join(__dirname, '..', 'components', 'game', 'GameFlowManager.tsx'), 'utf8')
const gate = fs.readFileSync(path.join(__dirname, '..', 'components', 'game', 'PrivyGate.tsx'), 'utf8')
const layout = fs.readFileSync(path.join(__dirname, '..', 'app', 'game', 'layout.tsx'), 'utf8')
const gamecss = fs.readFileSync(path.join(__dirname, '..', 'styles', 'game.css'), 'utf8')

ok('the gate is OFF unless the env var says exactly "1"',
/const ENV_ON = process\.env\.NEXT_PUBLIC_PRIVY_GATE === '1'/.test(flag))
Expand Down Expand Up @@ -88,6 +90,40 @@ ok('and the flag reads MiniPay straight off window.ethereum, needing nothing mou
ok('and it keeps re-checking briefly, in case the provider is injected late',
/setInterval\(\(\) => \{/.test(flag) && /if \(!readPrivyGateFlag\(\)\) \{ setOn\(false\)/.test(flag))

// ── THE VEIL ────────────────────────────────────────────────────────────────
//
// The map is in the PRERENDERED HTML, so it paints before any JavaScript runs
// and was visible under the gate for ~0.5s. Only something that runs earlier
// than React can fix that, so an inline script marks <html> before first paint.
//
// Measured, three ways:
// inside MiniPay no mark, home visible 102ms (untouched)
// new player outside mark 144→509ms, home NEVER visible, gate 547ms
// returning (skipped once) no mark, home visible 95ms (no black screen)
ok('MiniPay is the FIRST thing the inline script checks',
/if\(window\.ethereum&&window\.ethereum\.isMiniPay\)return;/.test(layout) &&
layout.indexOf('isMiniPay)return') < layout.indexOf("getItem('nullstate-auth-address')"))
ok('a player who already has an account or skipped once is never veiled',
/getItem\('nullstate-auth-address'\)\)return;/.test(layout) &&
/getItem\('nullstate-signin-skipped'\)==='1'\)return;/.test(layout))
// An overlay ELEMENT was the first attempt and Next's hydration deleted it at
// 261ms, 600ms before the gate could cover anything. An attribute React never
// rendered survives that reconciliation.
ok('it marks <html> rather than appending a node hydration would delete',
/setAttribute\('data-ns-gate','1'\)/.test(layout) && !/appendChild/.test(layout))
ok('and lifts itself after 3s even if React never arrives',
/setTimeout\(function\(\)\{h\.removeAttribute\('data-ns-gate'\)\},3000\)/.test(layout))
ok('the stylesheet hides the home screen while that mark is set',
/html\[data-ns-gate\] \.ns-home-root \{ visibility: hidden \}/.test(gamecss))
// visibility, not display/removal: the map's image request must survive, or a
// player who skips the gate pays for it twice.
ok('by visibility, so the map image is not fetched twice',
!/html\[data-ns-gate\][^\n]*display: *none/.test(gamecss))
ok('the gate lifts it once it has painted',
/useEffect\(\(\) => \{ liftGateVeil\(\) \}, \[\]\)/.test(gate))
ok('and the flow manager lifts it the moment the gate turns out not to be coming',
/if \(walletReady && !showPrivyGate\) liftGateVeil\(\)/.test(flow))

// 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')"))
Expand Down Expand Up @@ -200,6 +236,10 @@ ok('Privy is a real dependency, not an aspiration',
const body = (await page.textContent('body')) || ''
ok('and is not shown the gate', !/KEEP YOUR RUN/.test(body))
ok('nothing threw', errs.length === 0, errs.slice(0, 2).join(' | ') || 'clean')
// The veil must not fire on the default path either — a black screen for a
// gate that is never coming is worse than the flash it was fixing.
ok('and the home screen is never veiled with the flag off',
!(await page.evaluate(() => document.documentElement.hasAttribute('data-ns-gate'))))
await page.context().close()
}

Expand Down
18 changes: 18 additions & 0 deletions styles/game.css
Original file line number Diff line number Diff line change
Expand Up @@ -981,3 +981,21 @@
font-size:9.5px; line-height:1.5; color:#9fbdaf;
}
.ns-exit-streak-note b{ color:#ffe9a8 }

/* ─── The gate veil ──────────────────────────────────────────────────────────
Set by the inline script in app/game/layout.tsx, before the first paint, and
only when the Privy gate is certain to appear. See the long note there for
why it is an attribute on <html> and not an overlay element: Next's
hydration reconciles the children of <body> and removed the overlay at 261ms,
which is 600ms before the gate could cover anything.

Hiding rather than not-rendering: the map is in the prerendered HTML and
cannot be un-rendered from the client. `visibility` keeps its layout and its
image request intact, so a player who skips the gate gets the map instantly
instead of paying for it twice.

Cleared by lib/privyGateFlag.ts's liftGateVeil() from two places — the gate
once it has painted, and the flow manager the moment it turns out the gate is
not coming — with the script's own 3s timer as the backstop. */
html[data-ns-gate] { background: #000 }
html[data-ns-gate] .ns-home-root { visibility: hidden }
Loading