diff --git a/.npmrc b/.npmrc index 5cd4239..d93f821 100644 --- a/.npmrc +++ b/.npmrc @@ -5,6 +5,6 @@ legacy-peer-deps=true # kept as documentation of intent and will take effect if the project ever # migrates to pnpm or uses a private registry proxy that honours it. minimum-release-age=10080 -# Prevent lifecycle scripts from running automatically on install. -# Protects against supply-chain attacks that exploit postinstall hooks. -ignore-scripts=true +# NOTE: npm has no per-package lifecycle-script allowlist. A blanket +# ignore-scripts=true would also block legitimate native packages like sharp, +# whose postinstall step is required to fetch the correct build binary. diff --git a/_archive/game/GameFullUI.tsx b/_archive/game/GameFullUI.tsx deleted file mode 100644 index 65815b6..0000000 --- a/_archive/game/GameFullUI.tsx +++ /dev/null @@ -1,939 +0,0 @@ -'use client' - -import { useState, useRef, useEffect, useCallback } from 'react' -import Link from 'next/link' -import { motion, AnimatePresence } from 'framer-motion' -import { useWallet, NULLSTATE_ADDRESS, PlayerData, RaidData } from '@/lib/WalletProvider' -import { Enemy, NarrativeMessage, GameAction, ENEMIES, INITIAL_PLAYER, COMBAT_ACTIONS } from '@/lib/game-types' -import { db } from '@/lib/firebase' -import { collection, onSnapshot, query, orderBy, limit } from 'firebase/firestore' - -// ── Types ───────────────────────────────────────────────────────────────────── -type GamePhase = 'char_select' | 'menu' | 'registering' | 'combat' | 'zone_transition' | 'victory' | 'dead' | 'loading' -type CharChoice = 'male' | 'female' -type PlayerAnimState = 'idle' | 'walk' | 'attack' | 'hit' -type EnemyAnimState = 'idle' | 'attack' | 'hit' | 'death' - -// ── Sprite configs (verified from actual files) ─────────────────────────────── -const HERO_SPRITES = { - male: { - idle: { src: '/sprites/hero/idle.png', fw: 288, fh: 240, frames: 8 }, - attack: { src: '/sprites/hero/attack.png', fw: 288, fh: 240, frames: 8 }, - walk: { src: '/sprites/hero/run.png', fw: 288, fh: 240, frames: 8 }, - }, - female: { - idle: { src: '/sprites/hero/female_idle.png', fw: 192, fh: 192, frames: 6 }, - attack: { src: '/sprites/hero/female_attack.png', fw: 192, fh: 192, frames: 6 }, - walk: { src: '/sprites/hero/female_walk.png', fw: 192, fh: 192, frames: 6 }, - }, -} - -// Zone sequence: each zone has a background and an enemy pool -const ZONES = [ - { - id: 'forest', - bg: '/backgrounds/forest.png', - enemies: ['gas-goblin', 'null-pointer'], - }, - { - id: 'snow', - bg: '/backgrounds/snow.png', - enemies: ['null-pointer', 'rug-phantom'], - }, - { - id: 'desert', - bg: '/backgrounds/desert.png', - enemies: ['rug-phantom', 'fork-wraith'], - }, - { - id: 'void', - bg: '/backgrounds/back.png', - enemies: ['fork-wraith'], - isBossZone: true, - }, -] - -// Monster sprite config keyed by enemy id -const MONSTER_SPRITES: Record = { - 'gas-goblin': { - idle: '/sprites/monsters/mummy_idle.png', attack: '/sprites/monsters/mummy_attack.png', - fw: 128, fh: 128, idleFrames: 8, attackFrames: 10, - }, - 'null-pointer': { - idle: '/sprites/monsters/ice_idle.png', attack: '/sprites/monsters/ice_attack.png', - fw: 128, fh: 128, idleFrames: 8, attackFrames: 10, - }, - 'rug-phantom': { - idle: '/sprites/monsters/shadow_idle.png', attack: '/sprites/monsters/shadow_attack.png', - fw: 180, fh: 180, idleFrames: 5, attackFrames: 6, - }, - 'fork-wraith': { - idle: '/sprites/monsters/creature_idle.png', attack: '/sprites/monsters/creature_attack.png', - hit: '/sprites/monsters/creature_hit.png', - fw: 256, fh: 256, idleFrames: 16, attackFrames: 16, hitFrames: 12, - }, - 'boss': { - idle: '/sprites/monsters/boss_idle.png', attack: '/sprites/monsters/boss_attack.png', - fw: 256, fh: 256, idleFrames: 6, attackFrames: 8, - }, -} - -// ── Sprite Animator ─────────────────────────────────────────────────────────── -function SpriteAnim({ - src, fw, fh, frames, fps = 8, flipX = false, - brightness, style, -}: { - src: string; fw: number; fh: number; frames: number; fps?: number - flipX?: boolean; brightness?: number; style?: React.CSSProperties -}) { - const [frame, setFrame] = useState(0) - const rafRef = useRef() - const lastRef = useRef(0) - const fpsRef = useRef(fps) - fpsRef.current = fps - - useEffect(() => { - setFrame(0) - }, [src]) - - useEffect(() => { - const tick = (now: number) => { - if (now - lastRef.current >= 1000 / fpsRef.current) { - setFrame(f => (f + 1) % frames) - lastRef.current = now - } - rafRef.current = requestAnimationFrame(tick) - } - rafRef.current = requestAnimationFrame(tick) - return () => { if (rafRef.current) cancelAnimationFrame(rafRef.current) } - }, [frames]) - - return ( -
- ) -} - -// ── Ground shadow ───────────────────────────────────────────────────────────── -function GroundShadow({ w }: { w: number }) { - return ( -
- ) -} - -// ── Character Select Screen ─────────────────────────────────────────────────── -function CharSelect({ onSelect }: { onSelect: (c: CharChoice) => void }) { - return ( -
-
- Choose Your Hero -
-
- {/* Male hero */} - - - {/* Female hero */} - -
-
- ) -} - -// ── Zone Transition ─────────────────────────────────────────────────────────── -function ZoneTransition({ onDone, heroChar, heroSrc, fw, fh, frames }: { - onDone: () => void; heroChar: CharChoice - heroSrc: string; fw: number; fh: number; frames: number -}) { - const [runX, setRunX] = useState(0) - const [phase, setPhase] = useState<'run_out' | 'dark' | 'done'>('run_out') - - useEffect(() => { - // Hero runs to the right edge - const t1 = setTimeout(() => setPhase('dark'), 1200) - const t2 = setTimeout(() => { setPhase('done'); onDone() }, 2200) - return () => { clearTimeout(t1); clearTimeout(t2) } - }, [onDone]) - - return ( -
- {phase === 'run_out' && ( - - - - )} - {phase === 'dark' && ( -
-
- LOADING... -
-
- )} -
- ) -} - -// ── Battle Arena ────────────────────────────────────────────────────────────── -function BattleArena({ - enemy, phase, bg, - heroChar, playerAnim, enemyAnim, - dmgNumbers, screenShake, - onZoneTransitionDone, -}: { - enemy: Enemy | null; phase: GamePhase; bg: string - heroChar: CharChoice - playerAnim: PlayerAnimState; enemyAnim: EnemyAnimState - dmgNumbers: Array<{ id: number; val: number; side: 'enemy' | 'player' }> - screenShake: boolean - onZoneTransitionDone?: () => void -}) { - const hero = HERO_SPRITES[heroChar] - const monCfg = enemy ? (MONSTER_SPRITES[enemy.id] ?? MONSTER_SPRITES['boss']) : null - - const getHeroSprite = () => { - if (playerAnim === 'walk') return hero.walk - if (playerAnim === 'attack') return hero.attack - return hero.idle - } - - const getMonsterSrc = () => { - if (!monCfg) return MONSTER_SPRITES.boss.idle - if (enemyAnim === 'attack') return monCfg.attack - if (enemyAnim === 'hit' && monCfg.hit) return monCfg.hit - return monCfg.idle - } - - const getMonsterFrames = () => { - if (!monCfg) return MONSTER_SPRITES.boss.idleFrames - if (enemyAnim === 'attack') return monCfg.attackFrames - if (enemyAnim === 'hit' && monCfg.hitFrames) return monCfg.hitFrames - return monCfg.idleFrames - } - - const heroSprite = getHeroSprite() - - // Scale sprites to a consistent display size relative to arena height (280px) - // Target: sprite should be ~50% of arena height = 140px tall - const ARENA_H = 280 - const TARGET_H = 130 - - const heroScale = TARGET_H / heroSprite.fh - const heroFwDisp = Math.round(heroSprite.fw * heroScale) - const heroFhDisp = TARGET_H - - const monScale = monCfg ? TARGET_H / monCfg.fh : 1 - const monFwDisp = monCfg ? Math.round(monCfg.fw * monScale) : TARGET_H - const monFhDisp = TARGET_H - - return ( -
- {/* Background */} - - - {/* Bottom darkening — helps sprites look grounded */} -
- - {/* Vignette */} -
- - {/* CRT scanlines */} -
- - {/* Zone transition overlay */} - {phase === 'zone_transition' && onZoneTransitionDone && ( - - )} - - {phase === 'combat' && enemy && monCfg && ( - <> - {/* PLAYER — bottom-left, flush to ground */} -
- {/* DMG number */} - {dmgNumbers.filter(d => d.side === 'player').map(d => ( -
-{d.val}
- ))} - -
- PLAYER -
- - {/* Hero faces RIGHT (natural direction) */} - - -
- - {/* ENEMY — bottom-right, faces LEFT via scaleX(-1) */} -
- {dmgNumbers.filter(d => d.side === 'enemy').map(d => ( -
-{d.val}
- ))} - - {/* Enemy name + HP bar */} -
-
- {enemy.name} -
-
-
-
-
- - {/* Monster faces LEFT = scaleX(-1) applied inside SpriteAnim via flipX */} - - -
- - )} - - {phase === 'victory' && ( -
-
VICTORY
-
- )} - - {phase === 'dead' && ( -
-
YOU DIED
-
- )} - - {(phase === 'menu' || phase === 'registering') && ( -
-
- {phase === 'registering' ? 'REGISTERING ON-CHAIN...' : 'AWAITING ENTRY INTO THE NULL...'} -
-
- )} -
- ) -} - -// ── Live Raid Feed ──────────────────────────────────────────────────────────── -function LiveRaidFeed() { - const [feed, setFeed] = useState([ - { type: '⚔', addr: '0xa3f1', dmg: 45 }, - { type: '𝕏', addr: '0xb2cc', dmg: 25 }, - { type: '𝕏', addr: '0xc4d9', dmg: 25 }, - { type: '⚔', addr: '0xd8e0', dmg: 22 }, - ]) - useEffect(() => { - try { - const q = query(collection(db, 'raidFeed'), orderBy('createdAt', 'desc'), limit(4)) - const unsub = onSnapshot(q, snap => { - if (!snap.empty) setFeed(snap.docs.map(doc => ({ - type: doc.data().attackType === 'tweet' ? '𝕏' : '⚔', - addr: (doc.data().displayAddress ?? '0x????').slice(0, 6), - dmg: doc.data().damage ?? 0, - }))) - }) - return () => unsub() - } catch { /* not configured */ } - }, []) - return ( -
-
-
- // LIVE RAID FEED -
- {feed.map((e, i) => ( -
- {e.type} {e.addr}.. - -{e.dmg} HP -
- ))} -
- ) -} - -// ── Main Game ───────────────────────────────────────────────────────────────── -export default function GameFullUI() { - const wallet = useWallet() - - const [phase, setPhase] = useState('char_select') - const [heroChar, setHeroChar] = useState('male') - const [zoneIndex, setZoneIndex] = useState(0) - const [killCount, setKillCount] = useState(0) // kills in current zone - const [chainPlayer, setChainPlayer]= useState(null) - const [enemy, setEnemy] = useState(null) - const [narrative, setNarrative] = useState([]) - const [raidData, setRaidData] = useState(null) - const [isThinking, setIsThinking] = useState(false) - const [txPending, setTxPending] = useState(false) - const [txHash, setTxHash] = useState(null) - const [dmgNumbers, setDmgNumbers] = useState>([]) - const [screenShake, setScreenShake]= useState(false) - const [playerAnim, setPlayerAnim] = useState('idle') - const [enemyAnim, setEnemyAnim] = useState('idle') - - const dmgId = useRef(0) - const logRef = useRef(null) - - const zone = ZONES[Math.min(zoneIndex, ZONES.length - 1)] - - useEffect(() => { - if (logRef.current) logRef.current.scrollTop = logRef.current.scrollHeight - }, [narrative]) - - const addLog = useCallback((content: string, role: NarrativeMessage['role'] = 'dm', type: NarrativeMessage['type'] = 'combat') => { - setNarrative(prev => [...prev, { id: Date.now().toString(), role, content, timestamp: Date.now(), type }]) - }, []) - - const showDmg = useCallback((val: number, side: 'enemy' | 'player') => { - const id = ++dmgId.current - setDmgNumbers(prev => [...prev, { id, val, side }]) - setTimeout(() => setDmgNumbers(prev => prev.filter(d => d.id !== id)), 1200) - }, []) - - const triggerShake = useCallback(() => { - setScreenShake(true) - setTimeout(() => setScreenShake(false), 400) - }, []) - - const refreshChainState = useCallback(async () => { - if (!wallet.isConnected) return - const [p, r] = await Promise.all([wallet.readPlayer(), wallet.readRaid()]) - if (p) setChainPlayer(p) - if (r) setRaidData(r) - return p - }, [wallet]) - - useEffect(() => { if (wallet.isConnected) refreshChainState() }, [wallet.isConnected, refreshChainState]) - - const handleCharSelect = (c: CharChoice) => { - setHeroChar(c) - setPhase('menu') - } - - const handleConnect = useCallback(async () => { await wallet.connect() }, [wallet]) - - const handleRegister = useCallback(async () => { - setPhase('registering'); setTxPending(true) - try { - const hash = await wallet.registerPlayer() - setTxHash(hash) - addLog(`TX: ${hash.slice(0, 18)}...\nWaiting for Celo...`, 'system', 'event') - await new Promise(r => setTimeout(r, 4000)) - const p = await wallet.readPlayer() - if (p?.exists) { setChainPlayer(p); addLog('REGISTERED ON-CHAIN.', 'system', 'event'); setPhase('menu') } - } catch (e: unknown) { addLog(`Error: ${(e as Error).message}`, 'system'); setPhase('menu') } - finally { setTxPending(false) } - }, [wallet, addLog]) - - const startEncounter = useCallback(() => { - const currentZone = ZONES[Math.min(zoneIndex, ZONES.length - 1)] - // Pick enemy from zone's enemy pool - const enemyPool = ENEMIES.filter(e => currentZone.enemies.includes(e.id)) - const pool = enemyPool.length > 0 ? enemyPool : ENEMIES - const e = pool[Math.floor(Math.random() * pool.length)] - setEnemy({ ...e, hp: e.maxHp }) - setPhase('combat'); setNarrative([]) - setPlayerAnim('idle'); setEnemyAnim('idle') - addLog(`ENCOUNTER: ${e.name} — ${e.class}\n\n${e.description}\n\nSign your action to attack.`, 'dm', 'combat') - }, [zoneIndex, addLog]) - - // Zone transition: advance to next zone - const advanceZone = useCallback(() => { - setPhase('zone_transition') - }, []) - - const handleZoneTransitionDone = useCallback(() => { - const next = Math.min(zoneIndex + 1, ZONES.length - 1) - setZoneIndex(next) - setKillCount(0) - setEnemy(null) - setNarrative([]) - addLog(`ZONE ${next + 1}: ${ZONES[next].id.toUpperCase()}\nA new threat emerges from the null...`, 'system', 'event') - setPhase('menu') - }, [zoneIndex, addLog]) - - // ── Execute action — TX first, THEN animate ────────────────────────────── - const executeAction = useCallback(async (action: GameAction) => { - if (!enemy || isThinking || txPending || !wallet.isConnected) return - setTxPending(true) - addLog(`>> ${action.label} — awaiting TX confirmation...`, 'player') - - try { - const dmgDealt = Math.floor(Math.random() * 20) + 10 - const dmgReceived = Math.floor(Math.random() * 18) + 5 - const xpGained = Math.floor(Math.random() * 15) + 5 - const newEnemyHp = Math.max(0, enemy.hp - dmgDealt) - const enemyKilled = newEnemyHp === 0 - - // ── STEP 1: Submit TX, wait for confirmation ────────────────────────── - let txHash_: string - if ((NULLSTATE_ADDRESS as string) === '0x0000000000000000000000000000000000000000') { - // Demo mode: simulate TX delay - await new Promise(r => setTimeout(r, 1200)) - txHash_ = '0xdemo_' + Date.now().toString(16) - addLog('⚠️ CONTRACT NOT DEPLOYED — demo mode.', 'system') - } else { - txHash_ = await wallet.executeAction({ - actionType: action.effect === 'attack' ? 1 : action.effect === 'defend' ? 2 : action.effect === 'inspect' ? 3 : action.effect === 'flee' ? 4 : 5, - damageDealt: dmgDealt, damageReceived: dmgReceived, xpGained, enemyKilled, - }) - setTxHash(txHash_) - addLog(`TX CONFIRMED: ${txHash_.slice(0, 20)}...`, 'system', 'event') - } - - setTxPending(false) - - // ── STEP 2: Play attack animation AFTER TX confirmed ────────────────── - setPlayerAnim('walk') - await new Promise(r => setTimeout(r, 150)) - setPlayerAnim('attack') - await new Promise(r => setTimeout(r, 300)) - setEnemyAnim('hit') - triggerShake() - showDmg(dmgDealt, 'enemy') - await new Promise(r => setTimeout(r, 500)) - setEnemyAnim(enemyKilled ? 'death' : 'idle') - setPlayerAnim('idle') - - if (!enemyKilled && dmgReceived > 0) { - await new Promise(r => setTimeout(r, 300)) - setEnemyAnim('attack') - setPlayerAnim('hit') - showDmg(dmgReceived, 'player') - await new Promise(r => setTimeout(r, 600)) - setEnemyAnim('idle') - setPlayerAnim('idle') - } - - // ── STEP 3: AI narration ────────────────────────────────────────────── - setIsThinking(true) - try { - const res = await fetch('/api/groq', { - method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ action, player: chainPlayer ?? INITIAL_PLAYER, enemy, context: `Turn ${narrative.length}` }), - }) - const result = await res.json() - let msg = result.narrative ?? `You struck for ${dmgDealt} damage.` - if (result.specialEvent === 'critical_hit') msg = '⚡ CRITICAL HIT! ' + msg - addLog(msg, 'dm', 'combat') - } catch { - addLog(`You dealt ${dmgDealt} damage.`, 'dm', 'combat') - } - - setEnemy(prev => prev ? { ...prev, hp: newEnemyHp } : null) - - // ── STEP 4: Outcome ─────────────────────────────────────────────────── - if (enemyKilled) { - await new Promise(r => setTimeout(r, 600)) - addLog(`VICTORY — ${enemy.name} defeated.\n+${xpGained} XP earned.`, 'system', 'reward') - setPhase('victory') - const newKills = killCount + 1 - setKillCount(newKills) - await refreshChainState() - } else { - const newPlayerHp = Math.max(0, (chainPlayer?.hp ?? INITIAL_PLAYER.hp) - dmgReceived) - if (newPlayerHp <= 0) { - await new Promise(r => setTimeout(r, 600)) - addLog('YOU HAVE FALLEN.\nYour progress is lost.', 'system', 'death') - setPhase('dead') - await refreshChainState() - } - } - } catch (e: unknown) { - addLog(`Action failed: ${(e as Error).message}`, 'system') - setPlayerAnim('idle'); setEnemyAnim('idle') - } finally { - setIsThinking(false); setTxPending(false) - } - }, [enemy, isThinking, txPending, wallet, addLog, chainPlayer, narrative.length, showDmg, refreshChainState, triggerShake, killCount]) - - const handleRespawn = useCallback(async () => { - setTxPending(true) - try { - const hash = await wallet.respawnPlayer() - setTxHash(hash) - await new Promise(r => setTimeout(r, 4000)) - const p = await wallet.readPlayer() - if (p) setChainPlayer(p) - addLog('RESPAWNED. The null forgets nothing.', 'system', 'event') - setZoneIndex(0); setKillCount(0) - setPhase('menu'); setEnemy(null); setNarrative([]) - } catch (e: unknown) { addLog(`Respawn failed: ${(e as Error).message}`, 'system') } - finally { setTxPending(false) } - }, [wallet, addLog]) - - const handleTweetRaid = useCallback(() => { - const text = encodeURIComponent(`⚔️ Attacking THE 51% Raid Boss on @NullStateRPG!\nnullstate.xyz #CeloRPG`) - window.open(`https://twitter.com/intent/tweet?text=${text}`, '_blank') - }, []) - - // Derived - const displayHp = chainPlayer?.hp ?? INITIAL_PLAYER.hp - const displayMaxHp = chainPlayer?.maxHp ?? INITIAL_PLAYER.maxHp - const displayXp = chainPlayer?.xp ?? INITIAL_PLAYER.xp - const displayLevel = chainPlayer?.level ?? INITIAL_PLAYER.level - const displayKills = chainPlayer?.kills ?? INITIAL_PLAYER.kills - const hpPct = Math.round((displayHp / displayMaxHp) * 100) - const xpPct = Math.min(100, Math.round(((displayXp % 500) / 500) * 100)) - const raidHp = raidData ? parseInt(raidData.currentHp, 16) : 6488 - const raidMaxHp = raidData ? parseInt(raidData.maxHp, 16) : 10000 - const raidPct = Math.round((raidHp / raidMaxHp) * 100) - - return ( - <> - - -
- - {/* Char select overlay */} - {phase === 'char_select' && } - - {/* TOP BAR */} -
- ← NULL_STATE -
// ZONE {zoneIndex + 1}: {zone.id.toUpperCase()}
-
- {wallet.isConnected - ?
-
- {wallet.address?.slice(0, 6)}...{wallet.address?.slice(-4)} -
- : !wallet.isMiniPay && ( - - ) - } -
-
- - {/* MAIN */} -
- - {/* PLAYER HUD */} -
-
-
- HP - {displayHp}/{displayMaxHp} -
-
-
50 ? '#00ff88' : hpPct > 25 ? '#ffaa00' : '#ff2244', boxShadow: `0 0 6px ${hpPct > 50 ? '#00ff88' : hpPct > 25 ? '#ffaa00' : '#ff2244'}` }} /> -
-
- XP - LVL {displayLevel} · {displayXp} -
-
-
-
-
-
-
KILLS {displayKills}
- {wallet.isConnected &&
BAL {wallet.celoBalance}
} -
-
- - {/* BATTLE ARENA */} - - - {/* NARRATIVE LOG */} -
-
-
-
-
- AI_DM :: GROQ_70B - {isThinking && GENERATING...} - {txPending && TX PENDING...} -
-
- {narrative.length === 0 && ( - - {!wallet.isConnected ? '> Connect wallet...' : !chainPlayer?.exists ? '> Register on-chain...' : '> Enter the Null to begin.'} - - - )} - {narrative.map(msg => ( -
- {msg.role === 'player' && >> } - {msg.role === 'system' && // } - {msg.content.split('\n').map((l, i) => {l}
)} -
- ))} -
-
- - {txHash && ( - - )} - - {/* ACTION BUTTONS */} -
- - {!wallet.isConnected && ( - - )} - - {wallet.isConnected && !chainPlayer?.exists && phase !== 'registering' && ( - - )} - - {wallet.isConnected && chainPlayer?.exists && phase === 'menu' && ( - - )} - - {phase === 'combat' && COMBAT_ACTIONS.map((action, i) => ( - - ))} - - {phase === 'victory' && ( -
- - {zoneIndex < ZONES.length - 1 && ( - - )} -
- )} - - {phase === 'dead' && ( - - )} - - {txPending && ( -
- ⏳ AWAITING TX CONFIRMATION ON CELO... -
- )} - - {(phase === 'combat' || phase === 'victory') && ( - - )} - - {/* Raid HP */} -
-
- ⚔ RAID BOSS: THE 51% - {raidHp.toLocaleString()} HP -
-
-
-
-
- - -
-
-
- - ) -} diff --git a/_archive/game/README.txt b/_archive/game/README.txt deleted file mode 100644 index d71c432..0000000 --- a/_archive/game/README.txt +++ /dev/null @@ -1 +0,0 @@ -Archived old turn-based game (GameFullUI). Not compiled (outside app/components import graph). diff --git a/app/robots.ts b/app/robots.ts new file mode 100644 index 0000000..8b209a4 --- /dev/null +++ b/app/robots.ts @@ -0,0 +1,13 @@ +import type { MetadataRoute } from 'next' + +const siteUrl = 'https://nullstate-ten.vercel.app' + +export default function robots(): MetadataRoute.Robots { + return { + rules: { + userAgent: '*', + allow: '/', + }, + sitemap: `${siteUrl}/sitemap.xml`, + } +} diff --git a/app/sitemap.ts b/app/sitemap.ts new file mode 100644 index 0000000..489dbbc --- /dev/null +++ b/app/sitemap.ts @@ -0,0 +1,12 @@ +import type { MetadataRoute } from 'next' + +const siteUrl = 'https://nullstate-ten.vercel.app' + +const routes = ['/', '/docs', '/game', '/terms', '/privacy'] + +export default function sitemap(): MetadataRoute.Sitemap { + return routes.map((route) => ({ + url: `${siteUrl}${route}`, + lastModified: new Date(), + })) +}