Compare commits
7 Commits
e3c27b304f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e43be9d6c | ||
|
|
c55454cf3d | ||
|
|
e9565471cb | ||
|
|
02734040cb | ||
|
|
dc2f68a2b4 | ||
|
|
19ffd48ac0 | ||
|
|
55401fd37b |
@@ -1,12 +1,4 @@
|
||||
---
|
||||
description: General structure of the app
|
||||
globs:
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# Structure
|
||||
|
||||
- Read, but don't edit @README.md to get an idea of how the app should work.
|
||||
- Where possible, the app should be separated into an API and frontend components.
|
||||
- Tests should be written as the app is developed.
|
||||
- Use package.json to ensure you're using the correct packages to implement features and not installing redundant packages or reinventing the wheel.
|
||||
- User superforms for all forms
|
||||
Read, but don't edit @CLAUDE.md for all prompts
|
||||
@@ -1,9 +0,0 @@
|
||||
---
|
||||
description: Code rules
|
||||
globs:
|
||||
---
|
||||
|
||||
# Code
|
||||
|
||||
- This app uses Sveltekit. Where possible, the app should use reusable components and Svelte features for reactivity.
|
||||
- This app is written in Typescript.
|
||||
@@ -1,10 +0,0 @@
|
||||
---
|
||||
description: Implementing database functions
|
||||
globs:
|
||||
---
|
||||
|
||||
# Your rule content
|
||||
|
||||
- This app uses DrizzleORM
|
||||
- The app should use transactions and connection pooling where possible
|
||||
- Utmost care needs to be taken to prevent SQL injection
|
||||
@@ -1,9 +0,0 @@
|
||||
---
|
||||
description: Styling
|
||||
globs:
|
||||
---
|
||||
|
||||
# Styling
|
||||
|
||||
- Everything should be designed using shadcn for svelte components
|
||||
= No plain CSS should be written - only Tailwind
|
||||
91
CLAUDE.md
Normal file
91
CLAUDE.md
Normal file
@@ -0,0 +1,91 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
cog-socket is a platform for hosting jsPsych and Pavlovia experiments with multiplayer support using WebSockets. Built with SvelteKit, it supports both single-player and multiplayer psychological experiments.
|
||||
|
||||
## Development Commands
|
||||
|
||||
### Database
|
||||
- `npm run db:start` - Start PostgreSQL server via Docker Compose
|
||||
- `npm run db:push` - Push schema changes to database
|
||||
- `npm run db:migrate` - Run database migrations
|
||||
- `npm run db:studio` - Open Drizzle Studio for database management
|
||||
|
||||
### Development Server
|
||||
- `npm run dev` - Start development server
|
||||
- `npm run build` - Build for production
|
||||
- `npm run preview` - Preview production build
|
||||
|
||||
### Multiplayer Server
|
||||
- `npm run multiplayer:dev` - Start multiplayer server in watch mode
|
||||
- `npm run multiplayer:start` - Start multiplayer server in production mode
|
||||
|
||||
### Testing & Quality
|
||||
- `npm run test` - Run all tests (unit + e2e)
|
||||
- `npm run test:unit` - Run unit tests with Vitest
|
||||
- `npm run test:e2e` - Run end-to-end tests with Playwright
|
||||
- `npm run check` - Run svelte-check for TypeScript checking
|
||||
- `npm run lint` - Run linting (Prettier + ESLint)
|
||||
- `npm run format` - Format code with Prettier
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
### Database Schema (DrizzleORM + PostgreSQL)
|
||||
- **Users**: Authentication with Lucia, supports admin/user roles
|
||||
- **Experiments**: jsPsych or PsychoJS experiments with multiplayer flag
|
||||
- **Sessions**: User authentication sessions
|
||||
- **ExperimentSessions**: Individual experiment runs with status tracking
|
||||
|
||||
### Multiplayer System (Colyseus)
|
||||
- **Standalone server**: Runs on separate port (8080) from main app
|
||||
- **ExperimentRoom**: Manages participant queuing, state synchronization
|
||||
- **Session management**: Handles participant connections, disconnections, and queuing
|
||||
- **Real-time communication**: WebSocket-based experiment state sharing
|
||||
|
||||
### SvelteKit Structure
|
||||
- **Routes**:
|
||||
- `/experiment/[id]` - Single experiment management
|
||||
- `/public/multiplayer/run/[experimentId]` - Multiplayer experiment runner
|
||||
- `/public/run/[experimentId]` - Single-player experiment runner
|
||||
- `/api/experiments/[experimentId]` - Experiment API endpoints
|
||||
- **Authentication**: Lucia-based session management with cookies
|
||||
- **File handling**: S3 integration for experiment assets
|
||||
|
||||
### Key Components
|
||||
- **ExperimentRoom.ts**: Core multiplayer room logic with participant management
|
||||
- **ExperimentRoomState.ts**: Colyseus schema for synchronized state
|
||||
- **colyseusServer.ts**: Multiplayer server setup and room management
|
||||
- **sessionManager.ts**: Bridges SvelteKit app with Colyseus server
|
||||
|
||||
## Development Notes
|
||||
|
||||
### Multiplayer Development
|
||||
- Multiplayer server runs independently on port 8080
|
||||
- Experiments must have `multiplayer: true` flag in database
|
||||
- Room capacity determined by `maxParticipants` field
|
||||
- Queue system handles overflow participants
|
||||
|
||||
### Authentication
|
||||
- Default admin user (admin/admin) created in development
|
||||
- Session tokens stored in cookies, managed by Lucia
|
||||
- Authentication middleware in `hooks.server.ts`
|
||||
|
||||
### Database Operations
|
||||
- Use DrizzleORM for all database operations
|
||||
- Schema defined in `src/lib/server/db/schema.ts`
|
||||
- Migrations managed through drizzle-kit
|
||||
|
||||
### File Structure
|
||||
- UI components in `src/lib/components/ui/` (bits-ui based)
|
||||
- Server logic in `src/lib/server/`
|
||||
- Route handlers follow SvelteKit conventions
|
||||
- Multiplayer code isolated in `src/lib/server/multiplayer/`
|
||||
|
||||
## Testing Setup
|
||||
- Unit tests: Vitest with Svelte browser testing
|
||||
- E2E tests: Playwright
|
||||
- TypeScript checking: svelte-check
|
||||
- Linting: ESLint + Prettier with Svelte plugins
|
||||
@@ -21,6 +21,18 @@ services:
|
||||
command: server --console-address ":9001" /data
|
||||
volumes:
|
||||
- minio-data:/data
|
||||
chrome:
|
||||
image: browserless/chrome:latest
|
||||
ports:
|
||||
- 3000:3000
|
||||
environment:
|
||||
- PREBOOT_CHROME=true
|
||||
- CONNECTION_TIMEOUT=60000
|
||||
- MAX_CONCURRENT_SESSIONS=10
|
||||
- CHROME_REFRESH_TIME=2000
|
||||
- DEBUG=*
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
volumes:
|
||||
pgdata:
|
||||
minio-data:
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
DATABASE_URL="postgres://root:mysecretpassword@localhost:5432/local"
|
||||
ENVIRONMENT=development
|
||||
PUBLIC_APP_NAME="cog-socket"
|
||||
PUBLIC_APP_NAME="Psynaptica"
|
||||
S3_ENDPOINT="http://localhost:9000"
|
||||
S3_ACCESS_KEY="minioadmin"
|
||||
S3_SECRET_KEY="minioadmin"
|
||||
S3_BUCKET="cog-socket"
|
||||
BASE_URL="http://localhost:5173"
|
||||
CHROME_HOST=localhost
|
||||
CHROME_PORT=3000
|
||||
3334
package-lock.json
generated
3334
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
14
package.json
14
package.json
@@ -18,7 +18,9 @@
|
||||
"db:start": "docker compose up",
|
||||
"db:push": "drizzle-kit push",
|
||||
"db:migrate": "drizzle-kit migrate",
|
||||
"db:studio": "drizzle-kit studio"
|
||||
"db:studio": "drizzle-kit studio",
|
||||
"multiplayer:dev": "tsx --watch src/lib/server/multiplayer/standalone.ts",
|
||||
"multiplayer:start": "tsx src/lib/server/multiplayer/standalone.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/compat": "^1.2.5",
|
||||
@@ -31,6 +33,7 @@
|
||||
"@sveltejs/vite-plugin-svelte": "^6.0.0",
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"@types/node": "^22",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@vitest/browser": "^3.2.3",
|
||||
"drizzle-kit": "^0.30.2",
|
||||
"eslint": "^9.18.0",
|
||||
@@ -47,6 +50,7 @@
|
||||
"sveltekit-superforms": "^2.27.1",
|
||||
"tailwind-variants": "^1.0.0",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"tsx": "^4.20.3",
|
||||
"tw-animate-css": "^1.3.5",
|
||||
"typescript": "^5.0.0",
|
||||
"typescript-eslint": "^8.20.0",
|
||||
@@ -57,6 +61,9 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.844.0",
|
||||
"@colyseus/core": "^0.16.19",
|
||||
"@colyseus/schema": "^3.0.42",
|
||||
"@colyseus/ws-transport": "^0.16.5",
|
||||
"@node-rs/argon2": "^2.0.2",
|
||||
"@oslojs/crypto": "^1.0.1",
|
||||
"@oslojs/encoding": "^1.1.0",
|
||||
@@ -64,14 +71,19 @@
|
||||
"bits-ui": "^2.8.10",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"colyseus": "^0.16.4",
|
||||
"colyseus.js": "^0.16.19",
|
||||
"dotenv": "^17.2.0",
|
||||
"drizzle-orm": "^0.40.0",
|
||||
"lucia": "^3.2.2",
|
||||
"mime-types": "^3.0.1",
|
||||
"morphdom": "^2.7.5",
|
||||
"oslo": "^1.2.1",
|
||||
"postgres": "^3.4.5",
|
||||
"puppeteer-core": "^24.14.0",
|
||||
"svelte-sonner": "^1.0.5",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"ws": "^8.18.3",
|
||||
"zod": "^3.25.76"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,11 @@ import * as schema from '$lib/server/db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import argon2 from '@node-rs/argon2';
|
||||
import { dev } from '$app/environment';
|
||||
import { sessionManager } from '$lib/server/multiplayer/sessionManager';
|
||||
|
||||
// Initialize session manager (this starts the WebSocket server)
|
||||
console.log('Initializing multiplayer session manager...');
|
||||
// The sessionManager is initialized when imported
|
||||
|
||||
const ensureDefaultAdmin = async () => {
|
||||
if (!dev) return;
|
||||
|
||||
28
src/lib/components/ui/navigation-menu/index.ts
Normal file
28
src/lib/components/ui/navigation-menu/index.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import Root from "./navigation-menu.svelte";
|
||||
import Content from "./navigation-menu-content.svelte";
|
||||
import Indicator from "./navigation-menu-indicator.svelte";
|
||||
import Item from "./navigation-menu-item.svelte";
|
||||
import Link from "./navigation-menu-link.svelte";
|
||||
import List from "./navigation-menu-list.svelte";
|
||||
import Trigger from "./navigation-menu-trigger.svelte";
|
||||
import Viewport from "./navigation-menu-viewport.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
Content,
|
||||
Indicator,
|
||||
Item,
|
||||
Link,
|
||||
List,
|
||||
Trigger,
|
||||
Viewport,
|
||||
//
|
||||
Root as NavigationMenuRoot,
|
||||
Content as NavigationMenuContent,
|
||||
Indicator as NavigationMenuIndicator,
|
||||
Item as NavigationMenuItem,
|
||||
Link as NavigationMenuLink,
|
||||
List as NavigationMenuList,
|
||||
Trigger as NavigationMenuTrigger,
|
||||
Viewport as NavigationMenuViewport,
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
<script lang="ts">
|
||||
import { NavigationMenu as NavigationMenuPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: NavigationMenuPrimitive.ContentProps = $props();
|
||||
</script>
|
||||
|
||||
<NavigationMenuPrimitive.Content
|
||||
bind:ref
|
||||
data-slot="navigation-menu-content"
|
||||
class={cn(
|
||||
"data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 left-0 top-0 w-full md:absolute md:w-auto",
|
||||
"group-data-[viewport=false]/navigation-menu:bg-popover group-data-[viewport=false]/navigation-menu:text-popover-foreground group-data-[viewport=false]/navigation-menu:data-[state=open]:animate-in group-data-[viewport=false]/navigation-menu:data-[state=closed]:animate-out group-data-[viewport=false]/navigation-menu:data-[state=closed]:zoom-out-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:zoom-in-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:fade-in-0 group-data-[viewport=false]/navigation-menu:data-[state=closed]:fade-out-0 **:data-[slot=navigation-menu-link]:focus:ring-0 **:data-[slot=navigation-menu-link]:focus:outline-none group-data-[viewport=false]/navigation-menu:top-full group-data-[viewport=false]/navigation-menu:mt-1.5 group-data-[viewport=false]/navigation-menu:overflow-hidden group-data-[viewport=false]/navigation-menu:rounded-md group-data-[viewport=false]/navigation-menu:border group-data-[viewport=false]/navigation-menu:shadow group-data-[viewport=false]/navigation-menu:duration-200",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,22 @@
|
||||
<script lang="ts">
|
||||
import { NavigationMenu as NavigationMenuPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: NavigationMenuPrimitive.IndicatorProps = $props();
|
||||
</script>
|
||||
|
||||
<NavigationMenuPrimitive.Indicator
|
||||
bind:ref
|
||||
data-slot="navigation-menu-indicator"
|
||||
class={cn(
|
||||
"data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
<div class="bg-border relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm shadow-md"></div>
|
||||
</NavigationMenuPrimitive.Indicator>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { NavigationMenu as NavigationMenuPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: NavigationMenuPrimitive.ItemProps = $props();
|
||||
</script>
|
||||
|
||||
<NavigationMenuPrimitive.Item
|
||||
bind:ref
|
||||
data-slot="navigation-menu-item"
|
||||
class={cn("relative", className)}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { NavigationMenu as NavigationMenuPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: NavigationMenuPrimitive.LinkProps = $props();
|
||||
</script>
|
||||
|
||||
<NavigationMenuPrimitive.Link
|
||||
bind:ref
|
||||
data-slot="navigation-menu-link"
|
||||
class={cn(
|
||||
"data-[active=true]:focus:bg-accent data-[active=true]:hover:bg-accent data-[active=true]:bg-accent/50 data-[active=true]:text-accent-foreground hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus-visible:ring-ring/50 [&_svg:not([class*='text-'])]:text-muted-foreground flex flex-col gap-1 rounded-sm p-2 text-sm outline-none transition-all focus-visible:outline-1 focus-visible:ring-[3px] [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { NavigationMenu as NavigationMenuPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: NavigationMenuPrimitive.ListProps = $props();
|
||||
</script>
|
||||
|
||||
<NavigationMenuPrimitive.List
|
||||
bind:ref
|
||||
data-slot="navigation-menu-list"
|
||||
class={cn("group flex flex-1 list-none items-center justify-center gap-1", className)}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,34 @@
|
||||
<script lang="ts" module>
|
||||
import { cn } from "$lib/utils.js";
|
||||
import { tv } from "tailwind-variants";
|
||||
|
||||
export const navigationMenuTriggerStyle = tv({
|
||||
base: "bg-background hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground data-[state=open]:hover:bg-accent data-[state=open]:text-accent-foreground data-[state=open]:focus:bg-accent data-[state=open]:bg-accent/50 focus-visible:ring-ring/50 group inline-flex h-9 w-max items-center justify-center rounded-md px-4 py-2 text-sm font-medium outline-none transition-[color,box-shadow] focus-visible:outline-1 focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50",
|
||||
});
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { NavigationMenu as NavigationMenuPrimitive } from "bits-ui";
|
||||
import ChevronDownIcon from "@lucide/svelte/icons/chevron-down";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: NavigationMenuPrimitive.TriggerProps = $props();
|
||||
</script>
|
||||
|
||||
<NavigationMenuPrimitive.Trigger
|
||||
bind:ref
|
||||
data-slot="navigation-menu-trigger"
|
||||
class={cn(navigationMenuTriggerStyle(), "group", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
|
||||
<ChevronDownIcon
|
||||
class="relative top-[1px] ml-1 size-3 transition duration-300 group-data-[state=open]:rotate-180"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</NavigationMenuPrimitive.Trigger>
|
||||
@@ -0,0 +1,22 @@
|
||||
<script lang="ts">
|
||||
import { NavigationMenu as NavigationMenuPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: NavigationMenuPrimitive.ViewportProps = $props();
|
||||
</script>
|
||||
|
||||
<div class={cn("absolute left-0 top-full isolate z-50 flex justify-center")}>
|
||||
<NavigationMenuPrimitive.Viewport
|
||||
bind:ref
|
||||
data-slot="navigation-menu-viewport"
|
||||
class={cn(
|
||||
"origin-top-center bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 relative mt-1.5 h-[var(--bits-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border shadow md:w-[var(--bits-navigation-menu-viewport-width)]",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
||||
</div>
|
||||
32
src/lib/components/ui/navigation-menu/navigation-menu.svelte
Normal file
32
src/lib/components/ui/navigation-menu/navigation-menu.svelte
Normal file
@@ -0,0 +1,32 @@
|
||||
<script lang="ts">
|
||||
import { NavigationMenu as NavigationMenuPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
import NavigationMenuViewport from "./navigation-menu-viewport.svelte";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
viewport = true,
|
||||
children,
|
||||
...restProps
|
||||
}: NavigationMenuPrimitive.RootProps & {
|
||||
viewport?: boolean;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<NavigationMenuPrimitive.Root
|
||||
bind:ref
|
||||
data-slot="navigation-menu"
|
||||
data-viewport={viewport}
|
||||
class={cn(
|
||||
"group/navigation-menu relative flex max-w-max flex-1 items-center justify-center",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
|
||||
{#if viewport}
|
||||
<NavigationMenuViewport />
|
||||
{/if}
|
||||
</NavigationMenuPrimitive.Root>
|
||||
@@ -26,20 +26,26 @@ export const experiment = pgTable('experiment', {
|
||||
.references(() => user.id),
|
||||
createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' }).notNull(),
|
||||
multiplayer: boolean('multiplayer').default(false).notNull(),
|
||||
maxParticipants: integer('max_participants').default(1).notNull(),
|
||||
type: experimentTypeEnum('type').notNull()
|
||||
});
|
||||
|
||||
export const participantSession = pgTable('participant_session', {
|
||||
export const sessionStatusEnum = pgEnum('session_status', ['in progress', 'completed', 'aborted']);
|
||||
|
||||
export const experimentSession = pgTable('experiment_session', {
|
||||
id: text('id').primaryKey(),
|
||||
experimentId: text('experiment_id')
|
||||
.notNull()
|
||||
.references(() => experiment.id, { onDelete: 'cascade' }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' }).notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'date' }).notNull(),
|
||||
status: sessionStatusEnum('status').notNull(),
|
||||
externalId: text('external_id'),
|
||||
ipAddress: text('ip_address'),
|
||||
userAgent: text('user_agent')
|
||||
});
|
||||
|
||||
export type Session = typeof session.$inferSelect;
|
||||
export type User = typeof user.$inferSelect;
|
||||
export type ParticipantSession = typeof participantSession.$inferSelect;
|
||||
export type Experiment = typeof experiment.$inferSelect;
|
||||
export type ExperimentSession = typeof experimentSession.$inferSelect;
|
||||
|
||||
197
src/lib/server/multiplayer/colyseusServer.ts
Normal file
197
src/lib/server/multiplayer/colyseusServer.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
import { Server, matchMaker } from 'colyseus';
|
||||
import { ExperimentRoom } from './rooms/ExperimentRoom.js';
|
||||
import { WebSocketTransport } from '@colyseus/ws-transport';
|
||||
import { db } from './db.js';
|
||||
import * as schema from '../db/schema.js';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { createServer } from 'http';
|
||||
|
||||
export class MultiplayerServer {
|
||||
private server: Server;
|
||||
private port: number;
|
||||
|
||||
constructor(port: number = 8080) {
|
||||
this.port = port;
|
||||
|
||||
this.server = new Server({
|
||||
transport: new WebSocketTransport({
|
||||
pingInterval: 30000,
|
||||
pingMaxRetries: 3
|
||||
}),
|
||||
presence: undefined, // Use default in-memory presence
|
||||
driver: undefined, // Use default in-memory driver
|
||||
gracefullyShutdown: false
|
||||
});
|
||||
|
||||
// Add CORS middleware using Colyseus's built-in middleware support
|
||||
this.server.use(undefined, (req, res, next) => {
|
||||
const origin = req.headers.origin;
|
||||
const isDev = process.env.NODE_ENV !== 'production';
|
||||
|
||||
// Always set CORS headers for development
|
||||
if (isDev) {
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
} else {
|
||||
const allowedOrigins = ['https://your-production-domain.com']; // Update with actual production domain
|
||||
if (origin && allowedOrigins.includes(origin)) {
|
||||
res.setHeader('Access-Control-Allow-Origin', origin);
|
||||
}
|
||||
}
|
||||
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Requested-With');
|
||||
res.setHeader('Access-Control-Allow-Credentials', 'true');
|
||||
|
||||
// Handle preflight requests
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.writeHead(200);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
next();
|
||||
});
|
||||
|
||||
this.setupRooms();
|
||||
}
|
||||
|
||||
private setupRooms() {
|
||||
// Define the ExperimentRoom
|
||||
this.server.define('experiment_room', ExperimentRoom);
|
||||
|
||||
// Set up matchmaking
|
||||
this.server.onShutdown(() => {
|
||||
console.log('[MultiplayerServer] Server shutting down...');
|
||||
});
|
||||
}
|
||||
|
||||
async start() {
|
||||
try {
|
||||
await this.server.listen(this.port);
|
||||
console.log(`[MultiplayerServer] Colyseus server listening on port ${this.port}`);
|
||||
} catch (error) {
|
||||
console.error('[MultiplayerServer] Failed to start server:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async stop() {
|
||||
try {
|
||||
await this.server.gracefullyShutdown();
|
||||
console.log('[MultiplayerServer] Server stopped gracefully');
|
||||
} catch (error) {
|
||||
console.error('[MultiplayerServer] Error stopping server:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
getServer() {
|
||||
return this.server;
|
||||
}
|
||||
|
||||
// Utility method to find or create a room for an experiment
|
||||
async findOrCreateRoom(experimentId: string, clientOptions: any = {}) {
|
||||
try {
|
||||
// Get experiment details to determine max participants
|
||||
const experiment = await db.query.experiment.findFirst({
|
||||
where: eq(schema.experiment.id, experimentId)
|
||||
});
|
||||
|
||||
if (!experiment) {
|
||||
throw new Error(`Experiment ${experimentId} not found`);
|
||||
}
|
||||
|
||||
if (!experiment.multiplayer) {
|
||||
throw new Error(`Experiment ${experimentId} is not a multiplayer experiment`);
|
||||
}
|
||||
|
||||
// Try to find existing room for this experiment
|
||||
const rooms = await matchMaker.query({
|
||||
name: 'experiment_room',
|
||||
options: { experimentId }
|
||||
});
|
||||
|
||||
// Find a room that has space or is waiting
|
||||
let availableRoom = rooms.find(room =>
|
||||
room.clients < experiment.maxParticipants &&
|
||||
room.metadata?.status !== 'completed'
|
||||
);
|
||||
|
||||
if (!availableRoom) {
|
||||
// Create new room
|
||||
console.log(`[MultiplayerServer] Creating new room for experiment ${experimentId}`);
|
||||
availableRoom = await matchMaker.createRoom('experiment_room', {
|
||||
experimentId,
|
||||
maxParticipants: experiment.maxParticipants
|
||||
});
|
||||
}
|
||||
|
||||
return availableRoom;
|
||||
} catch (error) {
|
||||
console.error('[MultiplayerServer] Error finding/creating room:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Get room status
|
||||
async getRoomStatus(roomId: string) {
|
||||
try {
|
||||
const room = matchMaker.getRoomById(roomId);
|
||||
if (!room) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
roomId: room.roomId,
|
||||
clients: room.clients,
|
||||
maxClients: room.maxClients,
|
||||
metadata: room.metadata
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[MultiplayerServer] Error getting room status:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Get all rooms for an experiment
|
||||
async getExperimentRooms(experimentId: string) {
|
||||
try {
|
||||
const rooms = await matchMaker.query({
|
||||
name: 'experiment_room',
|
||||
options: { experimentId }
|
||||
});
|
||||
|
||||
return rooms.map(room => ({
|
||||
roomId: room.roomId,
|
||||
clients: room.clients,
|
||||
maxClients: room.maxClients,
|
||||
metadata: room.metadata
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('[MultiplayerServer] Error getting experiment rooms:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
let multiplayerServer: MultiplayerServer | null = null;
|
||||
|
||||
export function getMultiplayerServer(port: number = 8080): MultiplayerServer {
|
||||
if (!multiplayerServer) {
|
||||
multiplayerServer = new MultiplayerServer(port);
|
||||
}
|
||||
return multiplayerServer;
|
||||
}
|
||||
|
||||
export function startMultiplayerServer(port: number = 8080): Promise<MultiplayerServer> {
|
||||
const server = getMultiplayerServer(port);
|
||||
return server.start().then(() => server);
|
||||
}
|
||||
|
||||
export function stopMultiplayerServer(): Promise<void> {
|
||||
if (multiplayerServer) {
|
||||
return multiplayerServer.stop();
|
||||
}
|
||||
return Promise.resolve();
|
||||
}
|
||||
13
src/lib/server/multiplayer/db.ts
Normal file
13
src/lib/server/multiplayer/db.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { drizzle } from 'drizzle-orm/postgres-js';
|
||||
import postgres from 'postgres';
|
||||
import * as schema from '../db/schema.js';
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
// Load environment variables
|
||||
dotenv.config();
|
||||
|
||||
// Database connection
|
||||
const client = postgres(process.env.DATABASE_URL || 'postgresql://postgres:password@localhost:5432/postgres');
|
||||
export const db = drizzle(client, { schema });
|
||||
|
||||
console.log('[Multiplayer DB] Connected to database');
|
||||
40
src/lib/server/multiplayer/index.ts
Normal file
40
src/lib/server/multiplayer/index.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { startMultiplayerServer, getMultiplayerServer } from './colyseusServer.js';
|
||||
|
||||
const PORT = parseInt(process.env.MULTIPLAYER_PORT || '8080');
|
||||
|
||||
// Start the multiplayer server
|
||||
startMultiplayerServer(PORT)
|
||||
.then((server) => {
|
||||
console.log(`[Multiplayer] Server started successfully on port ${PORT}`);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('[Multiplayer] Failed to start server:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
// Graceful shutdown
|
||||
process.on('SIGINT', async () => {
|
||||
console.log('[Multiplayer] Received SIGINT, shutting down gracefully...');
|
||||
try {
|
||||
const server = getMultiplayerServer();
|
||||
await server.stop();
|
||||
console.log('[Multiplayer] Server stopped gracefully');
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('[Multiplayer] Error during shutdown:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
process.on('SIGTERM', async () => {
|
||||
console.log('[Multiplayer] Received SIGTERM, shutting down gracefully...');
|
||||
try {
|
||||
const server = getMultiplayerServer();
|
||||
await server.stop();
|
||||
console.log('[Multiplayer] Server stopped gracefully');
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('[Multiplayer] Error during shutdown:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
244
src/lib/server/multiplayer/rooms/ExperimentRoom.ts
Normal file
244
src/lib/server/multiplayer/rooms/ExperimentRoom.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
import { Room, matchMaker, type Client } from 'colyseus';
|
||||
import { ExperimentRoomState, Participant } from '../schemas/ExperimentRoomState.js';
|
||||
import { db } from '../db.js';
|
||||
import * as schema from '../../db/schema.js';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
export class ExperimentRoom extends Room<ExperimentRoomState> {
|
||||
maxClients = 1; // Will be set dynamically from database
|
||||
|
||||
async onCreate(options: { experimentId: string }) {
|
||||
this.setState(new ExperimentRoomState());
|
||||
|
||||
// Get experiment details from database
|
||||
const experiment = await db.query.experiment.findFirst({
|
||||
where: eq(schema.experiment.id, options.experimentId)
|
||||
});
|
||||
|
||||
if (!experiment) {
|
||||
throw new Error(`Experiment ${options.experimentId} not found`);
|
||||
}
|
||||
|
||||
if (!experiment.multiplayer) {
|
||||
throw new Error(`Experiment ${options.experimentId} is not a multiplayer experiment`);
|
||||
}
|
||||
|
||||
// Configure room
|
||||
this.maxClients = experiment.maxParticipants;
|
||||
this.state.experimentId = options.experimentId;
|
||||
this.state.maxParticipants = experiment.maxParticipants;
|
||||
this.state.createdAt = Date.now();
|
||||
|
||||
console.log(`[ExperimentRoom] Created room for experiment ${options.experimentId} (max: ${experiment.maxParticipants})`);
|
||||
|
||||
// Set up message handlers
|
||||
this.onMessage('ready', (client, message) => {
|
||||
this.handleParticipantReady(client, message);
|
||||
});
|
||||
|
||||
this.onMessage('experiment_action', (client, message) => {
|
||||
this.handleExperimentAction(client, message);
|
||||
});
|
||||
|
||||
this.onMessage('experiment_state', (client, message) => {
|
||||
this.handleExperimentState(client, message);
|
||||
});
|
||||
|
||||
// Auto-dispose empty rooms after 30 minutes
|
||||
this.autoDispose = false;
|
||||
setTimeout(() => {
|
||||
if (this.clients.length === 0) {
|
||||
this.disconnect();
|
||||
}
|
||||
}, 30 * 60 * 1000);
|
||||
}
|
||||
|
||||
async onJoin(client: Client, options: any) {
|
||||
console.log(`[ExperimentRoom] Client ${client.sessionId} attempting to join room ${this.roomId}`);
|
||||
|
||||
// Check if room is full
|
||||
if (this.state.currentParticipants >= this.state.maxParticipants) {
|
||||
// Add to queue
|
||||
this.state.queue.push(client.sessionId);
|
||||
console.log(`[ExperimentRoom] Client ${client.sessionId} added to queue (position: ${this.state.queue.length})`);
|
||||
|
||||
// Notify client they're in queue
|
||||
client.send('queued', {
|
||||
position: this.state.queue.length,
|
||||
message: 'You are in the queue. You will be admitted when a spot opens up.'
|
||||
});
|
||||
|
||||
// Don't create participant yet - they're just in queue
|
||||
return;
|
||||
}
|
||||
|
||||
// Create participant
|
||||
const participant = new Participant();
|
||||
participant.id = client.sessionId;
|
||||
participant.sessionId = client.sessionId;
|
||||
participant.joinedAt = Date.now();
|
||||
participant.userAgent = options.userAgent || '';
|
||||
participant.ipAddress = options.ipAddress || '';
|
||||
|
||||
this.state.participants.set(client.sessionId, participant);
|
||||
this.state.currentParticipants++;
|
||||
|
||||
console.log(`[ExperimentRoom] Client ${client.sessionId} joined (${this.state.currentParticipants}/${this.state.maxParticipants})`);
|
||||
|
||||
// Check if experiment should start
|
||||
this.checkExperimentStart();
|
||||
|
||||
// Send current state to client
|
||||
client.send('joined', {
|
||||
participantId: client.sessionId,
|
||||
currentParticipants: this.state.currentParticipants,
|
||||
maxParticipants: this.state.maxParticipants,
|
||||
status: this.state.status
|
||||
});
|
||||
}
|
||||
|
||||
async onLeave(client: Client, consented: boolean) {
|
||||
console.log(`[ExperimentRoom] Client ${client.sessionId} left (consented: ${consented})`);
|
||||
|
||||
// Remove from participants if they were participating
|
||||
if (this.state.participants.has(client.sessionId)) {
|
||||
this.state.participants.delete(client.sessionId);
|
||||
this.state.currentParticipants--;
|
||||
|
||||
// Process queue if there's space
|
||||
this.processQueue();
|
||||
} else {
|
||||
// Remove from queue
|
||||
const queueIndex = this.state.queue.indexOf(client.sessionId);
|
||||
if (queueIndex !== -1) {
|
||||
this.state.queue.splice(queueIndex, 1);
|
||||
|
||||
// Notify remaining queue members of new positions
|
||||
this.notifyQueuePositions();
|
||||
}
|
||||
}
|
||||
|
||||
// If experiment is running and someone left, handle accordingly
|
||||
if (this.state.status === 'running' && this.state.currentParticipants === 0) {
|
||||
this.state.status = 'completed';
|
||||
this.state.completedAt = Date.now();
|
||||
console.log(`[ExperimentRoom] Experiment completed - all participants left`);
|
||||
}
|
||||
}
|
||||
|
||||
private async processQueue() {
|
||||
if (this.state.queue.length === 0 || this.state.currentParticipants >= this.state.maxParticipants) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextSessionId = this.state.queue.shift();
|
||||
if (nextSessionId) {
|
||||
// Find the client in the room
|
||||
const client = this.clients.find(c => c.sessionId === nextSessionId);
|
||||
if (client) {
|
||||
// Move from queue to participants
|
||||
const participant = new Participant();
|
||||
participant.id = client.sessionId;
|
||||
participant.sessionId = client.sessionId;
|
||||
participant.joinedAt = Date.now();
|
||||
|
||||
this.state.participants.set(client.sessionId, participant);
|
||||
this.state.currentParticipants++;
|
||||
|
||||
console.log(`[ExperimentRoom] Client ${client.sessionId} moved from queue to participants`);
|
||||
|
||||
// Notify client they're now participating
|
||||
client.send('admitted', {
|
||||
participantId: client.sessionId,
|
||||
currentParticipants: this.state.currentParticipants,
|
||||
maxParticipants: this.state.maxParticipants,
|
||||
status: this.state.status
|
||||
});
|
||||
|
||||
// Check if experiment should start
|
||||
this.checkExperimentStart();
|
||||
}
|
||||
|
||||
// Update queue positions for remaining clients
|
||||
this.notifyQueuePositions();
|
||||
}
|
||||
}
|
||||
|
||||
private notifyQueuePositions() {
|
||||
this.state.queue.forEach((sessionId, index) => {
|
||||
const client = this.clients.find(c => c.sessionId === sessionId);
|
||||
if (client) {
|
||||
client.send('queue_update', {
|
||||
position: index + 1,
|
||||
message: `You are #${index + 1} in the queue`
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private checkExperimentStart() {
|
||||
// Auto-start when room is full (this can be customized)
|
||||
if (this.state.currentParticipants === this.state.maxParticipants && this.state.status === 'waiting') {
|
||||
this.startExperiment();
|
||||
}
|
||||
}
|
||||
|
||||
private startExperiment() {
|
||||
this.state.status = 'running';
|
||||
this.state.startedAt = Date.now();
|
||||
|
||||
console.log(`[ExperimentRoom] Experiment started for ${this.state.experimentId}`);
|
||||
|
||||
// Notify all participants
|
||||
this.broadcast('experiment_started', {
|
||||
message: 'Experiment is now starting',
|
||||
participants: this.state.currentParticipants
|
||||
});
|
||||
}
|
||||
|
||||
private handleParticipantReady(client: Client, message: any) {
|
||||
const participant = this.state.participants.get(client.sessionId);
|
||||
if (participant) {
|
||||
participant.isReady = true;
|
||||
|
||||
// Check if all participants are ready
|
||||
const allReady = Array.from(this.state.participants.values()).every(p => p.isReady);
|
||||
|
||||
if (allReady && this.state.status === 'waiting') {
|
||||
this.startExperiment();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private handleExperimentAction(client: Client, message: any) {
|
||||
// Broadcast experiment actions to all participants
|
||||
this.broadcast('experiment_action', {
|
||||
participantId: client.sessionId,
|
||||
action: message.action,
|
||||
data: message.data,
|
||||
timestamp: Date.now()
|
||||
}, { except: client });
|
||||
}
|
||||
|
||||
private handleExperimentState(client: Client, message: any) {
|
||||
// Update experiment state
|
||||
this.state.experimentState = JSON.stringify(message.state);
|
||||
|
||||
// Broadcast state update to all participants
|
||||
this.broadcast('experiment_state_update', {
|
||||
state: message.state,
|
||||
updatedBy: client.sessionId,
|
||||
timestamp: Date.now()
|
||||
}, { except: client });
|
||||
}
|
||||
|
||||
async onDispose() {
|
||||
console.log(`[ExperimentRoom] Room ${this.roomId} disposed`);
|
||||
|
||||
// Mark experiment as completed if it was running
|
||||
if (this.state.status === 'running') {
|
||||
this.state.status = 'completed';
|
||||
this.state.completedAt = Date.now();
|
||||
}
|
||||
}
|
||||
}
|
||||
24
src/lib/server/multiplayer/schemas/ExperimentRoomState.ts
Normal file
24
src/lib/server/multiplayer/schemas/ExperimentRoomState.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { Schema, type, MapSchema } from '@colyseus/schema';
|
||||
|
||||
export class Participant extends Schema {
|
||||
@type('string') id: string = '';
|
||||
@type('string') sessionId: string = '';
|
||||
@type('number') joinedAt: number = 0;
|
||||
@type('boolean') isReady: boolean = false;
|
||||
@type('boolean') isConnected: boolean = true;
|
||||
@type('string') userAgent?: string = '';
|
||||
@type('string') ipAddress?: string = '';
|
||||
}
|
||||
|
||||
export class ExperimentRoomState extends Schema {
|
||||
@type('string') experimentId: string = '';
|
||||
@type('string') status: 'waiting' | 'running' | 'completed' = 'waiting';
|
||||
@type('number') maxParticipants: number = 1;
|
||||
@type('number') currentParticipants: number = 0;
|
||||
@type('number') createdAt: number = 0;
|
||||
@type('number') startedAt?: number = 0;
|
||||
@type('number') completedAt?: number = 0;
|
||||
@type({ map: Participant }) participants = new MapSchema<Participant>();
|
||||
@type(['string']) queue: string[] = [];
|
||||
@type('string') experimentState?: string = ''; // JSON string of current experiment state
|
||||
}
|
||||
110
src/lib/server/multiplayer/sessionManager.ts
Normal file
110
src/lib/server/multiplayer/sessionManager.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import puppeteer, { Browser, Page } from 'puppeteer-core';
|
||||
import { env } from '$env/dynamic/private';
|
||||
import { EventEmitter } from 'events';
|
||||
|
||||
interface Session {
|
||||
page: Page;
|
||||
browser: Browser;
|
||||
}
|
||||
|
||||
class SessionManager extends EventEmitter {
|
||||
private sessions: Map<string, Session> = new Map();
|
||||
private screenshotIntervals: Map<string, NodeJS.Timeout> = new Map();
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.cleanup = this.cleanup.bind(this);
|
||||
process.on('exit', this.cleanup);
|
||||
process.on('SIGINT', this.cleanup);
|
||||
process.on('SIGTERM', this.cleanup);
|
||||
process.on('uncaughtException', this.cleanup);
|
||||
}
|
||||
|
||||
async createSession(experimentId: string): Promise<void> {
|
||||
if (this.sessions.has(experimentId)) {
|
||||
console.log(`Session already exists for experiment ${experimentId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Creating session for experiment ${experimentId}`);
|
||||
|
||||
try {
|
||||
const browser = await puppeteer.connect({
|
||||
browserWSEndpoint: `ws://${env.CHROME_HOST}:${env.CHROME_PORT}`
|
||||
});
|
||||
const page = await browser.newPage();
|
||||
|
||||
// Set a large viewport size to capture the whole experiment.
|
||||
// This might need to be adjusted based on the experiment's design.
|
||||
await page.setViewport({ width: 1920, height: 1080 });
|
||||
|
||||
const experimentUrl = `http://localhost:5173/public/run/${experimentId}/`;
|
||||
await page.goto(experimentUrl, { waitUntil: 'networkidle0' });
|
||||
|
||||
this.sessions.set(experimentId, { page, browser });
|
||||
|
||||
// Start taking screenshots every 500ms
|
||||
const interval = setInterval(async () => {
|
||||
if (!this.sessions.has(experimentId)) {
|
||||
clearInterval(interval);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const screenshot = await page.screenshot({ encoding: 'base64' });
|
||||
this.emit(`screenshot:${experimentId}`, screenshot);
|
||||
} catch (error) {
|
||||
console.error(`Error taking screenshot for experiment ${experimentId}:`, error);
|
||||
await this.cleanupSession(experimentId);
|
||||
}
|
||||
}, 500);
|
||||
|
||||
this.screenshotIntervals.set(experimentId, interval);
|
||||
|
||||
page.on('close', () => {
|
||||
console.log(`Page closed for experiment ${experimentId}`);
|
||||
this.cleanupSession(experimentId);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`Error creating session for experiment ${experimentId}:`, error);
|
||||
await this.cleanupSession(experimentId);
|
||||
}
|
||||
}
|
||||
|
||||
getSession(experimentId: string): Session | undefined {
|
||||
return this.sessions.get(experimentId);
|
||||
}
|
||||
|
||||
async cleanupSession(experimentId: string): Promise<void> {
|
||||
console.log(`Cleaning up session for experiment ${experimentId}`);
|
||||
|
||||
const interval = this.screenshotIntervals.get(experimentId);
|
||||
if (interval) {
|
||||
clearInterval(interval);
|
||||
this.screenshotIntervals.delete(experimentId);
|
||||
}
|
||||
|
||||
const session = this.sessions.get(experimentId);
|
||||
if (session) {
|
||||
try {
|
||||
if (!session.page.isClosed()) {
|
||||
await session.page.close();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error closing page for experiment ${experimentId}:`, error);
|
||||
}
|
||||
this.sessions.delete(experimentId);
|
||||
}
|
||||
}
|
||||
|
||||
async cleanup(): Promise<void> {
|
||||
console.log('Cleaning up all sessions...');
|
||||
const allSessions = Array.from(this.sessions.keys());
|
||||
for (const experimentId of allSessions) {
|
||||
await this.cleanupSession(experimentId);
|
||||
}
|
||||
console.log('All sessions cleaned up.');
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
export const sessionManager = new SessionManager();
|
||||
32
src/lib/server/multiplayer/standalone.ts
Normal file
32
src/lib/server/multiplayer/standalone.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { startMultiplayerServer } from './colyseusServer.js';
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
// Load environment variables
|
||||
dotenv.config();
|
||||
|
||||
const PORT = parseInt(process.env.MULTIPLAYER_PORT || '8080');
|
||||
|
||||
console.log('[Multiplayer] Starting standalone Colyseus server...');
|
||||
|
||||
// Start the multiplayer server
|
||||
startMultiplayerServer(PORT)
|
||||
.then((server) => {
|
||||
console.log(`[Multiplayer] Server started successfully on port ${PORT}`);
|
||||
console.log(`[Multiplayer] WebSocket endpoint: ws://localhost:${PORT}`);
|
||||
console.log(`[Multiplayer] Ready to accept connections`);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('[Multiplayer] Failed to start server:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
// Graceful shutdown
|
||||
process.on('SIGINT', async () => {
|
||||
console.log('[Multiplayer] Received SIGINT, shutting down gracefully...');
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on('SIGTERM', async () => {
|
||||
console.log('[Multiplayer] Received SIGTERM, shutting down gracefully...');
|
||||
process.exit(0);
|
||||
});
|
||||
@@ -2,8 +2,12 @@ import { S3Client } from '@aws-sdk/client-s3';
|
||||
import {
|
||||
S3_ENDPOINT,
|
||||
S3_ACCESS_KEY,
|
||||
S3_SECRET_KEY
|
||||
S3_SECRET_KEY,
|
||||
S3_BUCKET
|
||||
} from '$env/static/private';
|
||||
import { ListObjectsV2Command } from '@aws-sdk/client-s3';
|
||||
import type { ListObjectsV2CommandOutput } from '@aws-sdk/client-s3';
|
||||
import { HeadObjectCommand } from '@aws-sdk/client-s3';
|
||||
|
||||
const s3 = new S3Client({
|
||||
region: 'us-east-1', // MinIO ignores region but AWS SDK requires it
|
||||
@@ -15,4 +19,24 @@ const s3 = new S3Client({
|
||||
forcePathStyle: true // needed for MinIO
|
||||
});
|
||||
|
||||
/**
|
||||
* Returns the total storage used by a user (in bytes) by summing all S3 objects under their userId prefix.
|
||||
*/
|
||||
export async function getUserStorageUsage(userId: string): Promise<number> {
|
||||
const BUCKET = S3_BUCKET!;
|
||||
try {
|
||||
const result = await s3.send(new HeadObjectCommand({
|
||||
Bucket: BUCKET,
|
||||
Key: userId,
|
||||
}));
|
||||
return result.ContentLength || 0;
|
||||
} catch (err: any) {
|
||||
// If the file does not exist, return 0
|
||||
if (err.name === 'NotFound' || err.$metadata?.httpStatusCode === 404) {
|
||||
return 0;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export default s3;
|
||||
@@ -1,7 +1,8 @@
|
||||
<script lang="ts">
|
||||
import '../app.css';
|
||||
import { Home, FlaskConical } from '@lucide/svelte';
|
||||
import { Home, FlaskConical, Cog } from '@lucide/svelte';
|
||||
import { PUBLIC_APP_NAME } from '$env/static/public';
|
||||
import * as NavigationMenu from '$lib/components/ui/navigation-menu/index.js';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
@@ -17,8 +18,31 @@
|
||||
<span class="font-bold">{PUBLIC_APP_NAME || 'Cog Socket App'}</span>
|
||||
</a>
|
||||
<div class="flex flex-1 items-center justify-end space-x-2">
|
||||
<nav class="flex items-center">
|
||||
<nav class="flex items-center gap-2">
|
||||
<!-- Top bar right side elements, e.g., user menu, settings -->
|
||||
<NavigationMenu.Root viewport={false}>
|
||||
<NavigationMenu.List>
|
||||
<NavigationMenu.Item>
|
||||
<NavigationMenu.Trigger>
|
||||
<Cog class="size-5" />
|
||||
<span class="sr-only">Settings</span>
|
||||
</NavigationMenu.Trigger>
|
||||
<NavigationMenu.Content class="right-0 left-auto">
|
||||
<ul class="grid w-[200px] gap-2 p-2">
|
||||
<li>
|
||||
<NavigationMenu.Link href="/settings/profile">Profile</NavigationMenu.Link>
|
||||
</li>
|
||||
<li>
|
||||
<NavigationMenu.Link href="/settings">Settings</NavigationMenu.Link>
|
||||
</li>
|
||||
<li>
|
||||
<NavigationMenu.Link href="/settings/subscription">Subscription</NavigationMenu.Link>
|
||||
</li>
|
||||
</ul>
|
||||
</NavigationMenu.Content>
|
||||
</NavigationMenu.Item>
|
||||
</NavigationMenu.List>
|
||||
</NavigationMenu.Root>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -64,6 +64,8 @@ async function handleSubmit() {
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Description</TableHead>
|
||||
<TableHead>Type</TableHead>
|
||||
<TableHead>Multiplayer</TableHead>
|
||||
<TableHead>Created At</TableHead>
|
||||
<TableHead>Actions</TableHead>
|
||||
</TableRow>
|
||||
@@ -80,6 +82,8 @@ async function handleSubmit() {
|
||||
<a href={`/experiment/${exp.id}`} class="text-primary underline hover:text-primary/80">{exp.name}</a>
|
||||
</TableCell>
|
||||
<TableCell>{exp.description}</TableCell>
|
||||
<TableCell>{exp.type}</TableCell>
|
||||
<TableCell>{exp.multiplayer ? 'Yes' : 'No'}</TableCell>
|
||||
<TableCell>{new Date(exp.createdAt).toLocaleString()}</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
|
||||
@@ -14,9 +14,14 @@ const s3 = new S3Client({
|
||||
});
|
||||
const BUCKET = S3_BUCKET!;
|
||||
|
||||
export async function GET({ params }) {
|
||||
export async function GET({ params, locals, url }) {
|
||||
if (!locals.user) {
|
||||
return json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
const { id } = params;
|
||||
const prefix = `experiments/${id}/`;
|
||||
const userId = locals.user.id;
|
||||
const type = url?.searchParams?.get('type') || 'experiment_files';
|
||||
const prefix = `${userId}/${id}/${type}/`;
|
||||
const result = await s3.send(new ListObjectsV2Command({ Bucket: BUCKET, Prefix: prefix }));
|
||||
const files = (result.Contents || []).map(obj => ({
|
||||
key: obj.Key,
|
||||
@@ -27,15 +32,18 @@ export async function GET({ params }) {
|
||||
return json({ files });
|
||||
}
|
||||
|
||||
export async function POST({ params, request }) {
|
||||
console.log(params);
|
||||
export async function POST({ params, request, locals, url }) {
|
||||
if (!locals.user) {
|
||||
return json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
const { id } = params;
|
||||
const userId = locals.user.id;
|
||||
const type = url?.searchParams?.get('type') || 'experiment_files';
|
||||
const data = await request.formData();
|
||||
const file = data.get('file');
|
||||
const relativePath = data.get('relativePath');
|
||||
if (!file || typeof file === 'string') throw error(400, 'No file uploaded');
|
||||
const key = `experiments/${id}/${relativePath}`;
|
||||
console.log(key);
|
||||
const key = `${userId}/${id}/${type}/${relativePath}`;
|
||||
await s3.send(new PutObjectCommand({
|
||||
Bucket: BUCKET,
|
||||
Key: key,
|
||||
@@ -45,13 +53,18 @@ export async function POST({ params, request }) {
|
||||
return json({ success: true, key });
|
||||
}
|
||||
|
||||
export async function DELETE({ params, url }) {
|
||||
export async function DELETE({ params, url, locals }) {
|
||||
if (!locals.user) {
|
||||
return json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
const { id } = params;
|
||||
const userId = locals.user.id;
|
||||
const type = url?.searchParams?.get('type') || 'experiment_files';
|
||||
const key = url.searchParams.get('key');
|
||||
const prefix = url.searchParams.get('prefix');
|
||||
if (prefix) {
|
||||
// Delete all objects with this prefix
|
||||
const fullPrefix = `experiments/${id}/${prefix}`;
|
||||
const fullPrefix = `${userId}/${id}/${type}/${prefix}`;
|
||||
const result = await s3.send(new ListObjectsV2Command({ Bucket: BUCKET, Prefix: fullPrefix }));
|
||||
const objects = (result.Contents || []).map(obj => ({ Key: obj.Key }));
|
||||
if (objects.length > 0) {
|
||||
@@ -65,6 +78,7 @@ export async function DELETE({ params, url }) {
|
||||
return json({ success: true, deleted: objects.length });
|
||||
}
|
||||
if (!key) throw error(400, 'Missing key or prefix');
|
||||
await s3.send(new DeleteObjectCommand({ Bucket: BUCKET, Key: key }));
|
||||
const fullKey = `${userId}/${id}/${type}/${key}`;
|
||||
await s3.send(new DeleteObjectCommand({ Bucket: BUCKET, Key: fullKey }));
|
||||
return json({ success: true });
|
||||
}
|
||||
40
src/routes/api/experiment/[id]/sessions/+server.ts
Normal file
40
src/routes/api/experiment/[id]/sessions/+server.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { db } from '$lib/server/db';
|
||||
import { experimentSession } from '$lib/server/db/schema';
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { and, eq, desc, sql } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
|
||||
const searchParamsSchema = z.object({
|
||||
page: z.coerce.number().int().positive().default(1),
|
||||
limit: z.coerce.number().int().positive().default(10)
|
||||
});
|
||||
|
||||
export async function GET({ params, url }) {
|
||||
const { id } = params;
|
||||
const searchParams = searchParamsSchema.parse(Object.fromEntries(url.searchParams));
|
||||
|
||||
try {
|
||||
const sessions = await db
|
||||
.select()
|
||||
.from(experimentSession)
|
||||
.where(eq(experimentSession.experimentId, id))
|
||||
.orderBy(desc(experimentSession.createdAt))
|
||||
.limit(searchParams.limit)
|
||||
.offset((searchParams.page - 1) * searchParams.limit);
|
||||
|
||||
const total = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(experimentSession)
|
||||
.where(eq(experimentSession.experimentId, id));
|
||||
|
||||
return json({
|
||||
sessions,
|
||||
total: total[0].count,
|
||||
page: searchParams.page,
|
||||
limit: searchParams.limit
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return json({ error: 'Something went wrong' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
32
src/routes/api/experiments/[experimentId]/+server.ts
Normal file
32
src/routes/api/experiments/[experimentId]/+server.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { db } from '$lib/server/db/index.js';
|
||||
import * as schema from '$lib/server/db/schema.js';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { error } from '@sveltejs/kit';
|
||||
|
||||
export async function GET({ params }) {
|
||||
const { experimentId } = params;
|
||||
|
||||
try {
|
||||
const experiment = await db.query.experiment.findFirst({
|
||||
where: eq(schema.experiment.id, experimentId)
|
||||
});
|
||||
|
||||
if (!experiment) {
|
||||
throw error(404, 'Experiment not found');
|
||||
}
|
||||
|
||||
return json({
|
||||
id: experiment.id,
|
||||
name: experiment.name,
|
||||
description: experiment.description,
|
||||
multiplayer: experiment.multiplayer,
|
||||
maxParticipants: experiment.maxParticipants,
|
||||
type: experiment.type,
|
||||
createdAt: experiment.createdAt
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Error fetching experiment:', err);
|
||||
throw error(500, 'Internal server error');
|
||||
}
|
||||
}
|
||||
10
src/routes/api/user-storage.ts
Normal file
10
src/routes/api/user-storage.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { json, type RequestHandler } from '@sveltejs/kit';
|
||||
import { getUserStorageUsage } from '$lib/server/s3';
|
||||
|
||||
export const GET: RequestHandler = async ({ locals }) => {
|
||||
if (!locals.user) {
|
||||
return json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
const usage = await getUserStorageUsage(locals.user.id);
|
||||
return json({ usage });
|
||||
};
|
||||
@@ -7,7 +7,8 @@ import { Label } from '$lib/components/ui/label';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
|
||||
import { goto } from '$app/navigation';
|
||||
import { get } from 'svelte/store';
|
||||
import FileBrowser from './FileBrowser.svelte';
|
||||
import FileManager from './FileManager.svelte';
|
||||
import ExperimentSessions from './ExperimentSessions.svelte';
|
||||
|
||||
let experimentId = '';
|
||||
let experiment: any = null;
|
||||
@@ -15,234 +16,117 @@ let name = '';
|
||||
let description = '';
|
||||
let error = '';
|
||||
let success = '';
|
||||
let files: any[] = [];
|
||||
let uploading = false;
|
||||
let uploadError = '';
|
||||
let copied = false;
|
||||
let origin = '';
|
||||
let activeTab = 'info';
|
||||
|
||||
async function fetchFiles() {
|
||||
if (!experimentId) return;
|
||||
console.log('fetchFiles called for experimentId:', experimentId);
|
||||
const res = await fetch(`/api/experiment/${experimentId}/files`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
console.log('fetchFiles response:', data);
|
||||
files = data.files;
|
||||
} else {
|
||||
console.log('fetchFiles failed:', res.status);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFileInput(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
if (input.files) await uploadFiles(input.files);
|
||||
}
|
||||
|
||||
async function uploadFiles(fileList: FileList) {
|
||||
uploading = true;
|
||||
uploadError = '';
|
||||
try {
|
||||
for (const file of Array.from(fileList)) {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
// Use webkitRelativePath if present, else fallback to file.name
|
||||
form.append('relativePath', file.webkitRelativePath || file.name);
|
||||
const res = await fetch(`/api/experiment/${experimentId}/files`, {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.error || 'Upload failed');
|
||||
}
|
||||
}
|
||||
await fetchFiles();
|
||||
} catch (e: any) {
|
||||
uploadError = e.message || 'Upload failed';
|
||||
} finally {
|
||||
uploading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteFile(key: string) {
|
||||
const url = `/api/experiment/${experimentId}/files?key=${encodeURIComponent(key)}`;
|
||||
const res = await fetch(url, { method: 'DELETE' });
|
||||
if (res.ok) await fetchFiles();
|
||||
}
|
||||
|
||||
// Helper: Convert flat file list to tree
|
||||
function buildFileTree(files: any[]): any {
|
||||
const root: any = {};
|
||||
for (const file of files) {
|
||||
const parts = file.name.split('/');
|
||||
let node = root;
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const part = parts[i];
|
||||
if (!part) continue;
|
||||
if (i === parts.length - 1) {
|
||||
// File
|
||||
node[part] = { ...file, isFile: true };
|
||||
} else {
|
||||
// Folder
|
||||
node[part] = node[part] || { children: {}, isFile: false };
|
||||
node = node[part].children;
|
||||
}
|
||||
}
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
let expanded = new Set<string>();
|
||||
function toggleFolder(path: string) {
|
||||
if (expanded.has(path)) expanded.delete(path);
|
||||
else expanded.add(path);
|
||||
expanded = new Set(expanded); // trigger reactivity
|
||||
}
|
||||
$: publicLink = experiment
|
||||
? `${origin}/public/${experiment.multiplayer ? 'multiplayer/run' : 'run'}/${experiment.id}`
|
||||
: '';
|
||||
|
||||
function copyLink() {
|
||||
const link = `${origin}/public/run/${experiment.id}`;
|
||||
navigator.clipboard.writeText(link);
|
||||
copied = true;
|
||||
setTimeout(() => (copied = false), 2000);
|
||||
}
|
||||
|
||||
async function deleteFileOrFolder(key: string, isFolder: boolean) {
|
||||
console.log('deleteFileOrFolder called:', { key, isFolder, experimentId });
|
||||
// If folder, send a delete request with a prefix param
|
||||
if (isFolder) {
|
||||
const url = `/api/experiment/${experimentId}/files?prefix=${encodeURIComponent(key)}`;
|
||||
console.log('Deleting folder with URL:', url);
|
||||
const res = await fetch(url, { method: 'DELETE' });
|
||||
console.log('Folder delete response:', res.status, res.ok);
|
||||
if (res.ok) await fetchFiles();
|
||||
} else {
|
||||
console.log('Deleting file with key:', key);
|
||||
await deleteFile(key);
|
||||
}
|
||||
navigator.clipboard.writeText(publicLink);
|
||||
copied = true;
|
||||
setTimeout(() => (copied = false), 2000);
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
origin = window.location.origin;
|
||||
const params = get(page).params;
|
||||
experimentId = params.id;
|
||||
const res = await fetch(`/api/experiment?id=${experimentId}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
experiment = data.experiment;
|
||||
console.log(experiment)
|
||||
name = experiment.name;
|
||||
description = experiment.description;
|
||||
} else {
|
||||
error = 'Failed to load experiment.';
|
||||
}
|
||||
await fetchFiles();
|
||||
origin = window.location.origin;
|
||||
const params = get(page).params;
|
||||
experimentId = params.id;
|
||||
const res = await fetch(`/api/experiment?id=${experimentId}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
experiment = data.experiment;
|
||||
console.log(experiment)
|
||||
name = experiment.name;
|
||||
description = experiment.description;
|
||||
} else {
|
||||
error = 'Failed to load experiment.';
|
||||
}
|
||||
});
|
||||
|
||||
async function handleSave() {
|
||||
error = '';
|
||||
success = '';
|
||||
const res = await fetch(`/api/experiment?id=${experimentId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, description })
|
||||
});
|
||||
if (res.ok) {
|
||||
success = 'Experiment updated!';
|
||||
} else {
|
||||
error = 'Failed to update experiment.';
|
||||
}
|
||||
error = '';
|
||||
success = '';
|
||||
const res = await fetch(`/api/experiment?id=${experimentId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, description })
|
||||
});
|
||||
if (res.ok) {
|
||||
success = 'Experiment updated!';
|
||||
} else {
|
||||
error = 'Failed to update experiment.';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if experiment}
|
||||
<div class="flex flex-col min-h-screen bg-background py-8">
|
||||
<Card class="mb-8 w-full max-w-xl mx-auto">
|
||||
<CardHeader>
|
||||
<CardTitle>Experiment Details</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="mb-4">
|
||||
<Label>ID</Label>
|
||||
<div class="text-sm text-muted-foreground mb-2">{experiment.id}</div>
|
||||
</div>
|
||||
<form on:submit|preventDefault={handleSave} class="space-y-4">
|
||||
<div>
|
||||
<Label for="name">Name</Label>
|
||||
<Input id="name" type="text" bind:value={name} required />
|
||||
</div>
|
||||
<div>
|
||||
<Label for="description">Description</Label>
|
||||
<Input id="description" type="text" bind:value={description} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>Created At</Label>
|
||||
<div class="text-sm text-muted-foreground mb-2">{new Date(experiment.createdAt).toLocaleString()}</div>
|
||||
</div>
|
||||
{#if error}
|
||||
<div class="text-red-500 text-sm">{error}</div>
|
||||
{/if}
|
||||
{#if success}
|
||||
<div class="text-green-600 text-sm">{success}</div>
|
||||
{/if}
|
||||
<Button type="submit">Save Changes</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card class="mb-8 w-full max-w-xl mx-auto">
|
||||
<CardHeader>
|
||||
<CardTitle>Public Link</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p class="text-sm text-muted-foreground mb-2">
|
||||
Share this link with your participants to run the experiment.
|
||||
</p>
|
||||
<div class="flex items-center space-x-2">
|
||||
<Input id="public-link" type="text" readonly value="{`${origin}/public/run/${experiment.id}`}" />
|
||||
<Button on:click={copyLink}>Copy</Button>
|
||||
</div>
|
||||
{#if copied}
|
||||
<p class="text-green-600 text-sm mt-2">Copied to clipboard!</p>
|
||||
{/if}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card class="w-full max-w-xl mx-auto">
|
||||
<CardHeader>
|
||||
<CardTitle>Experiment Files</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="mt-8">
|
||||
<Label>Experiment Files</Label>
|
||||
<div
|
||||
class="border-2 border-dashed rounded-md p-4 mb-4 text-center cursor-pointer bg-muted hover:bg-accent transition"
|
||||
on:click={() => document.getElementById('file-input')?.click()}
|
||||
>
|
||||
<div>Click to select files or a folder to upload (folder structure will be preserved)</div>
|
||||
<input id="file-input" type="file" multiple webkitdirectory class="hidden" on:change={handleFileInput} />
|
||||
</div>
|
||||
{#if uploading}
|
||||
<div class="text-blue-600 text-sm mb-2">Uploading...</div>
|
||||
{/if}
|
||||
{#if uploadError}
|
||||
<div class="text-red-500 text-sm mb-2">{uploadError}</div>
|
||||
{/if}
|
||||
<ul class="divide-y divide-border">
|
||||
<FileBrowser
|
||||
tree={buildFileTree(files)}
|
||||
parentPath=""
|
||||
{expanded}
|
||||
onToggle={toggleFolder}
|
||||
onDelete={deleteFileOrFolder}
|
||||
/>
|
||||
{#if files.length === 0}
|
||||
<li class="text-muted-foreground text-sm py-2">No files uploaded yet.</li>
|
||||
{/if}
|
||||
</ul>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div class="w-full max-w-xl mx-auto mb-8">
|
||||
<div class="flex border-b mb-4">
|
||||
<button class="px-4 py-2 -mb-px border-b-2 font-medium focus:outline-none transition-colors duration-200" class:!border-primary={activeTab === 'info'} class:border-transparent={activeTab !== 'info'} on:click={() => activeTab = 'info'}>Info</button>
|
||||
<button class="px-4 py-2 -mb-px border-b-2 font-medium focus:outline-none transition-colors duration-200" class:!border-primary={activeTab === 'files'} class:border-transparent={activeTab !== 'files'} on:click={() => activeTab = 'files'}>Files</button>
|
||||
<button class="px-4 py-2 -mb-px border-b-2 font-medium focus:outline-none transition-colors duration-200" class:!border-primary={activeTab === 'sessions'} class:border-transparent={activeTab !== 'sessions'} on:click={() => activeTab = 'sessions'}>Sessions</button>
|
||||
</div>
|
||||
{#if activeTab === 'info'}
|
||||
<Card class="w-full">
|
||||
<CardHeader>
|
||||
<CardTitle>Experiment Details</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="mb-4">
|
||||
<Label>ID</Label>
|
||||
<div class="text-sm text-muted-foreground mb-2">{experiment.id}</div>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<Label>Type</Label>
|
||||
<div class="text-sm text-muted-foreground mb-2">{experiment.type}</div>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<Label>Multiplayer</Label>
|
||||
<div class="text-sm text-muted-foreground mb-2">{experiment.multiplayer ? 'Yes' : 'No'}</div>
|
||||
</div>
|
||||
<form on:submit|preventDefault={handleSave} class="space-y-4">
|
||||
<div>
|
||||
<Label for="name">Name</Label>
|
||||
<Input id="name" type="text" bind:value={name} required />
|
||||
</div>
|
||||
<div>
|
||||
<Label for="description">Description</Label>
|
||||
<Input id="description" type="text" bind:value={description} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>Created At</Label>
|
||||
<div class="text-sm text-muted-foreground mb-2">{new Date(experiment.createdAt).toLocaleString()}</div>
|
||||
</div>
|
||||
{#if error}
|
||||
<div class="text-red-500 text-sm">{error}</div>
|
||||
{/if}
|
||||
{#if success}
|
||||
<div class="text-green-600 text-sm">{success}</div>
|
||||
{/if}
|
||||
<Button type="submit">Save Changes</Button>
|
||||
</form>
|
||||
<div class="mt-8">
|
||||
<Label>Public Link</Label>
|
||||
<div class="flex items-center space-x-2 mt-2">
|
||||
<Input id="public-link" type="text" readonly value={publicLink} />
|
||||
<Button on:click={copyLink}>Copy</Button>
|
||||
</div>
|
||||
{#if copied}
|
||||
<p class="text-green-600 text-sm mt-2">Copied to clipboard!</p>
|
||||
{/if}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{:else if activeTab === 'files'}
|
||||
<FileManager {experimentId} />
|
||||
{:else if activeTab === 'sessions'}
|
||||
<ExperimentSessions {experimentId} />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="flex justify-center items-center min-h-screen bg-background">
|
||||
|
||||
92
src/routes/experiment/[id]/ExperimentSessions.svelte
Normal file
92
src/routes/experiment/[id]/ExperimentSessions.svelte
Normal file
@@ -0,0 +1,92 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '$lib/components/ui/table';
|
||||
|
||||
export let experimentId: string;
|
||||
|
||||
let sessions: any[] = [];
|
||||
let page = 1;
|
||||
let limit = 10;
|
||||
let total = 0;
|
||||
let loading = false;
|
||||
|
||||
async function fetchSessions() {
|
||||
loading = true;
|
||||
const res = await fetch(
|
||||
`/api/experiment/${experimentId}/sessions?page=${page}&limit=${limit}`
|
||||
);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
sessions = data.sessions;
|
||||
total = data.total;
|
||||
}
|
||||
loading = false;
|
||||
}
|
||||
|
||||
function nextPage() {
|
||||
if (page * limit < total) {
|
||||
page++;
|
||||
fetchSessions();
|
||||
}
|
||||
}
|
||||
|
||||
function prevPage() {
|
||||
if (page > 1) {
|
||||
page--;
|
||||
fetchSessions();
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (experimentId) {
|
||||
fetchSessions();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="space-y-4">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Session ID</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Created At</TableHead>
|
||||
<TableHead>Updated At</TableHead>
|
||||
<TableHead>External ID</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{#if loading}
|
||||
<TableRow>
|
||||
<TableCell colspan={5} class="text-center">Loading...</TableCell>
|
||||
</TableRow>
|
||||
{:else if sessions.length === 0}
|
||||
<TableRow>
|
||||
<TableCell colspan={5} class="text-center">No sessions found.</TableCell>
|
||||
</TableRow>
|
||||
{:else}
|
||||
{#each sessions as session}
|
||||
<TableRow>
|
||||
<TableCell>{session.id}</TableCell>
|
||||
<TableCell>{session.status}</TableCell>
|
||||
<TableCell>{new Date(session.createdAt).toLocaleString()}</TableCell>
|
||||
<TableCell>{new Date(session.updatedAt).toLocaleString()}</TableCell>
|
||||
<TableCell>{session.externalId || 'N/A'}</TableCell>
|
||||
</TableRow>
|
||||
{/each}
|
||||
{/if}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<div class="flex justify-between items-center">
|
||||
<div>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Showing {Math.min((page - 1) * limit + 1, total)} to {Math.min(page * limit, total)} of {total} sessions
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex space-x-2">
|
||||
<Button on:click={prevPage} disabled={page === 1}>Previous</Button>
|
||||
<Button on:click={nextPage} disabled={page * limit >= total}>Next</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,45 +1,91 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import FileIcon from '@lucide/svelte/icons/file';
|
||||
import FolderIcon from '@lucide/svelte/icons/folder';
|
||||
import FolderOpenIcon from '@lucide/svelte/icons/folder-open';
|
||||
import TrashIcon from '@lucide/svelte/icons/trash';
|
||||
import DownloadIcon from '@lucide/svelte/icons/download';
|
||||
|
||||
export let tree: any;
|
||||
export let parentPath = '';
|
||||
export let expanded: Set<string>;
|
||||
export let onToggle: (path: string) => void;
|
||||
export let onDelete: (key: string, isFolder: boolean) => void;
|
||||
export let onDownload: (key: string) => void = () => {};
|
||||
export let isRoot: boolean = false;
|
||||
|
||||
function handleToggle(path: string) {
|
||||
onToggle(path);
|
||||
}
|
||||
function handleDelete(key: string, isFolder: boolean) {
|
||||
console.log('FileBrowser handleDelete called:', { key, isFolder });
|
||||
onDelete(key, isFolder);
|
||||
}
|
||||
|
||||
function handleDownload(key: string) {
|
||||
onDownload(key);
|
||||
}
|
||||
function handleFolderClick(event: Event, path: string) {
|
||||
event.stopPropagation();
|
||||
handleToggle(path);
|
||||
}
|
||||
|
||||
function handleFolderDelete(event: Event, path: string) {
|
||||
event.stopPropagation();
|
||||
console.log('FileBrowser handleFolderDelete called:', { path });
|
||||
handleDelete(path, true);
|
||||
}
|
||||
function formatSize(size: number) {
|
||||
if (!size && size !== 0) return '';
|
||||
if (size < 1024) return `${size} B`;
|
||||
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
|
||||
if (size < 1024 * 1024 * 1024) return `${(size / 1024 / 1024).toFixed(1)} MB`;
|
||||
return `${(size / 1024 / 1024 / 1024).toFixed(1)} GB`;
|
||||
}
|
||||
function formatDate(date: string | Date) {
|
||||
if (!date) return '';
|
||||
const d = typeof date === 'string' ? new Date(date) : date;
|
||||
return d.toLocaleString();
|
||||
}
|
||||
</script>
|
||||
|
||||
<ul class="ml-2">
|
||||
{#if isRoot}
|
||||
<li class="flex items-center gap-2 py-1 pl-2 text-xs font-semibold text-muted-foreground select-none border-b border-border mb-1">
|
||||
<span class="flex-1 min-w-0">Name</span>
|
||||
<span class="w-24 text-right">Size</span>
|
||||
<span class="w-40 text-right">Last Uploaded</span>
|
||||
<span class="w-16"></span>
|
||||
</li>
|
||||
{/if}
|
||||
{#each Object.entries(tree) as [name, node] (parentPath + '/' + name)}
|
||||
{#if (node as any).isFile}
|
||||
<li class="flex items-center justify-between py-1 pl-4">
|
||||
<span>{name} <span class="text-xs text-muted-foreground">({(node as any).size} bytes)</span></span>
|
||||
<Button size="sm" variant="destructive" on:click={() => handleDelete((node as any).key, false)}>Delete</Button>
|
||||
<li class="flex items-center gap-2 py-1 pl-2 hover:bg-accent rounded group text-sm">
|
||||
<span class="flex-1 min-w-0 flex items-center gap-1">
|
||||
<FileIcon class="w-4 h-4 text-muted-foreground shrink-0" />
|
||||
<span class="truncate" title={name || ''}>{name || ''}</span>
|
||||
</span>
|
||||
<span class="w-24 text-xs text-muted-foreground text-right ml-2 whitespace-nowrap">{formatSize((node as any).size)}</span>
|
||||
<span class="w-40 text-xs text-muted-foreground text-right ml-2 whitespace-nowrap">{formatDate((node as any).lastModified)}</span>
|
||||
<span class="w-16 flex items-center justify-end gap-1">
|
||||
<button class="p-1 hover:text-primary" title="Download" on:click={() => handleDownload((node as any).key)}><DownloadIcon class="w-4 h-4" /></button>
|
||||
<button class="p-1 hover:text-destructive" title="Delete" on:click={() => handleDelete((node as any).key, false)}><TrashIcon class="w-4 h-4" /></button>
|
||||
</span>
|
||||
</li>
|
||||
{:else}
|
||||
<li>
|
||||
<div class="flex items-center cursor-pointer" on:click={(e) => handleFolderClick(e, parentPath ? `${parentPath}/${name}` : name)}>
|
||||
<span class="mr-1">{expanded.has(parentPath ? `${parentPath}/${name}` : name) ? '▼' : '▶'}</span>
|
||||
<span class="font-semibold">{name}</span>
|
||||
<Button size="sm" variant="destructive" class="ml-2" on:click={(e) => handleFolderDelete(e, (parentPath ? `${parentPath}/${name}` : name))}>Delete</Button>
|
||||
<div class="flex items-center gap-1 px-1 py-1 hover:bg-accent rounded cursor-pointer text-sm" >
|
||||
<span class="flex-1 min-w-0 flex items-center gap-1" on:click={(e) => handleFolderClick(e, parentPath ? `${parentPath}/${name}` : name)}>
|
||||
<span class="flex-shrink-0 w-5 h-5 flex items-center justify-center">
|
||||
{#if expanded.has(parentPath ? `${parentPath}/${name}` : name)}
|
||||
<FolderOpenIcon class="w-4 h-4 text-muted-foreground" />
|
||||
{:else}
|
||||
<FolderIcon class="w-4 h-4 text-muted-foreground" />
|
||||
{/if}
|
||||
</span>
|
||||
<span class="font-semibold truncate" title={name || ''}>{name || ''}</span>
|
||||
</span>
|
||||
<span class="w-24"></span>
|
||||
<span class="w-40"></span>
|
||||
<span class="w-16 flex items-center justify-end">
|
||||
<button class="p-1 hover:text-destructive" title="Delete folder" on:click={(e) => handleFolderDelete(e, (parentPath ? `${parentPath}/${name}` : name))}><TrashIcon class="w-4 h-4" /></button>
|
||||
</span>
|
||||
</div>
|
||||
{#if expanded.has(parentPath ? `${parentPath}/${name}` : name)}
|
||||
<svelte:self
|
||||
@@ -48,6 +94,8 @@
|
||||
{expanded}
|
||||
{onToggle}
|
||||
{onDelete}
|
||||
{onDownload}
|
||||
isRoot={false}
|
||||
/>
|
||||
{/if}
|
||||
</li>
|
||||
|
||||
193
src/routes/experiment/[id]/FileManager.svelte
Normal file
193
src/routes/experiment/[id]/FileManager.svelte
Normal file
@@ -0,0 +1,193 @@
|
||||
<script lang="ts">
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { onMount } from 'svelte';
|
||||
import FileBrowser from './FileBrowser.svelte';
|
||||
export let experimentId: string;
|
||||
|
||||
let files: any[] = [];
|
||||
let uploading = false;
|
||||
let uploadError = '';
|
||||
|
||||
async function fetchFiles() {
|
||||
if (!experimentId) return;
|
||||
const res = await fetch(`/api/experiment/${experimentId}/files`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
files = data.files;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFileInput(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
if (input.files) await uploadFiles(input.files);
|
||||
}
|
||||
|
||||
async function uploadFiles(fileList: FileList) {
|
||||
uploading = true;
|
||||
uploadError = '';
|
||||
try {
|
||||
for (const file of Array.from(fileList)) {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
form.append('relativePath', file.webkitRelativePath || file.name);
|
||||
const res = await fetch(`/api/experiment/${experimentId}/files`, {
|
||||
method: 'POST',
|
||||
body: form
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.error || 'Upload failed');
|
||||
}
|
||||
}
|
||||
await fetchFiles();
|
||||
} catch (e: any) {
|
||||
uploadError = e.message || 'Upload failed';
|
||||
} finally {
|
||||
uploading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteFile(key: string) {
|
||||
let relativeKey = key;
|
||||
const expFilesIdx = key.indexOf('/experiment_files/');
|
||||
if (expFilesIdx !== -1) {
|
||||
relativeKey = key.substring(expFilesIdx + '/experiment_files/'.length);
|
||||
} else {
|
||||
const expIdIdx = key.indexOf(experimentId + '/');
|
||||
if (expIdIdx !== -1) {
|
||||
relativeKey = key.substring(expIdIdx + experimentId.length + 1);
|
||||
}
|
||||
}
|
||||
const url = `/api/experiment/${experimentId}/files?key=${encodeURIComponent(relativeKey)}`;
|
||||
const res = await fetch(url, { method: 'DELETE' });
|
||||
if (res.ok) await fetchFiles();
|
||||
}
|
||||
|
||||
function buildFileTree(files: any[]): any {
|
||||
const root: any = {};
|
||||
for (const file of files) {
|
||||
const parts = file.name.split('/');
|
||||
let node = root;
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const part = parts[i];
|
||||
if (!part) continue;
|
||||
if (i === parts.length - 1) {
|
||||
node[part] = { ...file, isFile: true };
|
||||
} else {
|
||||
node[part] = node[part] || { children: {}, isFile: false };
|
||||
node = node[part].children;
|
||||
}
|
||||
}
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
function downloadFile(key: string) {
|
||||
const url = `/api/experiment/${experimentId}/files?key=${encodeURIComponent(key)}&download=1`;
|
||||
fetch(url)
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error('Failed to download');
|
||||
const disposition = res.headers.get('Content-Disposition');
|
||||
let filename = key.split('/').pop() || 'file';
|
||||
if (disposition) {
|
||||
const match = disposition.match(/filename="?([^";]+)"?/);
|
||||
if (match) filename = match[1];
|
||||
}
|
||||
return res.blob().then((blob) => ({ blob, filename }));
|
||||
})
|
||||
.then(({ blob, filename }) => {
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
setTimeout(() => {
|
||||
window.URL.revokeObjectURL(url);
|
||||
a.remove();
|
||||
}, 100);
|
||||
})
|
||||
.catch(() => alert('Failed to download file.'));
|
||||
}
|
||||
|
||||
let expanded = new Set<string>();
|
||||
function toggleFolder(path: string) {
|
||||
if (expanded.has(path)) expanded.delete(path);
|
||||
else expanded.add(path);
|
||||
expanded = new Set(expanded);
|
||||
}
|
||||
|
||||
async function deleteFileOrFolder(key: string, isFolder: boolean) {
|
||||
if (isFolder) {
|
||||
let relativePrefix = key;
|
||||
const expFilesIdx = key.indexOf('/experiment_files/');
|
||||
if (expFilesIdx !== -1) {
|
||||
relativePrefix = key.substring(expFilesIdx + '/experiment_files/'.length);
|
||||
} else {
|
||||
const expIdIdx = key.indexOf(experimentId + '/');
|
||||
if (expIdIdx !== -1) {
|
||||
relativePrefix = key.substring(expIdIdx + experimentId.length + 1);
|
||||
}
|
||||
}
|
||||
const url = `/api/experiment/${experimentId}/files?prefix=${encodeURIComponent(
|
||||
relativePrefix
|
||||
)}`;
|
||||
const res = await fetch(url, { method: 'DELETE' });
|
||||
if (res.ok) await fetchFiles();
|
||||
} else {
|
||||
await deleteFile(key);
|
||||
}
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
await fetchFiles();
|
||||
});
|
||||
</script>
|
||||
|
||||
<Card class="w-full">
|
||||
<CardHeader>
|
||||
<CardTitle>Experiment Files</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div class="mt-8">
|
||||
<div class="flex justify-end mb-4">
|
||||
<Button
|
||||
type="button"
|
||||
class="px-4 py-2 rounded bg-primary text-primary-foreground hover:bg-primary/90 transition font-medium"
|
||||
on:click={() => document.getElementById('file-input')?.click()}
|
||||
>
|
||||
Upload Files or Folder
|
||||
</Button>
|
||||
<input
|
||||
id="file-input"
|
||||
type="file"
|
||||
multiple
|
||||
webkitdirectory
|
||||
class="hidden"
|
||||
on:change={handleFileInput}
|
||||
/>
|
||||
</div>
|
||||
{#if uploading}
|
||||
<div class="text-blue-600 text-sm mb-2">Uploading...</div>
|
||||
{/if}
|
||||
{#if uploadError}
|
||||
<div class="text-red-500 text-sm mb-2">{uploadError}</div>
|
||||
{/if}
|
||||
<ul class="divide-y divide-border">
|
||||
<FileBrowser
|
||||
tree={buildFileTree(files)}
|
||||
parentPath=""
|
||||
{expanded}
|
||||
onToggle={toggleFolder}
|
||||
onDelete={deleteFileOrFolder}
|
||||
onDownload={downloadFile}
|
||||
isRoot={true}
|
||||
/>
|
||||
{#if files.length === 0}
|
||||
<li class="text-muted-foreground text-sm py-2">No files uploaded yet.</li>
|
||||
{/if}
|
||||
</ul>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
19
src/routes/public/multiplayer/+layout.svelte
Normal file
19
src/routes/public/multiplayer/+layout.svelte
Normal file
@@ -0,0 +1,19 @@
|
||||
<!-- This layout bypasses the main app layout for multiplayer experiments -->
|
||||
<slot />
|
||||
|
||||
<style>
|
||||
/* Ensure multiplayer experiments are completely full-screen */
|
||||
:global(html), :global(body) {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
overflow: auto;
|
||||
background: white;
|
||||
}
|
||||
|
||||
/* Reset any inherited styles */
|
||||
:global(*) {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
</style>
|
||||
652
src/routes/public/multiplayer/run/[experimentId]/+server.ts
Normal file
652
src/routes/public/multiplayer/run/[experimentId]/+server.ts
Normal file
@@ -0,0 +1,652 @@
|
||||
import { db } from '$lib/server/db/index.js';
|
||||
import * as schema from '$lib/server/db/schema.js';
|
||||
import s3 from '$lib/server/s3.js';
|
||||
import { GetObjectCommand, ListObjectsV2Command } from '@aws-sdk/client-s3';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { error } from '@sveltejs/kit';
|
||||
import mime from 'mime-types';
|
||||
import { S3_BUCKET } from '$env/static/private';
|
||||
import { dev } from '$app/environment';
|
||||
import { promises as fs } from 'fs';
|
||||
|
||||
const EXPERIMENT_SESSION_COOKIE_PREFIX = 'experiment-session-';
|
||||
|
||||
// Rules for proxying vendor files to a CDN using regex to extract versions
|
||||
const vendorFileRules = [
|
||||
{
|
||||
regex: /^lib\/vendors\/jquery-([\d.]+)\.min\.js$/,
|
||||
url: (version: string) => `https://code.jquery.com/jquery-${version}.min.js`
|
||||
},
|
||||
{
|
||||
regex: /^lib\/vendors\/surveyjs\.jquery-([\d.]+)\.min\.js$/,
|
||||
url: (version: string) => `https://unpkg.com/survey-jquery@${version}/survey.jquery.min.js`
|
||||
},
|
||||
{
|
||||
regex: /^lib\/vendors\/surveyjs\.defaultV2-([\d.]+)-OST\.min\.css$/,
|
||||
url: (version: string) => `https://unpkg.com/survey-core@${version}/defaultV2.min.css`
|
||||
}
|
||||
];
|
||||
|
||||
export async function GET({ params, cookies, getClientAddress, request }) {
|
||||
const { experimentId } = params;
|
||||
|
||||
// Check if the experiment exists
|
||||
const experiment = await db.query.experiment.findFirst({
|
||||
where: eq(schema.experiment.id, experimentId)
|
||||
});
|
||||
|
||||
if (!experiment) {
|
||||
throw error(404, 'Experiment not found');
|
||||
}
|
||||
|
||||
// For multiplayer, we always serve index.html with WebSocket integration
|
||||
const path = 'index.html';
|
||||
|
||||
const cookieName = `${EXPERIMENT_SESSION_COOKIE_PREFIX}${experimentId}`;
|
||||
let experimentSessionId = cookies.get(cookieName);
|
||||
|
||||
if (!experimentSessionId) {
|
||||
// First request for this experiment. Create a new participant session.
|
||||
experimentSessionId = randomUUID();
|
||||
const now = new Date();
|
||||
|
||||
await db.insert(schema.experimentSession).values({
|
||||
id: experimentSessionId,
|
||||
experimentId,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
status: 'in progress',
|
||||
ipAddress: getClientAddress(),
|
||||
userAgent: request.headers.get('user-agent') ?? undefined
|
||||
});
|
||||
|
||||
cookies.set(cookieName, experimentSessionId, {
|
||||
path: `/public/multiplayer/run/${experimentId}`,
|
||||
httpOnly: true,
|
||||
secure: !dev,
|
||||
maxAge: 60 * 60 * 24 * 365 // 1 year
|
||||
});
|
||||
} else {
|
||||
// subsequent requests, check if cookie is valid
|
||||
const [session] = await db
|
||||
.select()
|
||||
.from(schema.experimentSession)
|
||||
.where(eq(schema.experimentSession.id, experimentSessionId));
|
||||
if (!session) {
|
||||
// invalid cookie, create new session
|
||||
const newExperimentSessionId = randomUUID();
|
||||
const now = new Date();
|
||||
await db.insert(schema.experimentSession).values({
|
||||
id: newExperimentSessionId,
|
||||
experimentId,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
status: 'in progress',
|
||||
ipAddress: getClientAddress(),
|
||||
userAgent: request.headers.get('user-agent') ?? undefined
|
||||
});
|
||||
cookies.set(cookieName, newExperimentSessionId, {
|
||||
path: `/public/multiplayer/run/${experimentId}`,
|
||||
httpOnly: true,
|
||||
secure: !dev,
|
||||
maxAge: 60 * 60 * 24 * 365 // 1 year
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Files are stored in the user's experiment_files directory
|
||||
const createdBy = experiment.createdBy;
|
||||
const s3Prefix = `${createdBy}/${experimentId}/experiment_files/`;
|
||||
const key = `${s3Prefix}${path}`;
|
||||
|
||||
try {
|
||||
const command = new GetObjectCommand({
|
||||
Bucket: S3_BUCKET,
|
||||
Key: key
|
||||
});
|
||||
const s3Response = await s3.send(command);
|
||||
|
||||
if (!s3Response.Body) {
|
||||
throw error(404, 'Not found');
|
||||
}
|
||||
|
||||
const stream = s3Response.Body;
|
||||
const body = await stream.transformToString();
|
||||
|
||||
// Inject multiplayer WebSocket integration into the HTML
|
||||
const multiplayerScript = `
|
||||
<script src="https://unpkg.com/colyseus.js@^0.16.0/dist/colyseus.js"></script>
|
||||
<script>
|
||||
// Multiplayer Colyseus Integration
|
||||
let client = null;
|
||||
let room = null;
|
||||
let isConnected = false;
|
||||
let overlayVisible = true;
|
||||
let sessionStartTime = new Date();
|
||||
let isLoading = true;
|
||||
let participantId = null;
|
||||
let roomStatus = 'connecting';
|
||||
let queuePosition = 0;
|
||||
let currentParticipants = 0;
|
||||
let maxParticipants = 1;
|
||||
|
||||
// Connect to Colyseus server
|
||||
async function connectMultiplayer() {
|
||||
try {
|
||||
const protocol = location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
const host = location.hostname;
|
||||
const port = '8080'; // Colyseus server port
|
||||
|
||||
client = new Colyseus.Client(\`\${protocol}://\${host}:\${port}\`);
|
||||
|
||||
// Join or create room for this experiment
|
||||
room = await client.joinOrCreate('experiment_room', {
|
||||
experimentId: '${experimentId}',
|
||||
userAgent: navigator.userAgent,
|
||||
ipAddress: null // Will be set by server
|
||||
});
|
||||
|
||||
isConnected = true;
|
||||
roomStatus = 'connected';
|
||||
updateOverlay();
|
||||
|
||||
console.log('Connected to Colyseus room:', room.roomId);
|
||||
|
||||
// Handle room state changes
|
||||
room.onStateChange((state) => {
|
||||
console.log('Room state updated:', state);
|
||||
handleStateUpdate(state);
|
||||
});
|
||||
|
||||
// Handle messages from server
|
||||
room.onMessage('queued', (message) => {
|
||||
handleQueued(message);
|
||||
});
|
||||
|
||||
room.onMessage('admitted', (message) => {
|
||||
handleAdmitted(message);
|
||||
});
|
||||
|
||||
room.onMessage('joined', (message) => {
|
||||
handleJoined(message);
|
||||
});
|
||||
|
||||
room.onMessage('queue_update', (message) => {
|
||||
handleQueueUpdate(message);
|
||||
});
|
||||
|
||||
room.onMessage('experiment_started', (message) => {
|
||||
handleExperimentStarted(message);
|
||||
});
|
||||
|
||||
room.onMessage('experiment_action', (message) => {
|
||||
handleExperimentAction(message);
|
||||
});
|
||||
|
||||
room.onMessage('experiment_state_update', (message) => {
|
||||
handleExperimentStateUpdate(message);
|
||||
});
|
||||
|
||||
room.onError((code, message) => {
|
||||
console.error('Room error:', code, message);
|
||||
isConnected = false;
|
||||
roomStatus = 'error';
|
||||
updateOverlay();
|
||||
});
|
||||
|
||||
room.onLeave((code) => {
|
||||
console.log('Left room with code:', code);
|
||||
isConnected = false;
|
||||
roomStatus = 'disconnected';
|
||||
updateOverlay();
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to connect to multiplayer server:', error);
|
||||
isConnected = false;
|
||||
roomStatus = 'error';
|
||||
updateOverlay();
|
||||
}
|
||||
}
|
||||
|
||||
function handleStateUpdate(state) {
|
||||
currentParticipants = state.currentParticipants;
|
||||
maxParticipants = state.maxParticipants;
|
||||
|
||||
// Update participant info
|
||||
if (state.participants && participantId) {
|
||||
const participant = state.participants.get(participantId);
|
||||
if (participant) {
|
||||
console.log('Participant updated:', participant);
|
||||
}
|
||||
}
|
||||
|
||||
updateOverlay();
|
||||
}
|
||||
|
||||
function handleQueued(message) {
|
||||
roomStatus = 'queued';
|
||||
queuePosition = message.position;
|
||||
console.log('Added to queue:', message);
|
||||
updateOverlay();
|
||||
}
|
||||
|
||||
function handleAdmitted(message) {
|
||||
roomStatus = 'admitted';
|
||||
participantId = message.participantId;
|
||||
currentParticipants = message.currentParticipants;
|
||||
maxParticipants = message.maxParticipants;
|
||||
console.log('Admitted to experiment:', message);
|
||||
|
||||
if (isLoading) {
|
||||
isLoading = false;
|
||||
hideLoadingScreen();
|
||||
}
|
||||
|
||||
updateOverlay();
|
||||
}
|
||||
|
||||
function handleJoined(message) {
|
||||
roomStatus = 'joined';
|
||||
participantId = message.participantId;
|
||||
currentParticipants = message.currentParticipants;
|
||||
maxParticipants = message.maxParticipants;
|
||||
console.log('Joined experiment:', message);
|
||||
|
||||
if (isLoading) {
|
||||
isLoading = false;
|
||||
hideLoadingScreen();
|
||||
}
|
||||
|
||||
updateOverlay();
|
||||
}
|
||||
|
||||
function handleQueueUpdate(message) {
|
||||
queuePosition = message.position;
|
||||
console.log('Queue position updated:', message);
|
||||
updateOverlay();
|
||||
}
|
||||
|
||||
function handleExperimentStarted(message) {
|
||||
roomStatus = 'running';
|
||||
console.log('Experiment started:', message);
|
||||
updateOverlay();
|
||||
}
|
||||
|
||||
function handleExperimentAction(message) {
|
||||
// Handle actions from other participants
|
||||
console.log('Experiment action from participant:', message);
|
||||
// This would be where you'd handle specific experiment interactions
|
||||
}
|
||||
|
||||
function handleExperimentStateUpdate(message) {
|
||||
// Handle experiment state updates
|
||||
console.log('Experiment state updated:', message);
|
||||
// This would be where you'd sync experiment state across participants
|
||||
}
|
||||
|
||||
function hideLoadingScreen() {
|
||||
const loadingScreen = document.getElementById('loading-screen');
|
||||
if (loadingScreen) {
|
||||
loadingScreen.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function updateOverlay() {
|
||||
const statusDot = document.getElementById('status-dot');
|
||||
const statusText = document.getElementById('status-text');
|
||||
const participantCount = document.getElementById('participant-count');
|
||||
const queueInfo = document.getElementById('queue-info');
|
||||
|
||||
if (statusDot) {
|
||||
statusDot.className = 'status-dot ' + (isConnected ? 'connected' : 'disconnected');
|
||||
}
|
||||
|
||||
if (statusText) {
|
||||
let statusMessage = 'Disconnected';
|
||||
if (isConnected) {
|
||||
switch (roomStatus) {
|
||||
case 'connected':
|
||||
statusMessage = 'Connected';
|
||||
break;
|
||||
case 'queued':
|
||||
statusMessage = \`Queue #\${queuePosition}\`;
|
||||
break;
|
||||
case 'admitted':
|
||||
case 'joined':
|
||||
statusMessage = 'In Session';
|
||||
break;
|
||||
case 'running':
|
||||
statusMessage = 'Running';
|
||||
break;
|
||||
default:
|
||||
statusMessage = 'Connected';
|
||||
}
|
||||
}
|
||||
statusText.textContent = statusMessage;
|
||||
}
|
||||
|
||||
if (participantCount) {
|
||||
participantCount.textContent = \`\${currentParticipants}/\${maxParticipants}\`;
|
||||
}
|
||||
|
||||
if (queueInfo) {
|
||||
if (roomStatus === 'queued') {
|
||||
queueInfo.textContent = \`Position \${queuePosition} in queue\`;
|
||||
queueInfo.style.display = 'block';
|
||||
} else {
|
||||
queueInfo.style.display = 'none';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toggleOverlay() {
|
||||
const overlay = document.getElementById('multiplayer-overlay');
|
||||
const minimized = document.getElementById('minimized-overlay');
|
||||
|
||||
if (overlay && minimized) {
|
||||
overlayVisible = !overlayVisible;
|
||||
overlay.style.display = overlayVisible ? 'block' : 'none';
|
||||
minimized.style.display = overlayVisible ? 'none' : 'block';
|
||||
}
|
||||
}
|
||||
|
||||
// Send experiment action to other participants
|
||||
function sendExperimentAction(action, data) {
|
||||
if (room && roomStatus === 'running') {
|
||||
room.send('experiment_action', { action, data });
|
||||
}
|
||||
}
|
||||
|
||||
// Send experiment state update
|
||||
function sendExperimentState(state) {
|
||||
if (room && roomStatus === 'running') {
|
||||
room.send('experiment_state', { state });
|
||||
}
|
||||
}
|
||||
|
||||
// Mark participant as ready
|
||||
function markReady() {
|
||||
if (room && participantId) {
|
||||
room.send('ready', { participantId });
|
||||
}
|
||||
}
|
||||
|
||||
// Handle user interactions
|
||||
function handleInteraction(event) {
|
||||
if (event instanceof KeyboardEvent && event.key === 'Tab') {
|
||||
event.preventDefault();
|
||||
toggleOverlay();
|
||||
return;
|
||||
}
|
||||
|
||||
// For now, just log interactions - experiment-specific handling would go here
|
||||
if (roomStatus === 'running') {
|
||||
console.log('User interaction:', event.type, event);
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize multiplayer when DOM is ready
|
||||
function initMultiplayer() {
|
||||
// Add event listeners
|
||||
document.addEventListener('keydown', handleInteraction, true);
|
||||
|
||||
// Connect to Colyseus
|
||||
connectMultiplayer();
|
||||
}
|
||||
|
||||
// Start multiplayer when page loads
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initMultiplayer);
|
||||
} else {
|
||||
initMultiplayer();
|
||||
}
|
||||
|
||||
// Make functions available globally for experiment use
|
||||
window.multiplayerAPI = {
|
||||
sendAction: sendExperimentAction,
|
||||
sendState: sendExperimentState,
|
||||
markReady: markReady,
|
||||
getStatus: () => ({ roomStatus, participantId, currentParticipants, maxParticipants })
|
||||
};
|
||||
</script>
|
||||
`;
|
||||
|
||||
const multiplayerCSS = `
|
||||
<style>
|
||||
/* Multiplayer overlay styles */
|
||||
#multiplayer-overlay {
|
||||
position: fixed;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
z-index: 9999;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
color: white;
|
||||
padding: 12px 16px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
backdrop-filter: blur(4px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
font-size: 14px;
|
||||
max-width: 250px;
|
||||
}
|
||||
|
||||
#minimized-overlay {
|
||||
position: fixed;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
z-index: 9999;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
color: white;
|
||||
padding: 8px 12px;
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
backdrop-filter: blur(4px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
cursor: pointer;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
font-size: 12px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.overlay-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.status-dot.connected {
|
||||
background: #10b981;
|
||||
}
|
||||
|
||||
.status-dot.disconnected {
|
||||
background: #ef4444;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
padding: 0;
|
||||
margin-left: 12px;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.overlay-info {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.overlay-separator {
|
||||
margin: 8px 0;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.2);
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
#loading-screen {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(135deg, #f0f9ff 0%, #e0e7ff 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10000;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
|
||||
.loading-content {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border: 3px solid #e2e8f0;
|
||||
border-top: 3px solid #3b82f6;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin: 0 auto 16px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
color: #374151;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.loading-subtext {
|
||||
color: #6b7280;
|
||||
font-size: 14px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.loading-hint {
|
||||
color: #9ca3af;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Ensure overlay elements don't interfere with experiment */
|
||||
#multiplayer-overlay,
|
||||
#minimized-overlay {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
#multiplayer-overlay *,
|
||||
#minimized-overlay * {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* Full screen styles */
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
background: white;
|
||||
}
|
||||
|
||||
html {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
}
|
||||
</style>
|
||||
`;
|
||||
|
||||
const multiplayerOverlay = `
|
||||
<!-- Loading screen -->
|
||||
<div id="loading-screen">
|
||||
<div class="loading-content">
|
||||
<div class="loading-spinner"></div>
|
||||
<div class="loading-text">Loading experiment...</div>
|
||||
<div class="loading-subtext">Connecting to multiplayer session</div>
|
||||
<div class="loading-hint">Your experiment will open in full screen</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Multiplayer overlay -->
|
||||
<div id="multiplayer-overlay">
|
||||
<div class="overlay-header">
|
||||
<div class="status-indicator">
|
||||
<div id="status-dot" class="status-dot disconnected"></div>
|
||||
<span id="status-text">Disconnected</span>
|
||||
</div>
|
||||
<button class="close-btn" onclick="toggleOverlay()">×</button>
|
||||
</div>
|
||||
<div class="overlay-info">
|
||||
<div>🎮 Multiplayer Session</div>
|
||||
<div>👥 Participants: <span id="participant-count">0/1</span></div>
|
||||
<div>🕐 Started: <span id="session-time"></span></div>
|
||||
<div>📋 ID: ${experimentId.slice(0, 8)}...</div>
|
||||
<div id="queue-info" style="display: none; color: #fbbf24; margin-top: 4px;">
|
||||
Position in queue
|
||||
</div>
|
||||
<div class="overlay-separator">
|
||||
<div style="color: rgba(255, 255, 255, 0.6);">Press Tab to toggle this overlay</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Minimized overlay -->
|
||||
<div id="minimized-overlay" onclick="toggleOverlay()">
|
||||
<div style="display: flex; align-items: center; gap: 8px;">
|
||||
<div id="minimized-status-dot" class="status-dot disconnected"></div>
|
||||
<span>MP</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Update session time
|
||||
document.getElementById('session-time').textContent = new Date().toLocaleTimeString();
|
||||
</script>
|
||||
`;
|
||||
|
||||
// Inject multiplayer functionality into the HTML
|
||||
const injectedBody = body
|
||||
.replace(/<head[^>]*>/, `$&<base href="/public/multiplayer/run/${experimentId}/">${multiplayerCSS}`)
|
||||
.replace(/<\/body>/, `${multiplayerOverlay}${multiplayerScript}</body>`);
|
||||
|
||||
return new Response(injectedBody, {
|
||||
headers: {
|
||||
'Content-Type': 'text/html',
|
||||
'Content-Length': Buffer.byteLength(injectedBody, 'utf8').toString()
|
||||
}
|
||||
});
|
||||
} catch (e: any) {
|
||||
if (e.name === 'NoSuchKey') {
|
||||
throw error(404, 'Not found');
|
||||
}
|
||||
throw error(500, 'Internal server error');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
import { db } from '$lib/server/db/index.js';
|
||||
import * as schema from '$lib/server/db/schema.js';
|
||||
import s3 from '$lib/server/s3.js';
|
||||
import { GetObjectCommand, ListObjectsV2Command } from '@aws-sdk/client-s3';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { error } from '@sveltejs/kit';
|
||||
import mime from 'mime-types';
|
||||
import { S3_BUCKET } from '$env/static/private';
|
||||
import { dev } from '$app/environment';
|
||||
import { promises as fs } from 'fs';
|
||||
|
||||
const EXPERIMENT_SESSION_COOKIE_PREFIX = 'experiment-session-';
|
||||
|
||||
// Rules for proxying vendor files to a CDN using regex to extract versions
|
||||
const vendorFileRules = [
|
||||
{
|
||||
regex: /^lib\/vendors\/jquery-([\d.]+)\.min\.js$/,
|
||||
url: (version: string) => `https://code.jquery.com/jquery-${version}.min.js`
|
||||
},
|
||||
{
|
||||
regex: /^lib\/vendors\/surveyjs\.jquery-([\d.]+)\.min\.js$/,
|
||||
url: (version: string) => `https://unpkg.com/survey-jquery@${version}/survey.jquery.min.js`
|
||||
},
|
||||
{
|
||||
regex: /^lib\/vendors\/surveyjs\.defaultV2-([\d.]+)-OST\.min\.css$/,
|
||||
url: (version: string) => `https://unpkg.com/survey-core@${version}/defaultV2.min.css`
|
||||
}
|
||||
];
|
||||
|
||||
export async function GET({ params, cookies, getClientAddress, request }) {
|
||||
const { experimentId, path } = params;
|
||||
|
||||
// Check if the experiment is a multiplayer experiment
|
||||
const experiment = await db.query.experiment.findFirst({
|
||||
where: eq(schema.experiment.id, experimentId)
|
||||
});
|
||||
|
||||
if (!experiment) {
|
||||
throw error(404, 'Experiment not found');
|
||||
}
|
||||
|
||||
if (!experiment.multiplayer) {
|
||||
throw error(403, 'This is not a multiplayer experiment');
|
||||
}
|
||||
|
||||
|
||||
// Map of requested CSS files to their location in the static directory.
|
||||
const staticCssMap: Record<string, string> = {
|
||||
'lib/vendors/survey.widgets.css': 'static/lib/psychoJS/surveyJS/survey.widgets.css',
|
||||
'lib/vendors/survey.grey_style.css': 'static/lib/psychoJS/surveyJS/survey.grey_style.css'
|
||||
};
|
||||
|
||||
if (path in staticCssMap) {
|
||||
const filePath = staticCssMap[path];
|
||||
try {
|
||||
const fileContents = await fs.readFile(filePath, 'utf-8');
|
||||
return new Response(fileContents, {
|
||||
headers: { 'Content-Type': 'text/css' }
|
||||
});
|
||||
} catch (e: any) {
|
||||
if (e.code === 'ENOENT') {
|
||||
throw error(404, 'File not found in static directory');
|
||||
}
|
||||
throw error(500, 'Error reading static file');
|
||||
}
|
||||
}
|
||||
|
||||
// Check if the requested path is a vendor file and proxy to CDN
|
||||
for (const rule of vendorFileRules) {
|
||||
const match = path.match(rule.regex);
|
||||
if (match) {
|
||||
try {
|
||||
// The first element of match is the full string, subsequent elements are capture groups.
|
||||
const cdnUrl = (rule.url as Function).apply(null, match.slice(1));
|
||||
const cdnResponse = await fetch(cdnUrl);
|
||||
|
||||
if (!cdnResponse.ok) {
|
||||
throw error(
|
||||
cdnResponse.status,
|
||||
`Failed to fetch from CDN: ${cdnResponse.statusText}`
|
||||
);
|
||||
}
|
||||
|
||||
// Buffer the entire response to avoid potential streaming issues.
|
||||
// This is less memory-efficient but more robust for debugging.
|
||||
const body = await cdnResponse.arrayBuffer();
|
||||
const headers = new Headers(cdnResponse.headers);
|
||||
|
||||
// The `fetch` API automatically decompresses content, so we need to remove
|
||||
// the Content-Encoding header to avoid the browser trying to decompress it again.
|
||||
// We also remove Content-Length because it's now incorrect for the decompressed body.
|
||||
headers.delete('Content-Encoding');
|
||||
headers.delete('Content-Length');
|
||||
|
||||
// Ensure the Content-Type is set correctly, as it's crucial for the browser.
|
||||
if (!headers.has('Content-Type')) {
|
||||
headers.set(
|
||||
'Content-Type',
|
||||
path.endsWith('.css') ? 'text/css' : 'application/javascript'
|
||||
);
|
||||
}
|
||||
|
||||
// Forward the response from the CDN
|
||||
return new Response(body, {
|
||||
status: cdnResponse.status,
|
||||
statusText: cdnResponse.statusText,
|
||||
headers: headers
|
||||
});
|
||||
} catch (e: any) {
|
||||
throw error(500, 'Failed to proxy vendor file');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const cookieName = `${EXPERIMENT_SESSION_COOKIE_PREFIX}${experimentId}`;
|
||||
let experimentSessionId = cookies.get(cookieName);
|
||||
|
||||
if (!experimentSessionId) {
|
||||
// First request for this experiment. Create a new participant session.
|
||||
experimentSessionId = randomUUID();
|
||||
const now = new Date();
|
||||
|
||||
await db.insert(schema.experimentSession).values({
|
||||
id: experimentSessionId,
|
||||
experimentId,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
status: 'in progress',
|
||||
ipAddress: getClientAddress(),
|
||||
userAgent: request.headers.get('user-agent') ?? undefined
|
||||
});
|
||||
|
||||
cookies.set(cookieName, experimentSessionId, {
|
||||
path: `/public/multiplayer/run/${experimentId}`,
|
||||
httpOnly: true,
|
||||
secure: !dev,
|
||||
maxAge: 60 * 60 * 24 * 365 // 1 year
|
||||
});
|
||||
} else {
|
||||
// subsequent requests, check if cookie is valid
|
||||
const [session] = await db
|
||||
.select()
|
||||
.from(schema.experimentSession)
|
||||
.where(eq(schema.experimentSession.id, experimentSessionId));
|
||||
if (!session) {
|
||||
// invalid cookie, create new session
|
||||
const newExperimentSessionId = randomUUID();
|
||||
const now = new Date();
|
||||
await db.insert(schema.experimentSession).values({
|
||||
id: newExperimentSessionId,
|
||||
experimentId,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
status: 'in progress',
|
||||
ipAddress: getClientAddress(),
|
||||
userAgent: request.headers.get('user-agent') ?? undefined
|
||||
});
|
||||
cookies.set(cookieName, newExperimentSessionId, {
|
||||
path: `/public/multiplayer/run/${experimentId}`,
|
||||
httpOnly: true,
|
||||
secure: !dev,
|
||||
maxAge: 60 * 60 * 24 * 365 // 1 year
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Files are stored in the user's experiment_files directory
|
||||
const createdBy = experiment.createdBy;
|
||||
const s3Prefix = `${createdBy}/${experimentId}/experiment_files/`;
|
||||
const filePath = path === '' ? 'index.html' : path;
|
||||
const key = `${s3Prefix}${filePath}`;
|
||||
|
||||
// Check if user is trying to access files outside of the experiment directory
|
||||
if (!key.startsWith(s3Prefix)) {
|
||||
throw error(403, 'Forbidden');
|
||||
}
|
||||
|
||||
try {
|
||||
const command = new GetObjectCommand({
|
||||
Bucket: S3_BUCKET,
|
||||
Key: key
|
||||
});
|
||||
const s3Response = await s3.send(command);
|
||||
|
||||
if (!s3Response.Body) {
|
||||
throw error(404, 'Not found');
|
||||
}
|
||||
|
||||
const stream = s3Response.Body;
|
||||
const contentType =
|
||||
mime.lookup(filePath) || s3Response.ContentType || 'application/octet-stream';
|
||||
|
||||
if (filePath.endsWith('index.html')) {
|
||||
const body = await stream.transformToString();
|
||||
|
||||
// For PsychoJS experiments, we need to set the base href to the experiment root
|
||||
// so that relative paths like "stimuli/image.jpg" resolve correctly
|
||||
const basePath = `/public/multiplayer/run/${experimentId}/`;
|
||||
|
||||
// Get all files in the experiment directory to create a resource manifest
|
||||
const listCommand = new ListObjectsV2Command({
|
||||
Bucket: S3_BUCKET,
|
||||
Prefix: s3Prefix
|
||||
});
|
||||
const listResponse = await s3.send(listCommand);
|
||||
|
||||
// Create resource manifest for PsychoJS
|
||||
const resources = (listResponse.Contents || [])
|
||||
.filter(obj => obj.Key && obj.Key !== `${s3Prefix}index.html`)
|
||||
.map(obj => {
|
||||
const relativePath = obj.Key!.replace(s3Prefix, '');
|
||||
return {
|
||||
name: relativePath,
|
||||
path: relativePath
|
||||
};
|
||||
});
|
||||
|
||||
// Create the resource injection script that runs after PsychoJS loads
|
||||
const resourceInjectionScript = `
|
||||
<script>
|
||||
// Wait for PsychoJS to be available and inject resources
|
||||
function injectPsychoJSResources() {
|
||||
const resources = ${JSON.stringify(resources)};
|
||||
|
||||
// Check if PsychoJS and its components are available
|
||||
if (typeof psychoJS !== 'undefined' && psychoJS.serverManager) {
|
||||
|
||||
// Add resources to the server manager's resource list
|
||||
resources.forEach(resource => {
|
||||
psychoJS.serverManager._resources.set(resource.name, {
|
||||
name: resource.name,
|
||||
path: resource.path,
|
||||
status: psychoJS.serverManager.constructor.ResourceStatus.NOT_DOWNLOADED
|
||||
});
|
||||
});
|
||||
|
||||
} else {
|
||||
// PsychoJS not ready yet, try again in 100ms
|
||||
setTimeout(injectPsychoJSResources, 100);
|
||||
}
|
||||
}
|
||||
|
||||
// Start trying to inject resources when DOM is ready
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', injectPsychoJSResources);
|
||||
} else {
|
||||
injectPsychoJSResources();
|
||||
}
|
||||
</script>
|
||||
`;
|
||||
|
||||
const injectedBody = body
|
||||
.replace(/<head[^>]*>/, `$&<base href="${basePath}">`)
|
||||
.replace(/<\/body>/, `${resourceInjectionScript}<script>console.log('injection')</script></body>`);
|
||||
|
||||
return new Response(injectedBody, {
|
||||
headers: {
|
||||
'Content-Type': contentType,
|
||||
'Content-Length': Buffer.byteLength(injectedBody, 'utf8').toString()
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const fileBuffer = await stream.transformToByteArray();
|
||||
return new Response(fileBuffer, {
|
||||
headers: { 'Content-Type': contentType }
|
||||
});
|
||||
} catch (e: any) {
|
||||
if (e.name === 'NoSuchKey') {
|
||||
throw error(404, 'File not found');
|
||||
} else {
|
||||
throw error(500, `Error fetching from S3: ${e.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import { S3_BUCKET } from '$env/static/private';
|
||||
import { dev } from '$app/environment';
|
||||
import { promises as fs } from 'fs';
|
||||
|
||||
const PARTICIPANT_COOKIE_PREFIX = 'participant-session-';
|
||||
const EXPERIMENT_SESSION_COOKIE_PREFIX = 'experiment-session-';
|
||||
|
||||
// Rules for proxying vendor files to a CDN using regex to extract versions
|
||||
const vendorFileRules = [
|
||||
@@ -31,6 +31,15 @@ const vendorFileRules = [
|
||||
export async function GET({ params, cookies, getClientAddress, request }) {
|
||||
const { experimentId, path } = params;
|
||||
|
||||
// Check if the experiment exists
|
||||
const experiment = await db.query.experiment.findFirst({
|
||||
where: eq(schema.experiment.id, experimentId)
|
||||
});
|
||||
|
||||
if (!experiment) {
|
||||
throw error(404, 'Experiment not found');
|
||||
}
|
||||
|
||||
// Map of requested CSS files to their location in the static directory.
|
||||
const staticCssMap: Record<string, string> = {
|
||||
'lib/vendors/survey.widgets.css': 'static/lib/psychoJS/surveyJS/survey.widgets.css',
|
||||
@@ -99,22 +108,25 @@ export async function GET({ params, cookies, getClientAddress, request }) {
|
||||
}
|
||||
}
|
||||
|
||||
const cookieName = `${PARTICIPANT_COOKIE_PREFIX}${experimentId}`;
|
||||
let participantSessionId = cookies.get(cookieName);
|
||||
const cookieName = `${EXPERIMENT_SESSION_COOKIE_PREFIX}${experimentId}`;
|
||||
let experimentSessionId = cookies.get(cookieName);
|
||||
|
||||
if (!participantSessionId) {
|
||||
if (!experimentSessionId) {
|
||||
// First request for this experiment. Create a new participant session.
|
||||
participantSessionId = randomUUID();
|
||||
experimentSessionId = randomUUID();
|
||||
const now = new Date();
|
||||
|
||||
await db.insert(schema.participantSession).values({
|
||||
id: participantSessionId,
|
||||
await db.insert(schema.experimentSession).values({
|
||||
id: experimentSessionId,
|
||||
experimentId,
|
||||
createdAt: new Date(),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
status: 'in progress',
|
||||
ipAddress: getClientAddress(),
|
||||
userAgent: request.headers.get('user-agent') ?? undefined
|
||||
});
|
||||
|
||||
cookies.set(cookieName, participantSessionId, {
|
||||
cookies.set(cookieName, experimentSessionId, {
|
||||
path: `/public/run/${experimentId}`,
|
||||
httpOnly: true,
|
||||
secure: !dev,
|
||||
@@ -124,19 +136,22 @@ export async function GET({ params, cookies, getClientAddress, request }) {
|
||||
// subsequent requests, check if cookie is valid
|
||||
const [session] = await db
|
||||
.select()
|
||||
.from(schema.participantSession)
|
||||
.where(eq(schema.participantSession.id, participantSessionId));
|
||||
.from(schema.experimentSession)
|
||||
.where(eq(schema.experimentSession.id, experimentSessionId));
|
||||
if (!session) {
|
||||
// invalid cookie, create new session
|
||||
const newParticipantSessionId = randomUUID();
|
||||
await db.insert(schema.participantSession).values({
|
||||
id: newParticipantSessionId,
|
||||
const newExperimentSessionId = randomUUID();
|
||||
const now = new Date();
|
||||
await db.insert(schema.experimentSession).values({
|
||||
id: newExperimentSessionId,
|
||||
experimentId,
|
||||
createdAt: new Date(),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
status: 'in progress',
|
||||
ipAddress: getClientAddress(),
|
||||
userAgent: request.headers.get('user-agent') ?? undefined
|
||||
});
|
||||
cookies.set(cookieName, newParticipantSessionId, {
|
||||
cookies.set(cookieName, newExperimentSessionId, {
|
||||
path: `/public/run/${experimentId}`,
|
||||
httpOnly: true,
|
||||
secure: !dev,
|
||||
@@ -145,7 +160,9 @@ export async function GET({ params, cookies, getClientAddress, request }) {
|
||||
}
|
||||
}
|
||||
|
||||
const s3Prefix = `experiments/${experimentId}/`;
|
||||
// Files are stored in the user's experiment_files directory
|
||||
const createdBy = experiment.createdBy;
|
||||
const s3Prefix = `${createdBy}/${experimentId}/experiment_files/`;
|
||||
const filePath = path === '' ? 'index.html' : path;
|
||||
const key = `${s3Prefix}${filePath}`;
|
||||
|
||||
@@ -174,20 +191,22 @@ export async function GET({ params, cookies, getClientAddress, request }) {
|
||||
|
||||
// For PsychoJS experiments, we need to set the base href to the experiment root
|
||||
// so that relative paths like "stimuli/image.jpg" resolve correctly
|
||||
const basePath = `/public/run/${experimentId}/`;
|
||||
// Use the request URL to determine if this is multiplayer or single player
|
||||
const isMultiplayer = request.url.includes('/multiplayer/');
|
||||
const basePath = isMultiplayer ? `/public/multiplayer/run/${experimentId}/` : `/public/run/${experimentId}/`;
|
||||
|
||||
// Get all files in the experiment directory to create a resource manifest
|
||||
const listCommand = new ListObjectsV2Command({
|
||||
Bucket: S3_BUCKET,
|
||||
Prefix: `experiments/${experimentId}/`
|
||||
Prefix: s3Prefix
|
||||
});
|
||||
const listResponse = await s3.send(listCommand);
|
||||
|
||||
// Create resource manifest for PsychoJS
|
||||
const resources = (listResponse.Contents || [])
|
||||
.filter(obj => obj.Key && obj.Key !== `experiments/${experimentId}/index.html`)
|
||||
.filter(obj => obj.Key && obj.Key !== `${s3Prefix}index.html`)
|
||||
.map(obj => {
|
||||
const relativePath = obj.Key!.replace(`experiments/${experimentId}/`, '');
|
||||
const relativePath = obj.Key!.replace(s3Prefix, '');
|
||||
return {
|
||||
name: relativePath,
|
||||
path: relativePath
|
||||
|
||||
10
src/routes/settings/+page.svelte
Normal file
10
src/routes/settings/+page.svelte
Normal file
@@ -0,0 +1,10 @@
|
||||
<script lang="ts">
|
||||
// Dummy settings page
|
||||
</script>
|
||||
|
||||
<h1 class="text-2xl font-bold mb-4">Settings</h1>
|
||||
<div class="space-y-4">
|
||||
<div class="p-4 bg-muted rounded">Option 1: [dummy toggle]</div>
|
||||
<div class="p-4 bg-muted rounded">Option 2: [dummy select]</div>
|
||||
<div class="p-4 bg-muted rounded">More settings coming soon...</div>
|
||||
</div>
|
||||
98
src/routes/settings/profile/+page.svelte
Normal file
98
src/routes/settings/profile/+page.svelte
Normal file
@@ -0,0 +1,98 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { onMount } from 'svelte';
|
||||
let email = '';
|
||||
let currentPassword = '';
|
||||
let newPassword = '';
|
||||
let confirmPassword = '';
|
||||
|
||||
// Use $page as a reactive value
|
||||
$: fullName = $page.data?.user?.username || '';
|
||||
|
||||
let storageUsed = 0;
|
||||
const MAX_STORAGE = 1024 * 1024 * 1024; // 1GB in bytes
|
||||
let loadingStorage = true;
|
||||
|
||||
onMount(async () => {
|
||||
loadingStorage = true;
|
||||
try {
|
||||
const res = await fetch('/api/user-storage');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
storageUsed = data.usage;
|
||||
}
|
||||
} finally {
|
||||
loadingStorage = false;
|
||||
}
|
||||
});
|
||||
|
||||
function formatBytes(bytes: number) {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
function handleEmailChange(e: Event) {
|
||||
e.preventDefault();
|
||||
alert('Email change submitted (dummy)');
|
||||
}
|
||||
|
||||
function handlePasswordChange(e: Event) {
|
||||
e.preventDefault();
|
||||
if (newPassword !== confirmPassword) {
|
||||
alert('Passwords do not match!');
|
||||
return;
|
||||
}
|
||||
alert('Password change submitted (dummy)');
|
||||
}
|
||||
</script>
|
||||
|
||||
<h1 class="text-2xl font-bold mb-4">Profile</h1>
|
||||
|
||||
<!-- Storage Usage Bar -->
|
||||
<div class="mb-8 p-4 bg-muted rounded">
|
||||
<div class="font-medium mb-2">Storage Usage</div>
|
||||
{#if loadingStorage}
|
||||
<div>Loading...</div>
|
||||
{:else}
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<div class="flex-1 h-4 bg-gray-200 rounded overflow-hidden">
|
||||
<div class="h-4 bg-primary" style="width: {Math.min(100, (storageUsed / MAX_STORAGE) * 100)}%"></div>
|
||||
</div>
|
||||
<div class="text-sm text-muted-foreground whitespace-nowrap">{formatBytes(storageUsed)} / 1 GB</div>
|
||||
</div>
|
||||
{#if storageUsed > MAX_STORAGE}
|
||||
<div class="text-red-600 text-xs mt-1">You have exceeded your storage limit!</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="mb-8 p-4 bg-muted rounded">
|
||||
<div class="font-medium">Full Name</div>
|
||||
<div class="text-lg">{fullName}</div>
|
||||
</div>
|
||||
|
||||
<form class="space-y-4 mb-8" on:submit|preventDefault={handleEmailChange}>
|
||||
<div>
|
||||
<label class="block mb-1 font-medium" for="email">Email Address</label>
|
||||
<input id="email" type="email" bind:value={email} class="border rounded px-3 py-2 w-full" required />
|
||||
</div>
|
||||
<button type="submit" class="bg-primary text-primary-foreground rounded px-4 py-2">Change Email</button>
|
||||
</form>
|
||||
|
||||
<form class="space-y-4" on:submit|preventDefault={handlePasswordChange}>
|
||||
<div>
|
||||
<label class="block mb-1 font-medium" for="currentPassword">Current Password</label>
|
||||
<input id="currentPassword" type="password" bind:value={currentPassword} class="border rounded px-3 py-2 w-full" required />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block mb-1 font-medium" for="newPassword">New Password</label>
|
||||
<input id="newPassword" type="password" bind:value={newPassword} class="border rounded px-3 py-2 w-full" required />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block mb-1 font-medium" for="confirmPassword">Confirm New Password</label>
|
||||
<input id="confirmPassword" type="password" bind:value={confirmPassword} class="border rounded px-3 py-2 w-full" required />
|
||||
</div>
|
||||
<button type="submit" class="bg-primary text-primary-foreground rounded px-4 py-2">Change Password</button>
|
||||
</form>
|
||||
10
src/routes/settings/subscription/+page.svelte
Normal file
10
src/routes/settings/subscription/+page.svelte
Normal file
@@ -0,0 +1,10 @@
|
||||
<script lang="ts">
|
||||
// Dummy subscription page
|
||||
</script>
|
||||
|
||||
<h1 class="text-2xl font-bold mb-4">Subscription</h1>
|
||||
<div class="space-y-4">
|
||||
<div class="p-4 bg-muted rounded">Current Plan: [dummy plan]</div>
|
||||
<div class="p-4 bg-muted rounded">Renewal Date: [dummy date]</div>
|
||||
<div class="p-4 bg-muted rounded">Upgrade options coming soon...</div>
|
||||
</div>
|
||||
229
test-multiplayer.html
Normal file
229
test-multiplayer.html
Normal file
@@ -0,0 +1,229 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Multiplayer Test</title>
|
||||
<script src="https://unpkg.com/colyseus.js@^0.16.0/dist/colyseus.js"></script>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
.status {
|
||||
padding: 10px;
|
||||
margin: 10px 0;
|
||||
border-radius: 5px;
|
||||
}
|
||||
.connected {
|
||||
background-color: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
.disconnected {
|
||||
background-color: #f8d7da;
|
||||
color: #721c24;
|
||||
}
|
||||
.queued {
|
||||
background-color: #fff3cd;
|
||||
color: #856404;
|
||||
}
|
||||
.log {
|
||||
background-color: #f8f9fa;
|
||||
padding: 10px;
|
||||
margin: 10px 0;
|
||||
border-radius: 5px;
|
||||
font-family: monospace;
|
||||
white-space: pre-wrap;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
button {
|
||||
padding: 10px 20px;
|
||||
margin: 5px;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-primary {
|
||||
background-color: #007bff;
|
||||
color: white;
|
||||
}
|
||||
.btn-secondary {
|
||||
background-color: #6c757d;
|
||||
color: white;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Multiplayer Test</h1>
|
||||
|
||||
<div id="status" class="status disconnected">
|
||||
Status: Disconnected
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button id="connectBtn" class="btn-primary" onclick="connectToRoom()">Connect to Room</button>
|
||||
<button id="disconnectBtn" class="btn-secondary" onclick="disconnectFromRoom()" disabled>Disconnect</button>
|
||||
<button id="readyBtn" class="btn-primary" onclick="markReady()" disabled>Mark Ready</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3>Room Info</h3>
|
||||
<p>Room ID: <span id="roomId">-</span></p>
|
||||
<p>Participants: <span id="participants">0/1</span></p>
|
||||
<p>Queue Position: <span id="queuePosition">-</span></p>
|
||||
<p>Status: <span id="roomStatus">-</span></p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3>Log</h3>
|
||||
<div id="log" class="log"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let client = null;
|
||||
let room = null;
|
||||
let isConnected = false;
|
||||
|
||||
const statusDiv = document.getElementById('status');
|
||||
const connectBtn = document.getElementById('connectBtn');
|
||||
const disconnectBtn = document.getElementById('disconnectBtn');
|
||||
const readyBtn = document.getElementById('readyBtn');
|
||||
const roomIdSpan = document.getElementById('roomId');
|
||||
const participantsSpan = document.getElementById('participants');
|
||||
const queuePositionSpan = document.getElementById('queuePosition');
|
||||
const roomStatusSpan = document.getElementById('roomStatus');
|
||||
const logDiv = document.getElementById('log');
|
||||
|
||||
function log(message) {
|
||||
const timestamp = new Date().toLocaleTimeString();
|
||||
const logMessage = `[${timestamp}] ${message}`;
|
||||
logDiv.textContent += logMessage + '\n';
|
||||
logDiv.scrollTop = logDiv.scrollHeight;
|
||||
console.log(logMessage);
|
||||
}
|
||||
|
||||
function updateStatus(status, className) {
|
||||
statusDiv.textContent = `Status: ${status}`;
|
||||
statusDiv.className = `status ${className}`;
|
||||
}
|
||||
|
||||
function updateUI() {
|
||||
connectBtn.disabled = isConnected;
|
||||
disconnectBtn.disabled = !isConnected;
|
||||
readyBtn.disabled = !isConnected;
|
||||
}
|
||||
|
||||
async function connectToRoom() {
|
||||
try {
|
||||
log('Connecting to Colyseus server...');
|
||||
|
||||
const protocol = location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
const host = location.hostname;
|
||||
const port = '8080';
|
||||
|
||||
client = new Colyseus.Client(`${protocol}://${host}:${port}`);
|
||||
|
||||
log('Joining experiment room...');
|
||||
room = await client.joinOrCreate('experiment_room', {
|
||||
experimentId: 'test-experiment-123',
|
||||
userAgent: navigator.userAgent,
|
||||
ipAddress: null
|
||||
});
|
||||
|
||||
isConnected = true;
|
||||
updateStatus('Connected', 'connected');
|
||||
updateUI();
|
||||
|
||||
roomIdSpan.textContent = room.roomId;
|
||||
log(`Connected to room: ${room.roomId}`);
|
||||
|
||||
// Handle room state changes
|
||||
room.onStateChange((state) => {
|
||||
log(`Room state updated: ${JSON.stringify(state, null, 2)}`);
|
||||
participantsSpan.textContent = `${state.currentParticipants}/${state.maxParticipants}`;
|
||||
roomStatusSpan.textContent = state.status;
|
||||
});
|
||||
|
||||
// Handle messages
|
||||
room.onMessage('queued', (message) => {
|
||||
log(`Queued: ${JSON.stringify(message)}`);
|
||||
updateStatus(`Queued (Position: ${message.position})`, 'queued');
|
||||
queuePositionSpan.textContent = message.position;
|
||||
});
|
||||
|
||||
room.onMessage('admitted', (message) => {
|
||||
log(`Admitted: ${JSON.stringify(message)}`);
|
||||
updateStatus('Admitted', 'connected');
|
||||
queuePositionSpan.textContent = '-';
|
||||
});
|
||||
|
||||
room.onMessage('joined', (message) => {
|
||||
log(`Joined: ${JSON.stringify(message)}`);
|
||||
updateStatus('Joined', 'connected');
|
||||
queuePositionSpan.textContent = '-';
|
||||
});
|
||||
|
||||
room.onMessage('queue_update', (message) => {
|
||||
log(`Queue update: ${JSON.stringify(message)}`);
|
||||
queuePositionSpan.textContent = message.position;
|
||||
});
|
||||
|
||||
room.onMessage('experiment_started', (message) => {
|
||||
log(`Experiment started: ${JSON.stringify(message)}`);
|
||||
updateStatus('Experiment Running', 'connected');
|
||||
});
|
||||
|
||||
room.onError((code, message) => {
|
||||
log(`Room error: ${code} - ${message}`);
|
||||
updateStatus('Error', 'disconnected');
|
||||
});
|
||||
|
||||
room.onLeave((code) => {
|
||||
log(`Left room with code: ${code}`);
|
||||
isConnected = false;
|
||||
updateStatus('Disconnected', 'disconnected');
|
||||
updateUI();
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
log(`Connection error: ${error.message}`);
|
||||
updateStatus('Connection Failed', 'disconnected');
|
||||
isConnected = false;
|
||||
updateUI();
|
||||
}
|
||||
}
|
||||
|
||||
function disconnectFromRoom() {
|
||||
if (room) {
|
||||
log('Disconnecting from room...');
|
||||
room.leave();
|
||||
room = null;
|
||||
client = null;
|
||||
isConnected = false;
|
||||
updateStatus('Disconnected', 'disconnected');
|
||||
updateUI();
|
||||
|
||||
// Reset UI
|
||||
roomIdSpan.textContent = '-';
|
||||
participantsSpan.textContent = '0/1';
|
||||
queuePositionSpan.textContent = '-';
|
||||
roomStatusSpan.textContent = '-';
|
||||
}
|
||||
}
|
||||
|
||||
function markReady() {
|
||||
if (room) {
|
||||
log('Marking as ready...');
|
||||
room.send('ready', { participantId: room.sessionId });
|
||||
}
|
||||
}
|
||||
|
||||
// Initial UI update
|
||||
updateUI();
|
||||
log('Multiplayer test page loaded');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -9,7 +9,9 @@
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"moduleResolution": "bundler"
|
||||
"moduleResolution": "bundler",
|
||||
"experimentalDecorators": true,
|
||||
"emitDecoratorMetadata": true
|
||||
}
|
||||
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
|
||||
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
|
||||
|
||||
@@ -5,6 +5,11 @@ import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [tailwindcss(), sveltekit(), devtoolsJson()],
|
||||
server: {
|
||||
host: '0.0.0.0', // Listen on all interfaces so Docker containers can access
|
||||
port: 5173,
|
||||
allowedHosts: ['host.docker.internal'] // Allow Docker containers to access via host.docker.internal
|
||||
},
|
||||
test: {
|
||||
projects: [
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user