mirror of
https://github.com/supabase/supabase.git
synced 2026-10-13 05:09:12 +03:00
advisor game
This commit is contained in:
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're all caught up</div>
|
||||
<p className="text-sm text-foreground-light">
|
||||
Advisor found no pending security or performance issues
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
+2
-8
@@ -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'
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Generated
+586
-15
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')
|
||||
);
|
||||
Reference in new issue
Block a user