diff --git a/gameEditor/src/scripts/editorScripts/AsteroidComponent.ts b/gameEditor/src/scripts/editorScripts/AsteroidComponent.ts new file mode 100644 index 0000000..bf33a39 --- /dev/null +++ b/gameEditor/src/scripts/editorScripts/AsteroidComponent.ts @@ -0,0 +1,30 @@ +/** + * BabylonJS Editor script component for asteroids + * Copy this to your Editor workspace: src/scenes/scripts/AsteroidComponent.ts + * + * Attach to asteroid meshes to expose game properties in Inspector. + */ +import { Mesh } from "@babylonjs/core/Meshes/mesh"; + +import { + visibleAsNumber, + visibleAsString, + visibleAsVector3, +} from "babylonjs-editor-tools"; + +export default class AsteroidComponent extends Mesh { + @visibleAsVector3("Linear Velocity", { step: 0.1 }) + public linearVelocity = { x: 0, y: 0, z: 0 }; + + @visibleAsVector3("Angular Velocity", { step: 0.01 }) + public angularVelocity = { x: 0, y: 0, z: 0 }; + + @visibleAsNumber("Mass", { min: 1, max: 1000, step: 10 }) + public mass: number = 200; + + @visibleAsString("Target ID", { description: "Reference to a TargetComponent node" }) + public targetId: string = ""; + + @visibleAsString("Target Mode", { description: "orbit | moveToward | (empty)" }) + public targetMode: string = ""; +} diff --git a/gameEditor/src/scripts/editorScripts/BaseComponent.ts b/gameEditor/src/scripts/editorScripts/BaseComponent.ts new file mode 100644 index 0000000..f6bc26a --- /dev/null +++ b/gameEditor/src/scripts/editorScripts/BaseComponent.ts @@ -0,0 +1,17 @@ +/** + * BabylonJS Editor script component for the start base + * Copy this to your Editor workspace: src/scenes/scripts/BaseComponent.ts + * + * Attach to a mesh to mark it as the start base (yellow cylinder constraint zone). + */ +import { Mesh } from "@babylonjs/core/Meshes/mesh"; + +import { visibleAsString } from "babylonjs-editor-tools"; + +export default class BaseComponent extends Mesh { + @visibleAsString("Base GLB Path", { description: "Path to base GLB model" }) + public baseGlbPath: string = "base.glb"; + + @visibleAsString("Landing GLB Path", { description: "Path to landing zone GLB" }) + public landingGlbPath: string = ""; +} diff --git a/gameEditor/src/scripts/editorScripts/PlanetComponent.ts b/gameEditor/src/scripts/editorScripts/PlanetComponent.ts new file mode 100644 index 0000000..2a3109f --- /dev/null +++ b/gameEditor/src/scripts/editorScripts/PlanetComponent.ts @@ -0,0 +1,17 @@ +/** + * BabylonJS Editor script component for planets + * Copy this to your Editor workspace: src/scenes/scripts/PlanetComponent.ts + * + * Attach to a mesh to configure planet properties. + */ +import { Mesh } from "@babylonjs/core/Meshes/mesh"; + +import { visibleAsNumber, visibleAsString } from "babylonjs-editor-tools"; + +export default class PlanetComponent extends Mesh { + @visibleAsNumber("Diameter", { min: 10, max: 1000, step: 10 }) + public diameter: number = 100; + + @visibleAsString("Texture Path", { description: "Path to planet texture" }) + public texturePath: string = ""; +} diff --git a/gameEditor/src/scripts/editorScripts/ShipComponent.ts b/gameEditor/src/scripts/editorScripts/ShipComponent.ts new file mode 100644 index 0000000..061df13 --- /dev/null +++ b/gameEditor/src/scripts/editorScripts/ShipComponent.ts @@ -0,0 +1,17 @@ +/** + * BabylonJS Editor script component for player ship spawn + * Copy this to your Editor workspace: src/scenes/scripts/ShipComponent.ts + * + * Attach to a mesh/transform node to mark player spawn point. + */ +import { Mesh } from "@babylonjs/core/Meshes/mesh"; + +import { visibleAsVector3 } from "babylonjs-editor-tools"; + +export default class ShipComponent extends Mesh { + @visibleAsVector3("Start Velocity", { step: 0.1 }) + public linearVelocity = { x: 0, y: 0, z: 0 }; + + @visibleAsVector3("Start Angular Vel", { step: 0.01 }) + public angularVelocity = { x: 0, y: 0, z: 0 }; +} diff --git a/gameEditor/src/scripts/editorScripts/SunComponent.ts b/gameEditor/src/scripts/editorScripts/SunComponent.ts new file mode 100644 index 0000000..895a4f1 --- /dev/null +++ b/gameEditor/src/scripts/editorScripts/SunComponent.ts @@ -0,0 +1,17 @@ +/** + * BabylonJS Editor script component for the sun + * Copy this to your Editor workspace: src/scenes/scripts/SunComponent.ts + * + * Attach to a mesh to mark it as the sun. Position from transform. + */ +import { Mesh } from "@babylonjs/core/Meshes/mesh"; + +import { visibleAsNumber } from "babylonjs-editor-tools"; + +export default class SunComponent extends Mesh { + @visibleAsNumber("Diameter", { min: 10, max: 200, step: 5 }) + public diameter: number = 50; + + @visibleAsNumber("Intensity", { min: 0, max: 5000000, step: 100000 }) + public intensity: number = 1000000; +} diff --git a/gameEditor/src/scripts/editorScripts/TargetComponent.ts b/gameEditor/src/scripts/editorScripts/TargetComponent.ts new file mode 100644 index 0000000..bf60d2f --- /dev/null +++ b/gameEditor/src/scripts/editorScripts/TargetComponent.ts @@ -0,0 +1,15 @@ +/** + * BabylonJS Editor script component for orbit/movement targets + * Copy this to your Editor workspace: src/scenes/scripts/TargetComponent.ts + * + * Attach to a TransformNode to create an invisible target point. + * Asteroids can reference this by targetId to orbit or move toward. + */ +import { TransformNode } from "@babylonjs/core/Meshes/transformNode"; + +import { visibleAsString } from "babylonjs-editor-tools"; + +export default class TargetComponent extends TransformNode { + @visibleAsString("Display Name", { description: "Friendly name for this target" }) + public displayName: string = "Target"; +} diff --git a/public/levels/1.json b/public/levels/1.json index f5e009b..cee0c59 100644 --- a/public/levels/1.json +++ b/public/levels/1.json @@ -124,13 +124,5 @@ -0.972535786522579 ] } - ], - "difficultyConfig": { - "rockCount": 4, - "forceMultiplier": 1, - "rockSizeMin": 3, - "rockSizeMax": 5, - "distanceMin": 100, - "distanceMax": 200 - } -} \ No newline at end of file + ] +} diff --git a/public/levels/asteroid-mania.json b/public/levels/asteroid-mania.json index 0385484..3f349fb 100644 --- a/public/levels/asteroid-mania.json +++ b/public/levels/asteroid-mania.json @@ -257,7 +257,8 @@ -4.8069811132136699, -0.16093262088047533 ] - },{ + }, + { "id": "asteroid-11", "position": [ 300.953057070289603, @@ -276,14 +277,5 @@ -0.16093262088047533 ] } - - ], - "difficultyConfig": { - "rockCount": 10, - "forceMultiplier": 1, - "rockSizeMin": 8, - "rockSizeMax": 20, - "distanceMin": 225, - "distanceMax": 300 - } -} \ No newline at end of file + ] +} diff --git a/public/levels/deep-space-patrol.json b/public/levels/deep-space-patrol.json index 461d577..6afd416 100644 --- a/public/levels/deep-space-patrol.json +++ b/public/levels/deep-space-patrol.json @@ -429,13 +429,5 @@ -0.6354712781926715 ] } - ], - "difficultyConfig": { - "rockCount": 20, - "forceMultiplier": 1.2, - "rockSizeMin": 5, - "rockSizeMax": 40, - "distanceMin": 230, - "distanceMax": 450 - } -} \ No newline at end of file + ] +} diff --git a/public/levels/directory.json b/public/levels/directory.json index 0564e4e..7c216cc 100644 --- a/public/levels/directory.json +++ b/public/levels/directory.json @@ -17,7 +17,10 @@ "Don't run into things, it damages your hull" ], "unlockRequirements": [], - "tags": ["tutorial", "easy"], + "tags": [ + "tutorial", + "easy" + ], "defaultLocked": false }, { @@ -36,7 +39,9 @@ "Some of the asteroids are a little more distant" ], "unlockRequirements": [], - "tags": ["medium"], + "tags": [ + "medium" + ], "defaultLocked": true }, { @@ -54,8 +59,12 @@ "Asteroids are faster and more dangerous", "Plan your route carefully" ], - "unlockRequirements": ["rescue-mission"], - "tags": ["hard"], + "unlockRequirements": [ + "rescue-mission" + ], + "tags": [ + "hard" + ], "defaultLocked": true }, { @@ -73,8 +82,13 @@ "Multiple base trips may be necessary", "Test your skills to the limit" ], - "unlockRequirements": ["deep-space-patrol"], - "tags": ["very-hard", "combat"], + "unlockRequirements": [ + "deep-space-patrol" + ], + "tags": [ + "very-hard", + "combat" + ], "defaultLocked": true }, { @@ -92,8 +106,13 @@ "Only the best pilots succeed", "May the stars guide you" ], - "unlockRequirements": ["enemy-territory"], - "tags": ["very-hard", "endurance"], + "unlockRequirements": [ + "enemy-territory" + ], + "tags": [ + "very-hard", + "endurance" + ], "defaultLocked": true }, { @@ -112,8 +131,13 @@ "Complete this to prove your mastery", "Good luck, Commander" ], - "unlockRequirements": ["the-gauntlet"], - "tags": ["extreme", "final-boss"], + "unlockRequirements": [ + "the-gauntlet" + ], + "tags": [ + "extreme", + "final-boss" + ], "defaultLocked": true } ] diff --git a/public/levels/enemy-territory.json b/public/levels/enemy-territory.json index 829770f..c7724f8 100644 --- a/public/levels/enemy-territory.json +++ b/public/levels/enemy-territory.json @@ -999,13 +999,5 @@ -0.7867959684450998 ] } - ], - "difficultyConfig": { - "rockCount": 50, - "forceMultiplier": 1.3, - "rockSizeMin": 2, - "rockSizeMax": 8, - "distanceMin": 90, - "distanceMax": 280 - } -} \ No newline at end of file + ] +} diff --git a/public/levels/final-challenge.json b/public/levels/final-challenge.json index c772331..bad6a9b 100644 --- a/public/levels/final-challenge.json +++ b/public/levels/final-challenge.json @@ -999,13 +999,5 @@ 0.19715448756571075 ] } - ], - "difficultyConfig": { - "rockCount": 50, - "forceMultiplier": 1.3, - "rockSizeMin": 2, - "rockSizeMax": 8, - "distanceMin": 90, - "distanceMax": 280 - } -} \ No newline at end of file + ] +} diff --git a/public/levels/rescue-mission.json b/public/levels/rescue-mission.json index 3dfa8e6..c3e1de6 100644 --- a/public/levels/rescue-mission.json +++ b/public/levels/rescue-mission.json @@ -239,13 +239,5 @@ -0.16093262088047533 ] } - ], - "difficultyConfig": { - "rockCount": 10, - "forceMultiplier": 1, - "rockSizeMin": 8, - "rockSizeMax": 20, - "distanceMin": 225, - "distanceMax": 300 - } -} \ No newline at end of file + ] +} diff --git a/public/levels/rookie-training.json b/public/levels/rookie-training.json index 77f852c..c0e125d 100644 --- a/public/levels/rookie-training.json +++ b/public/levels/rookie-training.json @@ -145,13 +145,5 @@ ] } ], - "difficultyConfig": { - "rockCount": 5, - "forceMultiplier": 0.8, - "rockSizeMin": 10, - "rockSizeMax": 15, - "distanceMin": 220, - "distanceMax": 250 - }, "useOrbitConstraints": true -} \ No newline at end of file +} diff --git a/public/levels/the-gauntlet.json b/public/levels/the-gauntlet.json index 069d397..b0338c8 100644 --- a/public/levels/the-gauntlet.json +++ b/public/levels/the-gauntlet.json @@ -999,13 +999,5 @@ 0.6469944623807775 ] } - ], - "difficultyConfig": { - "rockCount": 50, - "forceMultiplier": 1.3, - "rockSizeMin": 2, - "rockSizeMax": 8, - "distanceMin": 90, - "distanceMax": 280 - } -} \ No newline at end of file + ] +} diff --git a/src/components/auth/UserProfile.svelte b/src/components/auth/UserProfile.svelte index f9b1113..ebc81ca 100644 --- a/src/components/auth/UserProfile.svelte +++ b/src/components/auth/UserProfile.svelte @@ -1,4 +1,5 @@
-
-

🚀 Space Combat VR

-

- Pilot your spaceship through asteroid fields and complete missions -

+

Space Combat VR

+

Pilot your spaceship through asteroid fields and complete missions

- +
-

Your Mission

-

- Choose your level and prepare for launch -

+
+ + +
- {#if !isReady} -
Loading levels...
- {:else if levels.size === 0} -
-

No Levels Found

-

No levels available. Please check your connection.

-
+ {#if activeTab === 'official'} + {#if !isReady} +
Loading levels...
+ {:else if levels.size === 0} +
+

No Levels Found

+

No levels available. Please check your connection.

+
+ {:else} + {#each LEVEL_ORDER as levelId} + {@const entry = levels.get(levelId)} + {#if entry} + + {/if} + {/each} + {/if} {:else} - {#each LEVEL_ORDER as levelId} - {@const entry = levels.get(levelId)} - {#if entry} - - {/if} - {/each} + {#if !$authStore.isAuthenticated} +
+

Sign In Required

+

Sign in to view your custom levels.

+ +
+ {:else if loadingMyLevels} +
Loading your levels...
+ {:else if myLevels.length === 0} +
+

No Custom Levels

+

You haven't created any levels yet. Use the BabylonJS Editor plugin to create levels.

+
+ {:else} + {#each myLevels as level (level.id)} + + {/each} + {/if} {/if}
@@ -59,12 +114,47 @@
diff --git a/src/components/game/PlayLevel.svelte b/src/components/game/PlayLevel.svelte index 37e4d84..34232f2 100644 --- a/src/components/game/PlayLevel.svelte +++ b/src/components/game/PlayLevel.svelte @@ -4,6 +4,7 @@ import { Main } from '../../main'; import type { LevelConfig } from '../../levels/config/levelConfig'; import { LevelRegistry } from '../../levels/storage/levelRegistry'; + import { CloudLevelService } from '../../services/cloudLevelService'; import { progressionStore } from '../../stores/progression'; import log from '../../core/logger'; import { DefaultScene } from '../../core/defaultScene'; @@ -86,17 +87,23 @@ throw new Error('Main instance not found'); } - // Get full level entry from registry + // Get full level entry from registry first const registry = LevelRegistry.getInstance(); - const levelEntry = registry.getLevelEntry(levelName); + let levelEntry = registry.getLevelEntry(levelName); + + // If not in registry, try fetching by ID (for private/custom levels) + if (!levelEntry) { + log.info('[PlayLevel] Level not in registry, fetching from cloud:', levelName); + levelEntry = await CloudLevelService.getInstance().getLevelById(levelName); + } if (!levelEntry) { throw new Error(`Level "${levelName}" not found`); } - // Check if level is unlocked (deep link protection) - const isDefault = levelEntry.levelType === 'official'; - if (!progressionStore.isLevelUnlocked(levelEntry.name, isDefault)) { + // Check if level is unlocked (skip for private levels) + const isOfficial = levelEntry.levelType === 'official'; + if (isOfficial && !progressionStore.isLevelUnlocked(levelEntry.name, true)) { log.warn('[PlayLevel] Level locked, redirecting to level select'); navigate('/', { replace: true }); return; diff --git a/src/components/layouts/App.svelte b/src/components/layouts/App.svelte index ddb8965..0b5b6c7 100644 --- a/src/components/layouts/App.svelte +++ b/src/components/layouts/App.svelte @@ -16,6 +16,7 @@ import SettingsScreen from '../settings/SettingsScreen.svelte'; import ControlsScreen from '../controls/ControlsScreen.svelte'; import Leaderboard from '../leaderboard/Leaderboard.svelte'; + import ProfilePage from '../profile/ProfilePage.svelte'; // Initialize Auth0 when component mounts onMount(async () => { @@ -56,6 +57,7 @@ +
diff --git a/src/components/profile/NewTokenDisplay.svelte b/src/components/profile/NewTokenDisplay.svelte new file mode 100644 index 0000000..c0a7076 --- /dev/null +++ b/src/components/profile/NewTokenDisplay.svelte @@ -0,0 +1,70 @@ + + +
+
+ Save this token now! + You won't be able to see it again. +
+ +
+ {token} +
+ +
+ + +
+
+ + diff --git a/src/components/profile/ProfilePage.svelte b/src/components/profile/ProfilePage.svelte new file mode 100644 index 0000000..8052f64 --- /dev/null +++ b/src/components/profile/ProfilePage.svelte @@ -0,0 +1,98 @@ + + +
+ ← Back to Game + +

Profile

+ + {#if !$authStore.isAuthenticated} +
+

Please sign in to manage your profile and editor tokens.

+ +
+ {:else} +

Welcome, {$authStore.user?.name || $authStore.user?.email || 'Player'}

+ +
+
+ {#if newToken} + + {/if} + +
+ +
+ + {#key refreshKey} + + {/key} +
+
+ {/if} + + +
+ + diff --git a/src/components/profile/TokenList.svelte b/src/components/profile/TokenList.svelte new file mode 100644 index 0000000..d41c203 --- /dev/null +++ b/src/components/profile/TokenList.svelte @@ -0,0 +1,112 @@ + + +
+ {#if loading} +

Loading tokens...

+ {:else if tokens.length === 0} +

No active tokens. Generate one to use with the editor plugin.

+ {:else} + {#each tokens as token (token.id)} +
+
+ {token.token_prefix}... + Created {formatDate(token.created_at)} + {#if token.last_used_at} + Last used {formatDate(token.last_used_at)} + {/if} +
+ +
+ {/each} + {/if} +
+ + diff --git a/src/environment/asteroids/rockFactory.ts b/src/environment/asteroids/rockFactory.ts index 866db85..2d12f66 100644 --- a/src/environment/asteroids/rockFactory.ts +++ b/src/environment/asteroids/rockFactory.ts @@ -36,6 +36,7 @@ export class Rock { interface RockConfig { position: Vector3; + rotation?: Vector3; scale: number; linearVelocity: Vector3; angularVelocity: Vector3; @@ -170,7 +171,8 @@ export class RockFactory { useOrbitConstraint: boolean = true, hidden: boolean = false, targetPosition?: Vector3, - targetMode?: 'orbit' | 'moveToward' + targetMode?: 'orbit' | 'moveToward', + rotation?: Vector3 ): Rock { if (!this._asteroidMesh) { throw new Error('[RockFactory] Asteroid mesh not loaded. Call initMesh() first.'); @@ -179,6 +181,7 @@ export class RockFactory { const rock = new InstancedMesh("asteroid-" + i, this._asteroidMesh as Mesh); rock.scaling = new Vector3(scale, scale, scale); rock.position = position; + if (rotation) rock.rotation = rotation; rock.name = "asteroid-" + i; rock.id = "asteroid-" + i; rock.metadata = { type: 'asteroid' }; @@ -188,6 +191,7 @@ export class RockFactory { // Store config for deferred physics initialization const config: RockConfig = { position, + rotation, scale, linearVelocity, angularVelocity, diff --git a/src/environment/stations/starBase.ts b/src/environment/stations/starBase.ts index 5638953..db8636b 100644 --- a/src/environment/stations/starBase.ts +++ b/src/environment/stations/starBase.ts @@ -36,7 +36,8 @@ export default class StarBase { public static async addToScene( position?: Vector3Array, baseGlbPath: string = 'base.glb', - hidden: boolean = false + hidden: boolean = false, + rotation?: Vector3Array ): Promise { const importMeshes = await loadAsset(baseGlbPath, "default", { hidden }); @@ -49,9 +50,12 @@ export default class StarBase { baseMesh.metadata.baseGlbPath = baseGlbPath; } - // Apply position - (importMeshes.container.rootNodes[0] as TransformNode).position = - position ? new Vector3(position[0], position[1], position[2]) : new Vector3(0, 0, 0); + // Apply position and rotation to root node + const rootNode = importMeshes.container.rootNodes[0] as TransformNode; + rootNode.position = position ? new Vector3(position[0], position[1], position[2]) : new Vector3(0, 0, 0); + if (rotation) { + rootNode.rotation = new Vector3(rotation[0], rotation[1], rotation[2]); + } this._loadedBase = { baseMesh, landingMesh, container: importMeshes.container }; diff --git a/src/levels/config/levelConfig.ts b/src/levels/config/levelConfig.ts index cc70023..2ec0e41 100644 --- a/src/levels/config/levelConfig.ts +++ b/src/levels/config/levelConfig.ts @@ -75,6 +75,7 @@ export interface ShipConfig { */ interface StartBaseConfig { position?: Vector3Array; // Defaults to [0, 0, 0] if not specified + rotation?: Vector3Array; baseGlbPath?: string; // Path to base GLB model (defaults to 'base.glb') landingGlbPath?: string; // Path to landing zone GLB model (uses same file as base, different mesh name) } @@ -84,6 +85,7 @@ interface StartBaseConfig { */ export interface SunConfig { position: Vector3Array; + rotation?: Vector3Array; diameter: number; intensity?: number; // Light intensity scale?: Vector3Array; // Independent x/y/z scaling @@ -126,6 +128,7 @@ export interface TargetConfig { export interface AsteroidConfig { id: string; position: Vector3Array; + rotation?: Vector3Array; scale: number; // Uniform scale applied to all axes linearVelocity: Vector3Array; angularVelocity?: Vector3Array; @@ -134,18 +137,6 @@ export interface AsteroidConfig { targetMode?: 'orbit' | 'moveToward'; // How asteroid interacts with target } -/** - * Difficulty configuration settings - */ -interface DifficultyConfig { - rockCount: number; - forceMultiplier: number; - rockSizeMin: number; - rockSizeMax: number; - distanceMin: number; - distanceMax: number; -} - /** * Complete level configuration */ @@ -170,9 +161,6 @@ export interface LevelConfig { planets: PlanetConfig[]; asteroids: AsteroidConfig[]; - // Optional: include original difficulty config for reference - difficultyConfig?: DifficultyConfig; - // Physics configuration useOrbitConstraints?: boolean; // Default: true - constrains asteroids to orbit at fixed distance diff --git a/src/levels/config/levelDeserializer.ts b/src/levels/config/levelDeserializer.ts index bc16b10..4a25fb6 100644 --- a/src/levels/config/levelDeserializer.ts +++ b/src/levels/config/levelDeserializer.ts @@ -131,8 +131,9 @@ export class LevelDeserializer { */ private async createStartBaseMesh(hidden: boolean) { const position = this.config.startBase?.position; + const rotation = this.config.startBase?.rotation; const baseGlbPath = this.config.startBase?.baseGlbPath || 'base.glb'; - return await StarBase.addToScene(position, baseGlbPath, hidden); + return await StarBase.addToScene(position, baseGlbPath, hidden, rotation); } /** @@ -161,6 +162,9 @@ export class LevelDeserializer { if (config.scale) { sun.scaling = this.arrayToVector3(config.scale); } + if (config.rotation) { + sun.rotation = this.arrayToVector3(config.rotation); + } return sun; } @@ -217,6 +221,9 @@ export class LevelDeserializer { material.unlit = true; planet.material = material; planet.renderingGroupId = 2; + if (planetConfig.rotation) { + planet.rotation = this.arrayToVector3(planetConfig.rotation); + } planets.push(planet); this._planets.push({ mesh: planet, diameter: planetConfig.diameter }); @@ -249,6 +256,7 @@ export class LevelDeserializer { } // Create mesh only (no physics) + const rotation = asteroidConfig.rotation ? this.arrayToVector3(asteroidConfig.rotation) : undefined; RockFactory.createRockMesh( i, this.arrayToVector3(asteroidConfig.position), @@ -259,7 +267,8 @@ export class LevelDeserializer { useOrbitConstraints, hidden, targetPosition, - asteroidConfig.targetMode + asteroidConfig.targetMode, + rotation ); const mesh = this.scene.getMeshByName(asteroidConfig.id); diff --git a/src/levels/level1.ts b/src/levels/level1.ts index a64de32..54206d4 100644 --- a/src/levels/level1.ts +++ b/src/levels/level1.ts @@ -388,6 +388,9 @@ export class Level1 implements Level { // Get ship config and add ship to scene const shipConfig = this._deserializer.getShipConfig(); await this._ship.addToScene(new Vector3(...shipConfig.position), hidden); + if (shipConfig.rotation) { + this._ship.transformNode.rotation = new Vector3(...shipConfig.rotation); + } // Create XR camera rig const cameraRig = new TransformNode("xrCameraRig", DefaultScene.MainScene); diff --git a/src/services/cloudLevelService.ts b/src/services/cloudLevelService.ts index a73fcb7..6a93ef1 100644 --- a/src/services/cloudLevelService.ts +++ b/src/services/cloudLevelService.ts @@ -215,10 +215,27 @@ export class CloudLevelService { } /** - * Get a level by ID + * Get a level by ID (tries authenticated client first for private levels) */ public async getLevelById(id: string): Promise { - const client = SupabaseService.getInstance().getClient(); + const supabaseService = SupabaseService.getInstance(); + + // Try authenticated client first (needed for private levels) + const authClient = await supabaseService.getAuthenticatedClient(); + if (authClient) { + const { data, error } = await authClient + .from('levels') + .select('*') + .eq('id', id) + .maybeSingle(); + + if (!error && data) { + return rowToEntry(data); + } + } + + // Fall back to public client for public levels + const client = supabaseService.getClient(); if (!client) { log.warn('[CloudLevelService] Supabase not configured'); return null; @@ -228,12 +245,10 @@ export class CloudLevelService { .from('levels') .select('*') .eq('id', id) - .single(); + .maybeSingle(); if (error) { - if (error.code !== 'PGRST116') { // Not found is not an error - log.error('[CloudLevelService] Failed to fetch level:', error); - } + log.error('[CloudLevelService] Failed to fetch level:', error); return null; } @@ -774,7 +789,7 @@ export class CloudLevelService { .select('can_review_levels, can_manage_admins, can_manage_official, can_view_analytics') .eq('user_id', internalUserId) .eq('is_active', true) - .single(); + .maybeSingle(); if (error) { log.warn('[CloudLevelService] Admin query error:', error.message, error.code); diff --git a/supabase/migrations/005_user_tokens.sql b/supabase/migrations/005_user_tokens.sql new file mode 100644 index 0000000..eb8fb83 --- /dev/null +++ b/supabase/migrations/005_user_tokens.sql @@ -0,0 +1,188 @@ +-- =========================================== +-- USER TOKENS MIGRATION +-- API tokens for editor plugin authentication +-- =========================================== + +-- =========================================== +-- USER_TOKENS TABLE +-- =========================================== +CREATE TABLE IF NOT EXISTS user_tokens ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name TEXT NOT NULL DEFAULT 'Editor Token', + token_hash TEXT NOT NULL, -- SHA256 hash of token + token_prefix TEXT NOT NULL, -- First 8 chars for display (e.g., "abc12345...") + created_at TIMESTAMPTZ DEFAULT NOW(), + last_used_at TIMESTAMPTZ, + expires_at TIMESTAMPTZ, -- NULL = never expires + is_revoked BOOLEAN DEFAULT FALSE +); + +CREATE INDEX idx_user_tokens_user_id ON user_tokens(user_id); +CREATE INDEX idx_user_tokens_hash ON user_tokens(token_hash); + +-- =========================================== +-- FUNCTION: Create a new token for current user +-- Returns the raw token (only time it's visible) +-- =========================================== +CREATE OR REPLACE FUNCTION create_user_token(p_name TEXT DEFAULT 'Editor Token') +RETURNS TEXT AS $$ +DECLARE + v_user_id UUID; + v_raw_token TEXT; + v_token_hash TEXT; +BEGIN + -- Get current user's internal ID + v_user_id := auth_user_id(); + IF v_user_id IS NULL THEN + RAISE EXCEPTION 'Not authenticated'; + END IF; + + -- Generate secure random token (32 bytes = 64 hex chars) + v_raw_token := encode(gen_random_bytes(32), 'hex'); + v_token_hash := encode(sha256(v_raw_token::bytea), 'hex'); + + -- Insert token record + INSERT INTO user_tokens (user_id, name, token_hash, token_prefix) + VALUES (v_user_id, p_name, v_token_hash, substring(v_raw_token, 1, 8)); + + -- Return raw token (only time user sees it) + RETURN v_raw_token; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +-- =========================================== +-- FUNCTION: Validate token and return user_id +-- Used by plugin to authenticate requests +-- =========================================== +CREATE OR REPLACE FUNCTION validate_user_token(p_token TEXT) +RETURNS UUID AS $$ +DECLARE + v_token_hash TEXT; + v_user_id UUID; +BEGIN + v_token_hash := encode(sha256(p_token::bytea), 'hex'); + + SELECT user_id INTO v_user_id + FROM user_tokens + WHERE token_hash = v_token_hash + AND is_revoked = FALSE + AND (expires_at IS NULL OR expires_at > NOW()); + + -- Update last_used_at if valid + IF v_user_id IS NOT NULL THEN + UPDATE user_tokens + SET last_used_at = NOW() + WHERE token_hash = v_token_hash; + END IF; + + RETURN v_user_id; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +-- =========================================== +-- FUNCTION: Revoke a token +-- =========================================== +CREATE OR REPLACE FUNCTION revoke_user_token(p_token_id UUID) +RETURNS BOOLEAN AS $$ +DECLARE + v_user_id UUID; +BEGIN + v_user_id := auth_user_id(); + IF v_user_id IS NULL THEN + RETURN FALSE; + END IF; + + UPDATE user_tokens + SET is_revoked = TRUE + WHERE id = p_token_id AND user_id = v_user_id; + + RETURN FOUND; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +-- =========================================== +-- RLS POLICIES +-- =========================================== +ALTER TABLE user_tokens ENABLE ROW LEVEL SECURITY; + +-- Users can only see their own tokens +CREATE POLICY "user_tokens_select_own" ON user_tokens FOR SELECT + USING (user_id = auth_user_id()); + +-- Users can only delete their own tokens +CREATE POLICY "user_tokens_delete_own" ON user_tokens FOR DELETE + USING (user_id = auth_user_id()); + +-- Insert/update via functions only (SECURITY DEFINER) + +-- =========================================== +-- RPC FUNCTIONS FOR PLUGIN ACCESS +-- These bypass RLS using the editor token +-- =========================================== + +-- Get user's levels using editor token +CREATE OR REPLACE FUNCTION get_my_levels_by_token(p_token TEXT) +RETURNS TABLE ( + id UUID, + name TEXT, + description TEXT, + difficulty TEXT, + level_type TEXT, + config JSONB, + updated_at TIMESTAMPTZ +) AS $$ +DECLARE + v_user_id UUID; +BEGIN + v_user_id := validate_user_token(p_token); + IF v_user_id IS NULL THEN + RAISE EXCEPTION 'Invalid or expired token'; + END IF; + + RETURN QUERY + SELECT l.id, l.name, l.description, l.difficulty, l.level_type, l.config, l.updated_at + FROM levels l + WHERE l.user_id = v_user_id + ORDER BY l.updated_at DESC; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +-- Save/update a level using editor token +CREATE OR REPLACE FUNCTION save_level_by_token( + p_token TEXT, + p_name TEXT, + p_difficulty TEXT, + p_config JSONB, + p_level_id UUID DEFAULT NULL +) +RETURNS UUID AS $$ +DECLARE + v_user_id UUID; + v_result_id UUID; +BEGIN + v_user_id := validate_user_token(p_token); + IF v_user_id IS NULL THEN + RAISE EXCEPTION 'Invalid or expired token'; + END IF; + + IF p_level_id IS NOT NULL THEN + -- Update existing level (only if owned by user) + UPDATE levels + SET name = p_name, difficulty = p_difficulty, config = p_config, updated_at = NOW() + WHERE id = p_level_id AND user_id = v_user_id + RETURNING id INTO v_result_id; + + IF v_result_id IS NULL THEN + RAISE EXCEPTION 'Level not found or not owned by user'; + END IF; + ELSE + -- Create new level + INSERT INTO levels (user_id, name, difficulty, config, level_type) + VALUES (v_user_id, p_name, p_difficulty, p_config, 'private') + RETURNING id INTO v_result_id; + END IF; + + RETURN v_result_id; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER;