Add My Levels tab, profile page, and token auth system

- Add My Levels tab to website level selection for viewing private levels
- Add profile page for generating/managing editor plugin tokens
- Create user_tokens table and RPC functions for token-based auth
- Fix cloudLevelService to use maybeSingle() for admin and level queries
- Fix getLevelById to try authenticated client first for private levels
- Add rotation support to asteroids, base, sun, and planets
- Remove deprecated difficultyConfig from level files
- Add editor script components for BabylonJS Editor integration

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Michael Mainguy 2025-12-09 07:06:40 -06:00
parent 496bb50095
commit fe88c2bf47
29 changed files with 824 additions and 156 deletions

View File

@ -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 = "";
}

View File

@ -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 = "";
}

View File

@ -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 = "";
}

View File

@ -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 };
}

View File

@ -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;
}

View File

@ -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";
}

View File

@ -124,13 +124,5 @@
-0.972535786522579 -0.972535786522579
] ]
} }
], ]
"difficultyConfig": { }
"rockCount": 4,
"forceMultiplier": 1,
"rockSizeMin": 3,
"rockSizeMax": 5,
"distanceMin": 100,
"distanceMax": 200
}
}

View File

@ -257,7 +257,8 @@
-4.8069811132136699, -4.8069811132136699,
-0.16093262088047533 -0.16093262088047533
] ]
},{ },
{
"id": "asteroid-11", "id": "asteroid-11",
"position": [ "position": [
300.953057070289603, 300.953057070289603,
@ -276,14 +277,5 @@
-0.16093262088047533 -0.16093262088047533
] ]
} }
]
], }
"difficultyConfig": {
"rockCount": 10,
"forceMultiplier": 1,
"rockSizeMin": 8,
"rockSizeMax": 20,
"distanceMin": 225,
"distanceMax": 300
}
}

View File

@ -429,13 +429,5 @@
-0.6354712781926715 -0.6354712781926715
] ]
} }
], ]
"difficultyConfig": { }
"rockCount": 20,
"forceMultiplier": 1.2,
"rockSizeMin": 5,
"rockSizeMax": 40,
"distanceMin": 230,
"distanceMax": 450
}
}

View File

@ -17,7 +17,10 @@
"Don't run into things, it damages your hull" "Don't run into things, it damages your hull"
], ],
"unlockRequirements": [], "unlockRequirements": [],
"tags": ["tutorial", "easy"], "tags": [
"tutorial",
"easy"
],
"defaultLocked": false "defaultLocked": false
}, },
{ {
@ -36,7 +39,9 @@
"Some of the asteroids are a little more distant" "Some of the asteroids are a little more distant"
], ],
"unlockRequirements": [], "unlockRequirements": [],
"tags": ["medium"], "tags": [
"medium"
],
"defaultLocked": true "defaultLocked": true
}, },
{ {
@ -54,8 +59,12 @@
"Asteroids are faster and more dangerous", "Asteroids are faster and more dangerous",
"Plan your route carefully" "Plan your route carefully"
], ],
"unlockRequirements": ["rescue-mission"], "unlockRequirements": [
"tags": ["hard"], "rescue-mission"
],
"tags": [
"hard"
],
"defaultLocked": true "defaultLocked": true
}, },
{ {
@ -73,8 +82,13 @@
"Multiple base trips may be necessary", "Multiple base trips may be necessary",
"Test your skills to the limit" "Test your skills to the limit"
], ],
"unlockRequirements": ["deep-space-patrol"], "unlockRequirements": [
"tags": ["very-hard", "combat"], "deep-space-patrol"
],
"tags": [
"very-hard",
"combat"
],
"defaultLocked": true "defaultLocked": true
}, },
{ {
@ -92,8 +106,13 @@
"Only the best pilots succeed", "Only the best pilots succeed",
"May the stars guide you" "May the stars guide you"
], ],
"unlockRequirements": ["enemy-territory"], "unlockRequirements": [
"tags": ["very-hard", "endurance"], "enemy-territory"
],
"tags": [
"very-hard",
"endurance"
],
"defaultLocked": true "defaultLocked": true
}, },
{ {
@ -112,8 +131,13 @@
"Complete this to prove your mastery", "Complete this to prove your mastery",
"Good luck, Commander" "Good luck, Commander"
], ],
"unlockRequirements": ["the-gauntlet"], "unlockRequirements": [
"tags": ["extreme", "final-boss"], "the-gauntlet"
],
"tags": [
"extreme",
"final-boss"
],
"defaultLocked": true "defaultLocked": true
} }
] ]

View File

@ -999,13 +999,5 @@
-0.7867959684450998 -0.7867959684450998
] ]
} }
], ]
"difficultyConfig": { }
"rockCount": 50,
"forceMultiplier": 1.3,
"rockSizeMin": 2,
"rockSizeMax": 8,
"distanceMin": 90,
"distanceMax": 280
}
}

View File

@ -999,13 +999,5 @@
0.19715448756571075 0.19715448756571075
] ]
} }
], ]
"difficultyConfig": { }
"rockCount": 50,
"forceMultiplier": 1.3,
"rockSizeMin": 2,
"rockSizeMax": 8,
"distanceMin": 90,
"distanceMax": 280
}
}

View File

@ -239,13 +239,5 @@
-0.16093262088047533 -0.16093262088047533
] ]
} }
], ]
"difficultyConfig": { }
"rockCount": 10,
"forceMultiplier": 1,
"rockSizeMin": 8,
"rockSizeMax": 20,
"distanceMin": 225,
"distanceMax": 300
}
}

View File

@ -145,13 +145,5 @@
] ]
} }
], ],
"difficultyConfig": {
"rockCount": 5,
"forceMultiplier": 0.8,
"rockSizeMin": 10,
"rockSizeMax": 15,
"distanceMin": 220,
"distanceMax": 250
},
"useOrbitConstraints": true "useOrbitConstraints": true
} }

View File

@ -999,13 +999,5 @@
0.6469944623807775 0.6469944623807775
] ]
} }
], ]
"difficultyConfig": { }
"rockCount": 50,
"forceMultiplier": 1.3,
"rockSizeMin": 2,
"rockSizeMax": 8,
"distanceMin": 90,
"distanceMax": 280
}
}

View File

@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { Link } from 'svelte-routing';
import { authStore } from '../../stores/auth'; import { authStore } from '../../stores/auth';
import Button from '../shared/Button.svelte'; import Button from '../shared/Button.svelte';
@ -16,7 +17,7 @@
<span class="loading">Loading...</span> <span class="loading">Loading...</span>
{:else if $authStore.isAuthenticated && $authStore.user} {:else if $authStore.isAuthenticated && $authStore.user}
<div class="user-info"> <div class="user-info">
<span class="user-name">{$authStore.user.name || $authStore.user.email}</span> <Link to="/profile" class="user-name-link">{$authStore.user.name || $authStore.user.email}</Link>
<Button variant="secondary" on:click={handleLogout}>Logout</Button> <Button variant="secondary" on:click={handleLogout}>Logout</Button>
</div> </div>
{:else} {:else}
@ -37,9 +38,13 @@
gap: var(--space-sm, 0.5rem); gap: var(--space-sm, 0.5rem);
} }
.user-name { :global(.user-name-link) {
color: var(--color-text, #fff); color: var(--color-text, #fff);
font-size: var(--font-size-sm, 0.875rem); font-size: var(--font-size-sm, 0.875rem);
text-decoration: none;
}
:global(.user-name-link:hover) {
color: var(--color-primary, #4fc3f7);
} }
.loading { .loading {

View File

@ -1,7 +1,9 @@
<script lang="ts"> <script lang="ts">
import { levelRegistryStore } from '../../stores/levelRegistry'; import { levelRegistryStore } from '../../stores/levelRegistry';
import { authStore } from '../../stores/auth';
import { CloudLevelService, type CloudLevelEntry } from '../../services/cloudLevelService';
import LevelCard from './LevelCard.svelte'; import LevelCard from './LevelCard.svelte';
import ProgressBar from './ProgressBar.svelte'; import Button from '../shared/Button.svelte';
// Get levels in order (by sortOrder from Supabase) // Get levels in order (by sortOrder from Supabase)
const LEVEL_ORDER = [ const LEVEL_ORDER = [
@ -13,45 +15,98 @@
'final-challenge' 'final-challenge'
]; ];
let activeTab: 'official' | 'my-levels' = 'official';
let myLevels: CloudLevelEntry[] = [];
let loadingMyLevels = false;
let myLevelsLoaded = false;
// Reactive declarations for store values // Reactive declarations for store values
$: isReady = $levelRegistryStore.isInitialized; $: isReady = $levelRegistryStore.isInitialized;
$: levels = $levelRegistryStore.levels; $: levels = $levelRegistryStore.levels;
// Load my levels when tab switches and user is authenticated
$: if (activeTab === 'my-levels' && $authStore.isAuthenticated && !myLevelsLoaded) {
loadMyLevels();
}
async function loadMyLevels() {
loadingMyLevels = true;
try {
myLevels = await CloudLevelService.getInstance().getMyLevels();
myLevelsLoaded = true;
} catch (err) {
console.error('Failed to load my levels:', err);
} finally {
loadingMyLevels = false;
}
}
function switchTab(tab: 'official' | 'my-levels') {
activeTab = tab;
}
</script> </script>
<div id="mainDiv"> <div id="mainDiv">
<div id="levelSelect" class:ready={isReady}> <div id="levelSelect" class:ready={isReady}>
<!-- Hero Section -->
<div class="hero"> <div class="hero">
<h1 class="hero-title">🚀 Space Combat VR</h1> <h1 class="hero-title">Space Combat VR</h1>
<p class="hero-subtitle"> <p class="hero-subtitle">Pilot your spaceship through asteroid fields and complete missions</p>
Pilot your spaceship through asteroid fields and complete missions
</p>
</div> </div>
<!-- Level Selection Section -->
<div class="level-section"> <div class="level-section">
<h2 class="level-header">Your Mission</h2> <div class="tabs">
<p class="level-description"> <button
Choose your level and prepare for launch class="tab"
</p> class:active={activeTab === 'official'}
on:click={() => switchTab('official')}
>
Official Levels
</button>
<button
class="tab"
class:active={activeTab === 'my-levels'}
on:click={() => switchTab('my-levels')}
>
My Levels
</button>
</div>
<div class="card-container" id="levelCardsContainer"> <div class="card-container" id="levelCardsContainer">
{#if !isReady} {#if activeTab === 'official'}
<div class="loading-message">Loading levels...</div> {#if !isReady}
{:else if levels.size === 0} <div class="loading-message">Loading levels...</div>
<div class="no-levels-message"> {:else if levels.size === 0}
<h2>No Levels Found</h2> <div class="no-levels-message">
<p>No levels available. Please check your connection.</p> <h2>No Levels Found</h2>
</div> <p>No levels available. Please check your connection.</p>
</div>
{:else}
{#each LEVEL_ORDER as levelId}
{@const entry = levels.get(levelId)}
{#if entry}
<LevelCard {levelId} levelEntry={entry} />
{/if}
{/each}
{/if}
{:else} {:else}
{#each LEVEL_ORDER as levelId} {#if !$authStore.isAuthenticated}
{@const entry = levels.get(levelId)} <div class="no-levels-message">
{#if entry} <h2>Sign In Required</h2>
<LevelCard <p>Sign in to view your custom levels.</p>
{levelId} <Button variant="primary" on:click={() => authStore.login()}>Sign In</Button>
levelEntry={entry} </div>
/> {:else if loadingMyLevels}
{/if} <div class="loading-message">Loading your levels...</div>
{/each} {:else if myLevels.length === 0}
<div class="no-levels-message">
<h2>No Custom Levels</h2>
<p>You haven't created any levels yet. Use the BabylonJS Editor plugin to create levels.</p>
</div>
{:else}
{#each myLevels as level (level.id)}
<LevelCard levelId={level.id} levelEntry={level} />
{/each}
{/if}
{/if} {/if}
</div> </div>
</div> </div>
@ -59,12 +114,47 @@
</div> </div>
<style> <style>
/* Inherits from global styles.css */ .tabs {
/* Most classes already defined */ display: flex;
gap: 0.5rem;
margin-bottom: 1.5rem;
justify-content: center;
}
.tab {
padding: 0.75rem 1.5rem;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 8px;
color: #aaa;
font-size: 1rem;
cursor: pointer;
transition: all 0.2s ease;
}
.tab:hover {
background: rgba(255, 255, 255, 0.15);
color: #fff;
}
.tab.active {
background: rgba(79, 195, 247, 0.2);
border-color: #4fc3f7;
color: #4fc3f7;
}
.loading-message, .no-levels-message { .loading-message, .no-levels-message {
grid-column: 1 / -1; grid-column: 1 / -1;
text-align: center; text-align: center;
padding: var(--space-2xl, 2rem); padding: var(--space-2xl, 2rem);
} }
.no-levels-message h2 {
margin-bottom: 0.5rem;
}
.no-levels-message p {
margin-bottom: 1rem;
color: #888;
}
</style> </style>

View File

@ -4,6 +4,7 @@
import { Main } from '../../main'; import { Main } from '../../main';
import type { LevelConfig } from '../../levels/config/levelConfig'; import type { LevelConfig } from '../../levels/config/levelConfig';
import { LevelRegistry } from '../../levels/storage/levelRegistry'; import { LevelRegistry } from '../../levels/storage/levelRegistry';
import { CloudLevelService } from '../../services/cloudLevelService';
import { progressionStore } from '../../stores/progression'; import { progressionStore } from '../../stores/progression';
import log from '../../core/logger'; import log from '../../core/logger';
import { DefaultScene } from '../../core/defaultScene'; import { DefaultScene } from '../../core/defaultScene';
@ -86,17 +87,23 @@
throw new Error('Main instance not found'); 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 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) { if (!levelEntry) {
throw new Error(`Level "${levelName}" not found`); throw new Error(`Level "${levelName}" not found`);
} }
// Check if level is unlocked (deep link protection) // Check if level is unlocked (skip for private levels)
const isDefault = levelEntry.levelType === 'official'; const isOfficial = levelEntry.levelType === 'official';
if (!progressionStore.isLevelUnlocked(levelEntry.name, isDefault)) { if (isOfficial && !progressionStore.isLevelUnlocked(levelEntry.name, true)) {
log.warn('[PlayLevel] Level locked, redirecting to level select'); log.warn('[PlayLevel] Level locked, redirecting to level select');
navigate('/', { replace: true }); navigate('/', { replace: true });
return; return;

View File

@ -16,6 +16,7 @@
import SettingsScreen from '../settings/SettingsScreen.svelte'; import SettingsScreen from '../settings/SettingsScreen.svelte';
import ControlsScreen from '../controls/ControlsScreen.svelte'; import ControlsScreen from '../controls/ControlsScreen.svelte';
import Leaderboard from '../leaderboard/Leaderboard.svelte'; import Leaderboard from '../leaderboard/Leaderboard.svelte';
import ProfilePage from '../profile/ProfilePage.svelte';
// Initialize Auth0 when component mounts // Initialize Auth0 when component mounts
onMount(async () => { onMount(async () => {
@ -56,6 +57,7 @@
<Route path="/settings"><SettingsScreen /></Route> <Route path="/settings"><SettingsScreen /></Route>
<Route path="/controls"><ControlsScreen /></Route> <Route path="/controls"><ControlsScreen /></Route>
<Route path="/leaderboard"><Leaderboard /></Route> <Route path="/leaderboard"><Leaderboard /></Route>
<Route path="/profile"><ProfilePage /></Route>
</div> </div>
</div> </div>
</Router> </Router>

View File

@ -0,0 +1,70 @@
<script lang="ts">
export let token: string;
export let onClear: () => void;
let copied = false;
async function copyToken() {
await navigator.clipboard.writeText(token);
copied = true;
setTimeout(() => { copied = false; }, 2000);
}
</script>
<div class="new-token-box">
<div class="warning-header">
<strong>Save this token now!</strong>
<span>You won't be able to see it again.</span>
</div>
<div class="token-display">
<code>{token}</code>
</div>
<div class="token-actions">
<button class="copy-btn" on:click={copyToken}>
{copied ? 'Copied!' : 'Copy to Clipboard'}
</button>
<button class="dismiss-btn" on:click={onClear}>I've saved it</button>
</div>
</div>
<style>
.new-token-box {
background: rgba(79, 195, 247, 0.1);
border: 1px solid #4fc3f7;
border-radius: 8px;
padding: 1rem;
margin-bottom: 1rem;
}
.warning-header {
display: flex;
flex-direction: column;
margin-bottom: 0.75rem;
color: #4fc3f7;
}
.warning-header span { font-size: 0.85rem; opacity: 0.8; }
.token-display {
background: #1a1a1a;
padding: 0.75rem;
border-radius: 4px;
overflow-x: auto;
margin-bottom: 0.75rem;
}
.token-display code {
font-family: monospace;
font-size: 0.9rem;
word-break: break-all;
color: #81c784;
}
.token-actions { display: flex; gap: 0.5rem; }
.copy-btn, .dismiss-btn {
padding: 0.5rem 1rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
}
.copy-btn { background: #4fc3f7; color: #000; }
.dismiss-btn { background: #444; color: #fff; }
</style>

View File

@ -0,0 +1,98 @@
<script lang="ts">
import { Link } from 'svelte-routing';
import { authStore } from '../../stores/auth';
import { SupabaseService } from '../../services/supabaseService';
import Button from '../shared/Button.svelte';
import Section from '../shared/Section.svelte';
import InfoBox from '../shared/InfoBox.svelte';
import TokenList from './TokenList.svelte';
import NewTokenDisplay from './NewTokenDisplay.svelte';
let message = '';
let messageType: 'success' | 'error' | 'warning' = 'success';
let showMessage = false;
let newToken: string | null = null;
let isGenerating = false;
let refreshKey = 0;
async function handleGenerateToken() {
isGenerating = true;
try {
const client = await SupabaseService.getInstance().getAuthenticatedClient();
if (!client) {
// Session is stale - refresh auth store to show login button
await authStore.refresh();
showNotification('Session expired. Please sign in again.', 'warning');
return;
}
const { data, error } = await client.rpc('create_user_token', { p_name: 'Editor Token' });
if (error) throw error;
newToken = data;
refreshKey++; // Trigger token list refresh
showNotification('Token generated! Copy it now - you won\'t see it again.', 'success');
} catch (err) {
showNotification(`Failed to generate token: ${(err as Error).message}`, 'error');
} finally {
isGenerating = false;
}
}
function handleTokenRevoked() {
refreshKey++;
showNotification('Token revoked', 'success');
}
function handleClearNewToken() {
newToken = null;
}
function showNotification(msg: string, type: 'success' | 'error' | 'warning') {
message = msg;
messageType = type;
showMessage = true;
setTimeout(() => { showMessage = false; }, 5000);
}
</script>
<div class="editor-container">
<Link to="/" class="back-link">← Back to Game</Link>
<h1>Profile</h1>
{#if !$authStore.isAuthenticated}
<Section title="Sign In Required">
<p>Please sign in to manage your profile and editor tokens.</p>
<Button variant="primary" on:click={() => authStore.login()}>Sign In</Button>
</Section>
{:else}
<p class="subtitle">Welcome, {$authStore.user?.name || $authStore.user?.email || 'Player'}</p>
<div class="settings-grid">
<Section title="Editor Tokens" description="Generate tokens to authenticate the BabylonJS Editor plugin.">
{#if newToken}
<NewTokenDisplay token={newToken} onClear={handleClearNewToken} />
{/if}
<div class="token-actions">
<Button variant="primary" on:click={handleGenerateToken} disabled={isGenerating}>
{isGenerating ? 'Generating...' : 'Generate New Token'}
</Button>
</div>
{#key refreshKey}
<TokenList onRevoke={handleTokenRevoked} />
{/key}
</Section>
</div>
{/if}
<InfoBox {message} type={messageType} visible={showMessage} />
</div>
<style>
.token-actions {
margin-bottom: 1rem;
}
</style>

View File

@ -0,0 +1,112 @@
<script lang="ts">
import { onMount } from 'svelte';
import { SupabaseService } from '../../services/supabaseService';
import { authStore } from '../../stores/auth';
import Button from '../shared/Button.svelte';
export let onRevoke: () => void;
interface Token {
id: string;
name: string;
token_prefix: string;
created_at: string;
last_used_at: string | null;
is_revoked: boolean;
}
let tokens: Token[] = [];
let loading = true;
let revoking: string | null = null;
onMount(loadTokens);
async function loadTokens() {
loading = true;
try {
const client = await SupabaseService.getInstance().getAuthenticatedClient();
if (!client) {
await authStore.refresh();
return;
}
const { data, error } = await client
.from('user_tokens')
.select('id, name, token_prefix, created_at, last_used_at, is_revoked')
.eq('is_revoked', false)
.order('created_at', { ascending: false });
if (error) throw error;
tokens = data || [];
} catch (err) {
console.error('Failed to load tokens:', err);
} finally {
loading = false;
}
}
async function handleRevoke(tokenId: string) {
if (!confirm('Revoke this token? The editor plugin will need a new token to authenticate.')) return;
revoking = tokenId;
try {
const client = await SupabaseService.getInstance().getAuthenticatedClient();
if (!client) throw new Error('Not authenticated');
const { error } = await client.rpc('revoke_user_token', { p_token_id: tokenId });
if (error) throw error;
tokens = tokens.filter(t => t.id !== tokenId);
onRevoke();
} catch (err) {
console.error('Failed to revoke token:', err);
} finally {
revoking = null;
}
}
function formatDate(dateStr: string | null): string {
if (!dateStr) return 'Never';
return new Date(dateStr).toLocaleDateString();
}
</script>
<div class="token-list">
{#if loading}
<p class="loading">Loading tokens...</p>
{:else if tokens.length === 0}
<p class="empty">No active tokens. Generate one to use with the editor plugin.</p>
{:else}
{#each tokens as token (token.id)}
<div class="token-item">
<div class="token-info">
<span class="token-prefix">{token.token_prefix}...</span>
<span class="token-meta">Created {formatDate(token.created_at)}</span>
{#if token.last_used_at}
<span class="token-meta">Last used {formatDate(token.last_used_at)}</span>
{/if}
</div>
<Button variant="danger" on:click={() => handleRevoke(token.id)} disabled={revoking === token.id}>
{revoking === token.id ? 'Revoking...' : 'Revoke'}
</Button>
</div>
{/each}
{/if}
</div>
<style>
.token-list { margin-top: 1rem; }
.token-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem;
background: rgba(255,255,255,0.05);
border-radius: 4px;
margin-bottom: 0.5rem;
}
.token-info { display: flex; flex-direction: column; gap: 0.25rem; }
.token-prefix { font-family: monospace; font-size: 1rem; color: #4fc3f7; }
.token-meta { font-size: 0.75rem; color: #888; }
.loading, .empty { color: #888; font-style: italic; }
</style>

View File

@ -36,6 +36,7 @@ export class Rock {
interface RockConfig { interface RockConfig {
position: Vector3; position: Vector3;
rotation?: Vector3;
scale: number; scale: number;
linearVelocity: Vector3; linearVelocity: Vector3;
angularVelocity: Vector3; angularVelocity: Vector3;
@ -170,7 +171,8 @@ export class RockFactory {
useOrbitConstraint: boolean = true, useOrbitConstraint: boolean = true,
hidden: boolean = false, hidden: boolean = false,
targetPosition?: Vector3, targetPosition?: Vector3,
targetMode?: 'orbit' | 'moveToward' targetMode?: 'orbit' | 'moveToward',
rotation?: Vector3
): Rock { ): Rock {
if (!this._asteroidMesh) { if (!this._asteroidMesh) {
throw new Error('[RockFactory] Asteroid mesh not loaded. Call initMesh() first.'); 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); const rock = new InstancedMesh("asteroid-" + i, this._asteroidMesh as Mesh);
rock.scaling = new Vector3(scale, scale, scale); rock.scaling = new Vector3(scale, scale, scale);
rock.position = position; rock.position = position;
if (rotation) rock.rotation = rotation;
rock.name = "asteroid-" + i; rock.name = "asteroid-" + i;
rock.id = "asteroid-" + i; rock.id = "asteroid-" + i;
rock.metadata = { type: 'asteroid' }; rock.metadata = { type: 'asteroid' };
@ -188,6 +191,7 @@ export class RockFactory {
// Store config for deferred physics initialization // Store config for deferred physics initialization
const config: RockConfig = { const config: RockConfig = {
position, position,
rotation,
scale, scale,
linearVelocity, linearVelocity,
angularVelocity, angularVelocity,

View File

@ -36,7 +36,8 @@ export default class StarBase {
public static async addToScene( public static async addToScene(
position?: Vector3Array, position?: Vector3Array,
baseGlbPath: string = 'base.glb', baseGlbPath: string = 'base.glb',
hidden: boolean = false hidden: boolean = false,
rotation?: Vector3Array
): Promise<StarBaseMeshResult> { ): Promise<StarBaseMeshResult> {
const importMeshes = await loadAsset(baseGlbPath, "default", { hidden }); const importMeshes = await loadAsset(baseGlbPath, "default", { hidden });
@ -49,9 +50,12 @@ export default class StarBase {
baseMesh.metadata.baseGlbPath = baseGlbPath; baseMesh.metadata.baseGlbPath = baseGlbPath;
} }
// Apply position // Apply position and rotation to root node
(importMeshes.container.rootNodes[0] as TransformNode).position = const rootNode = importMeshes.container.rootNodes[0] as TransformNode;
position ? new Vector3(position[0], position[1], position[2]) : new Vector3(0, 0, 0); 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 }; this._loadedBase = { baseMesh, landingMesh, container: importMeshes.container };

View File

@ -75,6 +75,7 @@ export interface ShipConfig {
*/ */
interface StartBaseConfig { interface StartBaseConfig {
position?: Vector3Array; // Defaults to [0, 0, 0] if not specified position?: Vector3Array; // Defaults to [0, 0, 0] if not specified
rotation?: Vector3Array;
baseGlbPath?: string; // Path to base GLB model (defaults to 'base.glb') 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) 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 { export interface SunConfig {
position: Vector3Array; position: Vector3Array;
rotation?: Vector3Array;
diameter: number; diameter: number;
intensity?: number; // Light intensity intensity?: number; // Light intensity
scale?: Vector3Array; // Independent x/y/z scaling scale?: Vector3Array; // Independent x/y/z scaling
@ -126,6 +128,7 @@ export interface TargetConfig {
export interface AsteroidConfig { export interface AsteroidConfig {
id: string; id: string;
position: Vector3Array; position: Vector3Array;
rotation?: Vector3Array;
scale: number; // Uniform scale applied to all axes scale: number; // Uniform scale applied to all axes
linearVelocity: Vector3Array; linearVelocity: Vector3Array;
angularVelocity?: Vector3Array; angularVelocity?: Vector3Array;
@ -134,18 +137,6 @@ export interface AsteroidConfig {
targetMode?: 'orbit' | 'moveToward'; // How asteroid interacts with target 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 * Complete level configuration
*/ */
@ -170,9 +161,6 @@ export interface LevelConfig {
planets: PlanetConfig[]; planets: PlanetConfig[];
asteroids: AsteroidConfig[]; asteroids: AsteroidConfig[];
// Optional: include original difficulty config for reference
difficultyConfig?: DifficultyConfig;
// Physics configuration // Physics configuration
useOrbitConstraints?: boolean; // Default: true - constrains asteroids to orbit at fixed distance useOrbitConstraints?: boolean; // Default: true - constrains asteroids to orbit at fixed distance

View File

@ -131,8 +131,9 @@ export class LevelDeserializer {
*/ */
private async createStartBaseMesh(hidden: boolean) { private async createStartBaseMesh(hidden: boolean) {
const position = this.config.startBase?.position; const position = this.config.startBase?.position;
const rotation = this.config.startBase?.rotation;
const baseGlbPath = this.config.startBase?.baseGlbPath || 'base.glb'; 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) { if (config.scale) {
sun.scaling = this.arrayToVector3(config.scale); sun.scaling = this.arrayToVector3(config.scale);
} }
if (config.rotation) {
sun.rotation = this.arrayToVector3(config.rotation);
}
return sun; return sun;
} }
@ -217,6 +221,9 @@ export class LevelDeserializer {
material.unlit = true; material.unlit = true;
planet.material = material; planet.material = material;
planet.renderingGroupId = 2; planet.renderingGroupId = 2;
if (planetConfig.rotation) {
planet.rotation = this.arrayToVector3(planetConfig.rotation);
}
planets.push(planet); planets.push(planet);
this._planets.push({ mesh: planet, diameter: planetConfig.diameter }); this._planets.push({ mesh: planet, diameter: planetConfig.diameter });
@ -249,6 +256,7 @@ export class LevelDeserializer {
} }
// Create mesh only (no physics) // Create mesh only (no physics)
const rotation = asteroidConfig.rotation ? this.arrayToVector3(asteroidConfig.rotation) : undefined;
RockFactory.createRockMesh( RockFactory.createRockMesh(
i, i,
this.arrayToVector3(asteroidConfig.position), this.arrayToVector3(asteroidConfig.position),
@ -259,7 +267,8 @@ export class LevelDeserializer {
useOrbitConstraints, useOrbitConstraints,
hidden, hidden,
targetPosition, targetPosition,
asteroidConfig.targetMode asteroidConfig.targetMode,
rotation
); );
const mesh = this.scene.getMeshByName(asteroidConfig.id); const mesh = this.scene.getMeshByName(asteroidConfig.id);

View File

@ -388,6 +388,9 @@ export class Level1 implements Level {
// Get ship config and add ship to scene // Get ship config and add ship to scene
const shipConfig = this._deserializer.getShipConfig(); const shipConfig = this._deserializer.getShipConfig();
await this._ship.addToScene(new Vector3(...shipConfig.position), hidden); await this._ship.addToScene(new Vector3(...shipConfig.position), hidden);
if (shipConfig.rotation) {
this._ship.transformNode.rotation = new Vector3(...shipConfig.rotation);
}
// Create XR camera rig // Create XR camera rig
const cameraRig = new TransformNode("xrCameraRig", DefaultScene.MainScene); const cameraRig = new TransformNode("xrCameraRig", DefaultScene.MainScene);

View File

@ -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<CloudLevelEntry | null> { public async getLevelById(id: string): Promise<CloudLevelEntry | null> {
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) { if (!client) {
log.warn('[CloudLevelService] Supabase not configured'); log.warn('[CloudLevelService] Supabase not configured');
return null; return null;
@ -228,12 +245,10 @@ export class CloudLevelService {
.from('levels') .from('levels')
.select('*') .select('*')
.eq('id', id) .eq('id', id)
.single(); .maybeSingle();
if (error) { 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; return null;
} }
@ -774,7 +789,7 @@ export class CloudLevelService {
.select('can_review_levels, can_manage_admins, can_manage_official, can_view_analytics') .select('can_review_levels, can_manage_admins, can_manage_official, can_view_analytics')
.eq('user_id', internalUserId) .eq('user_id', internalUserId)
.eq('is_active', true) .eq('is_active', true)
.single(); .maybeSingle();
if (error) { if (error) {
log.warn('[CloudLevelService] Admin query error:', error.message, error.code); log.warn('[CloudLevelService] Admin query error:', error.message, error.code);

View File

@ -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;