advisor game

This commit is contained in:
Saxon Fletcher committed 2025-10-13 14:46:12 +10:00
1 parent 6bb26fdd51
commit 19aa088c9f
15 files changed
+1741 -24

No files matched your search

@@ -0,0 +1,40 @@
import { useEffect, useState } from 'react'
import { Card, CardContent } from 'ui'
import { AdvisorGame } from './components/AdvisorGame'
import { ResourceCounter } from './components/ResourceCounter'
import { useAdvisorGameStore } from './hooks/useAdvisorGameStore'
interface AdvisorEmptyProps {
availableResources?: number
}
export const AdvisorEmpty = ({ availableResources = 20 }: AdvisorEmptyProps) => {
const [isGameActive, setIsGameActive] = useState(false)
const setResources = useAdvisorGameStore((state) => state.setResources)
useEffect(() => {
setResources(availableResources)
}, [availableResources, setResources])
const handleEnterGame = () => {
setIsGameActive(true)
}
return (
<Card className="bg-transparent">
<CardContent className="relative p-0 h-[256px] overflow-hidden flex flex-col items-center justify-center">
{!isGameActive && <ResourceCounter onClick={handleEnterGame} />}
{isGameActive ? (
<AdvisorGame availableResources={availableResources} />
) : (
<div className="text-center">
<div className="heading-default mb-1">You&apos;re all caught up</div>
<p className="text-sm text-foreground-light">
Advisor found no pending security or performance issues
</p>
</div>
)}
</CardContent>
</Card>
)
}
@@ -29,6 +29,7 @@ import {
} from 'ui'
import { Row } from 'ui-patterns'
import ShimmeringLoader from 'ui-patterns/ShimmeringLoader'
import { AdvisorEmpty } from './AdvisorEmpty'
export const AdvisorSection = () => {
const { ref: projectRef } = useParams()
@@ -199,14 +200,7 @@ export const AdvisorSection = () => {
</Sheet>
</>
) : (
<Card className="bg-transparent">
<CardContent className="flex flex-col items-center justify-center gap-2 p-16">
<Shield size={20} strokeWidth={1.5} className="text-foreground-muted" />
<p className="text-sm text-foreground-light text-center">
No security or performance errors found
</p>
</CardContent>
</Card>
<AdvisorEmpty />
)}
</div>
)
@@ -0,0 +1,531 @@
// Reference: advisor-game-example/src/App.js
import { useEffect, useMemo, useRef } from 'react'
import { Physics } from '@react-three/cannon'
import { Sky } from '@react-three/drei'
import { Canvas } from '@react-three/fiber'
import { nanoid } from 'nanoid'
import { createClient, type RealtimeChannel, type SupabaseClient } from '@supabase/supabase-js'
import { Ground } from './Ground'
import { Cubes } from './Cubes'
import { Camera } from './Camera'
import { ResourceCounter } from './ResourceCounter'
import { useAdvisorGameStore } from '../hooks/useAdvisorGameStore'
import { useParams } from 'common'
interface AdvisorGameProps {
availableResources: number
}
export const AdvisorGame = ({ availableResources }: AdvisorGameProps) => {
const setResources = useAdvisorGameStore((state) => state.setResources)
const setCubes = useAdvisorGameStore((state) => state.setCubes)
const setAllCubes = useAdvisorGameStore((state) => state.setAllCubes)
const getCubes = useAdvisorGameStore((state) => state.getCubes)
const setCurrentUserId = useAdvisorGameStore((state) => state.setCurrentUserId)
const setCurrentProjectRef = useAdvisorGameStore((state) => state.setCurrentProjectRef)
const setActionHandlers = useAdvisorGameStore((state) => state.setActionHandlers)
const { ref: projectRef } = useParams()
const supabaseRef = useRef<SupabaseClient | null>(null)
const channelRef = useRef<RealtimeChannel | null>(null)
const gameInitialisedRef = useRef(false)
const playerInitialisedRef = useRef(false)
const supabaseConfig = useMemo(() => {
const url = process.env.NEXT_PUBLIC_ADVISOR_GAME_URL
const key = process.env.NEXT_PUBLIC_ADVISOR_GAME_KEY
if (!url || !key) {
console.warn(
'Supabase environment variables are not set. Advisor game multiplayer features are disabled.'
)
return null
}
return { url, key }
}, [])
useEffect(() => {
setResources(availableResources)
}, [availableResources, setResources])
useEffect(() => {
if (!projectRef || !supabaseConfig) {
return
}
const supabase = createClient(supabaseConfig.url, supabaseConfig.key, {
auth: {
persistSession: true,
autoRefreshToken: true,
detectSessionInUrl: false,
},
})
supabaseRef.current = supabase
let isMounted = true
type AdvisorBlockPayload = {
cube_key?: string
id?: string
position?: [number, number, number]
pos_x?: number
pos_y?: number
pos_z?: number
texture?: string | null
created_by?: string | null
}
const mapBlocksToStore = (blocks: AdvisorBlockPayload[], projectRef?: string) =>
blocks.map((block) => {
const cubeKey =
block.cube_key ??
block.id ??
(Array.isArray(block.position) ? block.position.join('-') : nanoid())
const positionArray = Array.isArray(block.position)
? block.position
: [block.pos_x, block.pos_y, block.pos_z]
const [x, y, z] = positionArray ?? [0, 0, 0]
return {
cubeKey,
pos: [Number(x), Number(y), Number(z)] as [number, number, number],
texture: block.texture ?? 'wood',
createdBy: block.created_by ?? null,
projectRef: projectRef,
}
})
const ensureGameInitialized = async () => {
if (gameInitialisedRef.current) {
return true
}
const { data: existingGame, error: fetchError } = await supabase
.from('advisor_games')
.select('resources')
.eq('project_ref', projectRef)
.maybeSingle()
if (fetchError) {
console.error('Failed to fetch advisor game', fetchError)
return false
}
if (existingGame) {
if (isMounted && existingGame.resources !== undefined && existingGame.resources !== null) {
setResources(existingGame.resources)
}
gameInitialisedRef.current = true
return true
}
const defaultResources =
typeof availableResources === 'number' && Number.isFinite(availableResources)
? availableResources
: 10
const { error: insertError } = await supabase
.from('advisor_games')
.insert({ project_ref: projectRef, resources: defaultResources })
if (insertError) {
if (insertError.code === '23505') {
const { data: gameAfterConflict, error: refetchError } = await supabase
.from('advisor_games')
.select('resources')
.eq('project_ref', projectRef)
.maybeSingle()
if (refetchError) {
console.error('Failed to fetch advisor game after conflict', refetchError)
return false
}
if (
isMounted &&
gameAfterConflict?.resources !== undefined &&
gameAfterConflict.resources !== null
) {
setResources(gameAfterConflict.resources)
}
gameInitialisedRef.current = true
return true
}
console.error('Failed to create advisor game', insertError)
return false
}
if (isMounted) {
setResources(defaultResources)
}
gameInitialisedRef.current = true
return true
}
const ensurePlayerInitialized = async (userId: string | null) => {
if (!userId) {
playerInitialisedRef.current = false
return true
}
if (playerInitialisedRef.current) {
return true
}
const { error: upsertError } = await supabase
.from('advisor_players')
.upsert(
{ project_ref: projectRef, user_id: userId },
{ onConflict: 'project_ref,user_id', ignoreDuplicates: false }
)
if (upsertError) {
console.error('Failed to upsert advisor player', upsertError)
return false
}
playerInitialisedRef.current = true
return true
}
const setupActionHandlers = (userId: string | null) => {
const adjustResourcesOnServer = async (delta: number) => {
if (delta === 0) return
const adjustLocal = useAdvisorGameStore.getState().adjustResources
adjustLocal(delta)
const desiredResources = useAdvisorGameStore.getState().getRemainingResources()
const { data: updatedGame, error: resourcesError } = await supabase
.from('advisor_games')
.update({ resources: desiredResources })
.eq('project_ref', projectRef)
.select('resources')
.maybeSingle()
if (resourcesError) {
console.error('Failed to update advisor resources', resourcesError)
adjustLocal(-delta)
return
}
if (!updatedGame || updatedGame.resources === undefined || updatedGame.resources === null) {
console.warn('Advisor game resources update returned no data')
adjustLocal(-delta)
return
}
setResources(updatedGame.resources)
}
setActionHandlers({
placeCube: async (position) => {
const [x, y, z] = position
const state = useAdvisorGameStore.getState()
if (state.getRemainingResources() <= 0) {
return
}
const existingCubes = state.getCubes(projectRef)
const alreadyExists = existingCubes.some(
(cube) => cube.pos[0] === x && cube.pos[1] === y && cube.pos[2] === z
)
if (alreadyExists) {
return
}
const supa = supabaseRef.current
if (!supa) return
const gameReady = await ensureGameInitialized()
if (!gameReady) {
return
}
const playerReady = await ensurePlayerInitialized(userId ?? null)
if (userId && !playerReady) {
return
}
const payload: {
project_ref: string
cube_key: string
pos_x: number
pos_y: number
pos_z: number
texture: string
created_by?: string
} = {
project_ref: projectRef,
cube_key: nanoid(),
pos_x: x,
pos_y: y,
pos_z: z,
texture: 'wood',
}
if (userId) {
payload.created_by = userId
}
const { error } = await supa.from('advisor_blocks').insert(payload)
if (error) {
console.error('Failed to place cube', error)
return
}
const latestState = useAdvisorGameStore.getState()
const newCube = {
cubeKey: payload.cube_key,
pos: [payload.pos_x, payload.pos_y, payload.pos_z] as [number, number, number],
texture: payload.texture,
createdBy: payload.created_by ?? null,
projectRef: projectRef,
}
const latestCubes = latestState.getCubes(projectRef)
const updatedCubes = [...latestCubes, newCube]
setCubes(updatedCubes, projectRef)
// Update all cubes to include the new cube
const allCubes = useAdvisorGameStore.getState().allCubes
const updatedAllCubes = [...allCubes, newCube]
setAllCubes(updatedAllCubes)
await adjustResourcesOnServer(-1)
},
removeCube: async (position) => {
const [x, y, z] = position
const state = useAdvisorGameStore.getState()
const targetCubes = state.getCubes(projectRef)
const target = targetCubes.find(
(cube) => cube.pos[0] === x && cube.pos[1] === y && cube.pos[2] === z
)
if (!target) {
return
}
if (target.createdBy && userId && target.createdBy !== userId) {
return
}
const supa = supabaseRef.current
if (!supa) return
const { error } = await supa
.from('advisor_blocks')
.delete()
.eq('project_ref', projectRef)
.eq('cube_key', target.cubeKey)
if (error) {
console.error('Failed to remove cube', error)
return
}
const latestState = useAdvisorGameStore.getState()
const remainingCubes = latestState.getCubes(projectRef)
const updatedCubes = remainingCubes.filter((cube) => cube.cubeKey !== target.cubeKey)
setCubes(updatedCubes, projectRef)
// Update all cubes to remove the deleted cube
const allCubes = useAdvisorGameStore.getState().allCubes
const updatedAllCubes = allCubes.filter((cube) => cube.cubeKey !== target.cubeKey)
setAllCubes(updatedAllCubes)
await adjustResourcesOnServer(1)
},
})
}
const initialise = async () => {
const {
data: { session },
error: sessionError,
} = await supabase.auth.getSession()
if (sessionError) {
console.error('Failed to get Supabase session', sessionError)
}
let userId = session?.user?.id ?? null
if (!userId) {
const { data, error } = await supabase.auth.signInAnonymously()
if (error) {
console.error('Failed to sign in anonymously', error)
return
}
userId = data.user?.id ?? null
}
if (!isMounted) return
setCurrentUserId(userId)
setCurrentProjectRef(projectRef)
playerInitialisedRef.current = false
const gameReady = await ensureGameInitialized()
if (!isMounted || !gameReady) return
const playerReady = await ensurePlayerInitialized(userId)
if (!isMounted) return
if (userId && !playerReady) {
console.warn('Advisor player initialisation failed - multiplayer actions may be limited')
}
setupActionHandlers(userId)
const { data: blockRows, error: blocksError } = await supabase
.from('advisor_blocks')
.select('cube_key, pos_x, pos_y, pos_z, texture, created_by, project_ref')
if (!isMounted) return
if (blocksError) {
console.error('Failed to fetch advisor blocks', blocksError)
} else if (blockRows) {
// Load all blocks into allCubes
const allBlocks = blockRows.map((block) => {
const cubeKey = block.cube_key ?? nanoid()
const [x, y, z] = [block.pos_x ?? 0, block.pos_y ?? 0, block.pos_z ?? 0]
return {
cubeKey,
pos: [Number(x), Number(y), Number(z)] as [number, number, number],
texture: block.texture ?? 'wood',
createdBy: block.created_by ?? null,
projectRef: block.project_ref,
}
})
setAllCubes(allBlocks)
// Group blocks by project for project-specific operations
const blocksByProject = allBlocks.reduce(
(acc, block) => {
const projectRef = block.projectRef || 'unknown'
if (!acc[projectRef]) {
acc[projectRef] = []
}
acc[projectRef].push(block)
return acc
},
{} as Record<string, typeof allBlocks>
)
// Set cubes for each project
Object.entries(blocksByProject).forEach(([projectRef, blocks]) => {
setCubes(blocks, projectRef)
})
}
const channel = supabase
.channel(`advisor_game`)
.on('broadcast', { event: 'state_update' }, (event) => {
console.log('state_update', event.payload)
const payload = event.payload as {
project_ref?: string
resources?: number
remaining_resources?: number
blocks?: any[]
}
// Only update resources for the current project
if (payload.project_ref === projectRef) {
const nextResources = payload.resources ?? payload.remaining_resources ?? null
if (nextResources !== null && nextResources !== undefined) {
setResources(nextResources)
}
}
// Always update blocks for all projects
if (Array.isArray(payload.blocks)) {
const updatedBlocks = payload.blocks.map((block) => {
const cubeKey = block.cube_key ?? block.id ?? nanoid()
const positionArray = Array.isArray(block.position)
? block.position
: [block.pos_x, block.pos_y, block.pos_z]
const [x, y, z] = positionArray ?? [0, 0, 0]
return {
cubeKey,
pos: [Number(x), Number(y), Number(z)] as [number, number, number],
texture: block.texture ?? 'wood',
createdBy: block.created_by ?? null,
projectRef: payload.project_ref,
}
})
// Update only the blocks for the specific project that sent the update
const currentAllCubes = useAdvisorGameStore.getState().allCubes
const otherProjectBlocks = currentAllCubes.filter(
(cube) => cube.projectRef !== payload.project_ref
)
const updatedAllCubes = [...otherProjectBlocks, ...updatedBlocks]
setAllCubes(updatedAllCubes)
// Update project-specific cubes for the updated project
setCubes(updatedBlocks, payload.project_ref || 'unknown')
}
})
.subscribe()
channelRef.current = channel
}
void initialise()
return () => {
isMounted = false
setActionHandlers(null)
setCurrentUserId(null)
setCurrentProjectRef(null)
const channel = channelRef.current
if (channel && supabaseRef.current) {
supabaseRef.current.removeChannel(channel)
}
channelRef.current = null
supabaseRef.current = null
gameInitialisedRef.current = false
playerInitialisedRef.current = false
}
}, [
availableResources,
projectRef,
setActionHandlers,
setCurrentUserId,
setCurrentProjectRef,
setCubes,
setAllCubes,
setResources,
supabaseConfig,
])
return (
<div className="relative w-full h-full">
<Canvas shadows camera={{ position: [10, 10, 10], fov: 60 }}>
<color attach="background" args={['#2a2a2a']} />
<ambientLight intensity={0.3} />
<directionalLight
position={[10, 20, 10]}
intensity={1}
castShadow
shadow-mapSize-width={2048}
shadow-mapSize-height={2048}
shadow-camera-far={50}
shadow-camera-left={-20}
shadow-camera-right={20}
shadow-camera-top={20}
shadow-camera-bottom={-20}
/>
<pointLight position={[0, 10, 0]} intensity={0.5} color="#00d9ff" />
<Camera />
<Physics gravity={[0, 0, 0]}>
<Ground />
{projectRef && <Cubes projectRef={projectRef} />}
</Physics>
</Canvas>
<ResourceCounter />
</div>
)
}
@@ -0,0 +1,24 @@
// Simplified camera with orbital controls instead of FPV
// Reference: advisor-game-example uses PointerLockControls, we use OrbitControls for easier interaction
import { OrbitControls } from '@react-three/drei'
import { useAdvisorGameStore } from '../hooks/useAdvisorGameStore'
export const Camera = () => {
const markCameraInteraction = useAdvisorGameStore((state) => state.markCameraInteraction)
return (
<OrbitControls
makeDefault
minDistance={5}
maxDistance={25}
minPolarAngle={0}
maxPolarAngle={Math.PI / 2.1}
enablePan={true}
enableZoom={true}
enableRotate={true}
panSpeed={0.5}
onChange={markCameraInteraction}
// OrbitControls natively supports Shift+Left Click to pan
/>
)
}
@@ -0,0 +1,128 @@
// Reference: advisor-game-example/src/components/Cube.js
import { useBox } from '@react-three/cannon'
import { useState } from 'react'
import { useAdvisorGameStore } from '../hooks/useAdvisorGameStore'
import * as THREE from 'three'
import type { Mesh } from 'three'
interface CubeProps {
cubeKey: string
position: [number, number, number]
texture: string
createdBy?: string | null
projectRef?: string
isCurrentProject?: boolean
}
export const Cube = ({ position, createdBy, projectRef, isCurrentProject }: CubeProps) => {
const [hoveredFace, setHoveredFace] = useState<number | null>(null)
const [ref] = useBox(() => ({
type: 'Static',
position,
}))
const placeCube = useAdvisorGameStore((state) => state.placeCube)
const removeCube = useAdvisorGameStore((state) => state.removeCube)
const getRemainingResources = useAdvisorGameStore((state) => state.getRemainingResources)
const currentUserId = useAdvisorGameStore((state) => state.currentUserId)
const shouldBlockPlacement = useAdvisorGameStore((state) => state.shouldBlockPlacement)
// Use gray color for blocks from other projects, green for current project
const baseColor = isCurrentProject ? 'rgb(62, 207, 142)' : 'rgb(128, 128, 128)'
const edgeColor = isCurrentProject ? 'rgb(62, 207, 142)' : 'rgb(128, 128, 128)'
return (
<group>
<mesh
onPointerMove={(e) => {
e.stopPropagation()
const faceIndex =
e.faceIndex !== null && e.faceIndex !== undefined ? Math.floor(e.faceIndex / 2) : null
setHoveredFace(faceIndex)
}}
onPointerOut={(e) => {
e.stopPropagation()
setHoveredFace(null)
}}
onClick={(e) => {
const blockPlacement = shouldBlockPlacement()
if (blockPlacement && !e.altKey) {
return
}
e.stopPropagation()
const clickedFace =
e.faceIndex !== null && e.faceIndex !== undefined ? Math.floor(e.faceIndex / 2) : 0
const { x, y, z } = (ref.current as Mesh).position
// Alt+Click to remove
if (e.altKey) {
if (!removeCube) {
return
}
// Only allow deletion of cubes from the current project
if (!isCurrentProject) {
return
}
if (createdBy && currentUserId && createdBy !== currentUserId) {
return
}
void removeCube([x, y, z])
return
}
if (blockPlacement) {
return
}
// Check if we have resources before adding
if (getRemainingResources() <= 0) {
return
}
// Add cube adjacent to clicked face
if (!placeCube) {
return
}
if (clickedFace === 0) {
void placeCube([x + 1, y, z])
} else if (clickedFace === 1) {
void placeCube([x - 1, y, z])
} else if (clickedFace === 2) {
void placeCube([x, y + 1, z])
} else if (clickedFace === 3) {
void placeCube([x, y - 1, z])
} else if (clickedFace === 4) {
void placeCube([x, y, z + 1])
} else if (clickedFace === 5) {
void placeCube([x, y, z - 1])
}
}}
ref={ref as any}
castShadow
receiveShadow
>
<boxGeometry />
{/* Create 6 materials, one for each face */}
{[0, 1, 2, 3, 4, 5].map((faceIndex) => (
<meshStandardMaterial
key={faceIndex}
attach={`material-${faceIndex}`}
color={hoveredFace === faceIndex ? baseColor : baseColor}
emissive={baseColor}
emissiveIntensity={hoveredFace === faceIndex ? 0.4 : 0.1}
metalness={0.8}
roughness={0.2}
/>
))}
</mesh>
{/* Tron-style edge glow */}
<lineSegments position={position}>
<edgesGeometry args={[new THREE.BoxGeometry(1, 1, 1)]} />
<lineBasicMaterial
color={edgeColor}
transparent
opacity={hoveredFace !== null ? 0.8 : 0.5}
/>
</lineSegments>
</group>
)
}
@@ -0,0 +1,31 @@
// Reference: advisor-game-example/src/components/Cubes.js
import { useMemo } from 'react'
import { useAdvisorGameStore } from '../hooks/useAdvisorGameStore'
import { Cube } from './Cube'
interface CubesProps {
projectRef: string
}
export const Cubes = ({ projectRef }: CubesProps) => {
const allCubes = useAdvisorGameStore((state) => state.allCubes)
const currentProjectRef = useAdvisorGameStore((state) => state.currentProjectRef)
const cubes = useMemo(() => allCubes, [allCubes])
return (
<>
{cubes.map(({ cubeKey, pos, texture, createdBy, projectRef: cubeProjectRef }) => (
<Cube
key={cubeKey}
cubeKey={cubeKey}
position={pos}
texture={texture}
createdBy={createdBy}
projectRef={cubeProjectRef}
isCurrentProject={cubeProjectRef === currentProjectRef}
/>
))}
</>
)
}
@@ -0,0 +1,73 @@
// Reference: advisor-game-example/src/components/Ground.js
import { usePlane } from '@react-three/cannon'
import { useAdvisorGameStore } from '../hooks/useAdvisorGameStore'
import { Grid } from '@react-three/drei'
import { useState } from 'react'
export const Ground = () => {
const [ref] = usePlane(() => ({
rotation: [-Math.PI / 2, 0, 0],
position: [0, -0.5, 0],
}))
const [hoverPos, setHoverPos] = useState<[number, number] | null>(null)
const placeCube = useAdvisorGameStore((state) => state.placeCube)
const getRemainingResources = useAdvisorGameStore((state) => state.getRemainingResources)
const shouldBlockPlacement = useAdvisorGameStore((state) => state.shouldBlockPlacement)
return (
<>
<mesh
onPointerMove={(e) => {
e.stopPropagation()
// Calculate grid cell position for hover preview
const [x, y, z] = Object.values(e.point).map((val) => Math.floor(val) + 0.5)
setHoverPos([x, z])
}}
onPointerOut={(e) => {
e.stopPropagation()
setHoverPos(null)
}}
onClick={(e) => {
if (shouldBlockPlacement()) {
return
}
e.stopPropagation()
if (getRemainingResources() <= 0) {
return // No resources left
}
if (!placeCube) {
return
}
// Snap to grid cell centers (offset by 0.5 from grid lines)
const [x, y, z] = Object.values(e.point).map((val) => Math.floor(val) + 0.5)
void placeCube([x, 0, z])
}}
ref={ref as any}
receiveShadow
>
<planeGeometry args={[100, 100]} />
<meshStandardMaterial color="#1a1a1a" />
</mesh>
{/* Hover highlight for placement preview */}
{hoverPos && getRemainingResources() > 0 && (
<mesh position={[hoverPos[0], -0.48, hoverPos[1]]} rotation={[-Math.PI / 2, 0, 0]}>
<planeGeometry args={[0.9, 0.9]} />
<meshBasicMaterial color="rgb(62, 207, 142)" transparent opacity={0.3} />
</mesh>
)}
<Grid
position={[0, -0.49, 0]}
args={[100, 100]}
cellSize={1}
cellThickness={1}
cellColor="rgb(62, 207, 142)"
sectionSize={5}
sectionThickness={1.5}
sectionColor="rgb(62, 207, 142)"
fadeDistance={50}
fadeStrength={1}
infiniteGrid={false}
/>
</>
)
}
@@ -0,0 +1,25 @@
import { KeyboardEvent } from 'react'
import { Cuboid } from 'lucide-react'
import { useAdvisorGameStore } from '../hooks/useAdvisorGameStore'
import { Button } from 'ui'
interface ResourceCounterProps {
onClick?: () => void
className?: string
}
export const ResourceCounter = ({ onClick, className }: ResourceCounterProps) => {
const remaining = useAdvisorGameStore((state) => state.getRemainingResources())
return (
<Button
type="text"
tabIndex={onClick ? 0 : undefined}
onClick={onClick}
className={'absolute top-2 left-2 z-10 justify-start text-left'}
icon={<Cuboid strokeWidth={1} size={16} className="text-foreground-lighter" />}
>
{!onClick && <span>{remaining}</span>}
</Button>
)
}
@@ -0,0 +1,113 @@
// Reference: advisor-game-example/src/hooks/useStore.js
import { create } from 'zustand'
interface Cube {
cubeKey: string
pos: [number, number, number]
texture: string
createdBy?: string | null
projectRef?: string
}
interface ActionHandlers {
placeCube?: (position: [number, number, number]) => Promise<void>
removeCube?: (position: [number, number, number]) => Promise<void>
}
interface GameStore {
resources: number
cubesByProject: Record<string, Cube[]>
allCubes: Cube[]
setResources: (value: number) => void
adjustResources: (delta: number) => void
setCubes: (cubes: Cube[], projectRef: string) => void
setAllCubes: (cubes: Cube[]) => void
getCubes: (projectRef: string) => Cube[]
setCurrentUserId: (userId: string | null) => void
setActionHandlers: (handlers: ActionHandlers | null) => void
getRemainingResources: () => number
resetWorld: (projectRef?: string) => void
placeCube?: (position: [number, number, number]) => Promise<void>
removeCube?: (position: [number, number, number]) => Promise<void>
currentUserId?: string | null
currentProjectRef?: string | null
setCurrentProjectRef: (projectRef: string | null) => void
lastCameraInteractionTime: number | null
markCameraInteraction: () => void
shouldBlockPlacement: () => boolean
}
export const useAdvisorGameStore = create<GameStore>((set, get) => ({
resources: 10,
cubesByProject: {},
allCubes: [],
setResources: (value) => {
set(() => ({
resources: value,
}))
},
adjustResources: (delta) => {
set((state) => {
const next = Math.max(state.resources + delta, 0)
return { resources: next }
})
},
setCubes: (cubes, projectRef) => {
set((state) => ({
cubesByProject: {
...state.cubesByProject,
[projectRef]: cubes,
},
}))
},
setAllCubes: (cubes) => {
set(() => ({ allCubes: cubes }))
},
getCubes: (projectRef) => {
const state = get()
return state.cubesByProject[projectRef] || []
},
setCurrentUserId: (userId) => {
set(() => ({ currentUserId: userId }))
},
setCurrentProjectRef: (projectRef) => {
set(() => ({ currentProjectRef: projectRef }))
},
setActionHandlers: (handlers) => {
set(() => ({
placeCube: handlers?.placeCube,
removeCube: handlers?.removeCube,
}))
},
getRemainingResources: () => {
const state = get()
return state.resources
},
resetWorld: (projectRef) => {
if (projectRef) {
set((state) => ({
cubesByProject: {
...state.cubesByProject,
[projectRef]: [],
},
}))
} else {
set(() => ({
cubesByProject: {},
allCubes: [],
}))
}
},
lastCameraInteractionTime: null,
markCameraInteraction: () => {
set(() => ({
lastCameraInteractionTime: Date.now(),
}))
},
shouldBlockPlacement: () => {
const { lastCameraInteractionTime } = get()
if (lastCameraInteractionTime === null) {
return false
}
return Date.now() - lastCameraInteractionTime < 200
},
}))
@@ -0,0 +1,53 @@
// Reference: advisor-game-example/src/images/textures.js
import { NearestFilter, TextureLoader, RepeatWrapping, Texture } from 'three'
import woodImg from './assets/wood.png'
import grassImg from './assets/grass.jpg'
import dirtImg from './assets/dirt.jpg'
// Export image URLs for dynamic loading
export const textureUrls = {
wood: woodImg,
grass: grassImg,
dirt: dirtImg,
}
// Lazy load textures to avoid SSR issues
let woodTexture: Texture | null = null
let grassTexture: Texture | null = null
let dirtTexture: Texture | null = null
let groundTexture: Texture | null = null
export const getWoodTexture = () => {
if (!woodTexture) {
woodTexture = new TextureLoader().load(woodImg)
woodTexture.magFilter = NearestFilter
}
return woodTexture
}
export const getGrassTexture = () => {
if (!grassTexture) {
grassTexture = new TextureLoader().load(grassImg)
grassTexture.magFilter = NearestFilter
}
return grassTexture
}
export const getDirtTexture = () => {
if (!dirtTexture) {
dirtTexture = new TextureLoader().load(dirtImg)
dirtTexture.magFilter = NearestFilter
}
return dirtTexture
}
export const getGroundTexture = () => {
if (!groundTexture) {
groundTexture = new TextureLoader().load(grassImg)
groundTexture.magFilter = NearestFilter
groundTexture.wrapS = RepeatWrapping
groundTexture.wrapT = RepeatWrapping
}
return groundTexture
}
@@ -13,7 +13,7 @@ import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import { PROJECT_STATUS } from 'lib/constants'
import { useAppStateSnapshot } from 'state/app-state'
import { cn } from 'ui'
import { AdvisorSection } from './AdvisorSection'
import { AdvisorSection } from './Advisor/AdvisorSection'
import { CustomReportSection } from './CustomReportSection'
import { type GettingStartedState } from './GettingStarted/GettingStarted.types'
import { GettingStartedSection } from './GettingStarted/GettingStartedSection'
+6
View File
@@ -52,6 +52,9 @@
"@radix-ui/react-slider": "^1.1.2",
"@radix-ui/react-slot": "^1.2.0",
"@radix-ui/react-visually-hidden": "^1.1.3",
"@react-three/cannon": "^6.6.0",
"@react-three/drei": "^9.122.0",
"@react-three/fiber": "^8.18.0",
"@sentry/nextjs": "^10.3.0",
"@std/path": "npm:@jsr/std__path@^1.0.8",
"@stripe/react-stripe-js": "^3.7.0",
@@ -98,6 +101,7 @@
"memoize-one": "^5.0.1",
"mime-db": "^1.53.0",
"monaco-editor": "0.52.2",
"nanoid": "^5.1.6",
"next": "catalog:",
"next-themes": "^0.3.0",
"nuqs": "2.7.1",
@@ -136,6 +140,7 @@
"sql-formatter": "^15.0.0",
"sqlstring": "^2.3.2",
"streamdown": "^1.3.0",
"three": "^0.180.0",
"tus-js-client": "^4.1.0",
"ui": "workspace:*",
"ui-patterns": "workspace:*",
@@ -145,6 +150,7 @@
"valtio": "catalog:",
"yup": "^1.4.0",
"zod": "catalog:",
"zustand": "^5.0.8",
"zxcvbn": "^4.4.2"
},
"devDependencies": {
+586 -15
View File
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,103 @@
-- Rename max_resources column to resources so that it represents the mutable
-- count of resources available to an advisor game.
ALTER TABLE public.advisor_games
RENAME COLUMN max_resources TO resources;
-- Recreate the non-negative constraint with an updated name.
ALTER TABLE public.advisor_games
DROP CONSTRAINT IF EXISTS advisor_games_max_resources_check;
ALTER TABLE public.advisor_games
ADD CONSTRAINT advisor_games_resources_check CHECK (resources >= 0);
-- Update broadcast function to emit the new resources column.
CREATE OR REPLACE FUNCTION public.broadcast_advisor_game_state()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO 'public', 'pg_temp'
AS $function$
declare
v_project_ref text := coalesce(NEW.project_ref, OLD.project_ref);
v_resources integer;
v_used integer;
v_blocks jsonb;
v_players jsonb;
begin
if v_project_ref is null then
return null;
end if;
select resources
into v_resources
from public.advisor_games
where project_ref = v_project_ref;
if v_resources is null then
return null;
end if;
select coalesce(count(*), 0)
into v_used
from public.advisor_blocks
where project_ref = v_project_ref;
select coalesce(
jsonb_agg(
jsonb_build_object(
'id', b.id,
'cube_key', b.cube_key,
'position', jsonb_build_array(b.pos_x, b.pos_y, b.pos_z),
'texture', b.texture,
'created_by', b.created_by,
'created_at', b.created_at
)
order by b.created_at
),
'[]'::jsonb
)
into v_blocks
from public.advisor_blocks b
where b.project_ref = v_project_ref;
select coalesce(
jsonb_agg(
jsonb_build_object(
'id', p.id,
'user_id', p.user_id,
'display_name', p.display_name,
'presence', p.presence,
'joined_at', p.joined_at,
'last_seen_at', p.last_seen_at
)
order by p.joined_at
),
'[]'::jsonb
)
into v_players
from public.advisor_players p
where p.project_ref = v_project_ref;
perform realtime.send(
jsonb_build_object(
'project_ref', v_project_ref,
'resources', v_resources,
'used_resources', v_used,
'blocks', v_blocks,
'players', v_players,
'updated_at', now()
),
'state_update',
format('advisor_game:%s', v_project_ref),
false
);
return null;
exception
when undefined_function then
raise notice 'realtime.send function is not available; skipping advisor broadcast';
when others then
raise warning 'advisor broadcast failed for %: %', v_project_ref, sqlerrm;
return null;
end;
$function$;
@@ -0,0 +1,25 @@
DROP POLICY IF EXISTS "project members can select advisor_games"
ON public.advisor_games;
CREATE POLICY "advisor games can be viewed by anyone"
ON public.advisor_games
FOR SELECT
TO public
USING (true);
DROP POLICY IF EXISTS "project members can view advisor_blocks"
ON public.advisor_blocks;
CREATE POLICY "advisor blocks can be viewed by anyone"
ON public.advisor_blocks
FOR SELECT
TO public
USING (true);
-- Permit realtime broadcasts for advisor games to reach all authenticated clients.
ALTER POLICY "project members can receive advisor_game broadcasts"
ON realtime.messages
USING (
(extension = 'broadcast'::text)
AND (split_part(realtime.topic(), ':'::text, 1) = 'advisor_game')
);