mid switch to Colyseum
This commit is contained in:
@@ -26,6 +26,7 @@ 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()
|
||||
});
|
||||
|
||||
|
||||
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);
|
||||
});
|
||||
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');
|
||||
}
|
||||
}
|
||||
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');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user