Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { PuzzleSessionAnalyticsService } from '../services/puzzle-session-analytics.service';

describe('PuzzleSessionAnalyticsService', () => {
it('is defined', () => {
expect(PuzzleSessionAnalyticsService).toBeDefined();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { PuzzleSessionPlayerService } from '../services/puzzle-session-player.service';

describe('PuzzleSessionPlayerService', () => {
it('is defined', () => {
expect(PuzzleSessionPlayerService).toBeDefined();
});
});
39 changes: 39 additions & 0 deletions src/multiplayer/__tests__/puzzle-session-state.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { PuzzleSessionStateService } from '../services/puzzle-session-state.service';
import { PuzzleSessionStatus } from '../enums/puzzle-session-status.enum';

describe('PuzzleSessionStateService', () => {
let service: PuzzleSessionStateService;
let redis: any;

beforeEach(() => {
service = new PuzzleSessionStateService();
redis = (service as any).redis;
jest.spyOn(redis, 'get').mockResolvedValue(null);
jest.spyOn(redis, 'set').mockResolvedValue('OK');
jest.spyOn(redis, 'del').mockResolvedValue(1);
});

it('creates initial state', async () => {
const state = await service.create('session-1', 'puzzle-1');
expect(state.version).toBe(0);
expect(state.status).toBe(PuzzleSessionStatus.WAITING);
expect(redis.set).toHaveBeenCalled();
});

it('rejects a stale client version', async () => {
jest.spyOn(redis, 'get').mockResolvedValue(JSON.stringify({
sessionId: 'session-1',
puzzleId: 'puzzle-1',
version: 4,
status: PuzzleSessionStatus.ACTIVE,
players: {},
sharedProgress: { completedSteps: [], discoveredHints: [], solvedSteps: [] },
partialSolutions: {},
updatedAt: new Date().toISOString(),
}));

await expect(
service.mutate('session-1', 3, () => undefined),
).rejects.toThrow('STALE_SESSION_STATE:4');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { PuzzleSessionTimeoutService } from '../services/puzzle-session-timeout.service';

describe('PuzzleSessionTimeoutService', () => {
it('is defined', () => {
expect(PuzzleSessionTimeoutService).toBeDefined();
});
});
21 changes: 21 additions & 0 deletions src/multiplayer/__tests__/puzzle-session.e2e-spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/**
* E2E scenario for #407.
*
* Recommended flow:
* 1. Authenticate 10 users.
* 2. Create one puzzle session.
* 3. Join all 10 users through Socket.IO.
* 4. Assert all clients receive player/state events.
* 5. Submit partial solutions from multiple users.
* 6. Assert version increments and state convergence.
* 7. Disconnect one user.
* 8. Reconnect the user and assert latest state is restored.
* 9. Complete the puzzle.
* 10. Assert analytics/history persistence.
*/
describe('Multiplayer Puzzle Session E2E', () => {
it.todo('supports a 10-player collaborative session');
it.todo('recovers a disconnected player');
it.todo('rejects stale state updates');
it.todo('persists session history and analytics');
});
7 changes: 7 additions & 0 deletions src/multiplayer/__tests__/puzzle-session.gateway.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { PuzzleSessionsGateway } from '../gateways/puzzle-sessions.gateway';

describe('PuzzleSessionsGateway', () => {
it('is defined', () => {
expect(PuzzleSessionsGateway).toBeDefined();
});
});
9 changes: 9 additions & 0 deletions src/multiplayer/__tests__/puzzle-session.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { PuzzleSessionService } from '../services/puzzle-session.service';
import { PuzzleSessionStatus } from '../enums/puzzle-session-status.enum';

describe('PuzzleSessionService', () => {
it('has the expected service contract', () => {
expect(PuzzleSessionService).toBeDefined();
expect(PuzzleSessionStatus.ACTIVE).toBe('ACTIVE');
});
});
40 changes: 40 additions & 0 deletions src/multiplayer/controllers/puzzle-sessions.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import {
Body,
Controller,
Get,
Param,
Post,
Query,
Req,
} from '@nestjs/common';
import { PuzzleSessionService } from '../services/puzzle-session.service';
import { CreatePuzzleSessionDto } from '../dto/create-puzzle-session.dto';
import { PuzzleSessionQueryDto } from '../dto/puzzle-session-query.dto';

@Controller('api/v1/puzzle-sessions')
export class PuzzleSessionsController {
constructor(private readonly service: PuzzleSessionService) {}

@Post()
create(@Body() dto: CreatePuzzleSessionDto, @Req() req: any) {
return this.service.create(dto, req.user?.id ?? req.user?.sub);
}

@Get(':id')
get(@Param('id') id: string) {
return this.service.getState(id);
}

@Get(':id/history')
history(
@Param('id') id: string,
@Query() query: PuzzleSessionQueryDto,
) {
return this.service.history(id, query.limit, query.offset);
}

@Post(':id/complete')
complete(@Param('id') id: string, @Req() req: any) {
return this.service.complete(id, req.user?.id ?? req.user?.sub);
}
}
77 changes: 77 additions & 0 deletions src/multiplayer/db/001_create_puzzle_multiplayer_sessions.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
CREATE EXTENSION IF NOT EXISTS pgcrypto;

DO $$
BEGIN
CREATE TYPE puzzle_session_status AS ENUM (
'WAITING',
'ACTIVE',
'COMPLETED',
'EXPIRED',
'CANCELLED'
);
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;

DO $$
BEGIN
CREATE TYPE puzzle_player_status AS ENUM (
'CONNECTED',
'DISCONNECTED',
'LEFT'
);
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;

CREATE TABLE IF NOT EXISTS puzzle_sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
puzzle_id VARCHAR(128) NOT NULL,
status puzzle_session_status NOT NULL DEFAULT 'WAITING',
max_players INTEGER NOT NULL DEFAULT 10,
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE IF NOT EXISTS puzzle_session_players (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
session_id UUID NOT NULL REFERENCES puzzle_sessions(id) ON DELETE CASCADE,
user_id VARCHAR(128) NOT NULL,
status puzzle_player_status NOT NULL DEFAULT 'CONNECTED',
score INTEGER NOT NULL DEFAULT 0,
progress DOUBLE PRECISION NOT NULL DEFAULT 0,
joined_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
left_at TIMESTAMPTZ,
UNIQUE(session_id, user_id)
);

CREATE INDEX IF NOT EXISTS idx_puzzle_session_players_session
ON puzzle_session_players(session_id);

CREATE TABLE IF NOT EXISTS puzzle_session_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
session_id UUID NOT NULL REFERENCES puzzle_sessions(id) ON DELETE CASCADE,
user_id VARCHAR(128),
type VARCHAR(64) NOT NULL,
payload JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX IF NOT EXISTS idx_puzzle_session_events_session_created
ON puzzle_session_events(session_id, created_at DESC);

CREATE TABLE IF NOT EXISTS puzzle_session_solutions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
session_id UUID NOT NULL REFERENCES puzzle_sessions(id) ON DELETE CASCADE,
user_id VARCHAR(128) NOT NULL,
step_id VARCHAR(128),
content TEXT NOT NULL,
is_correct BOOLEAN NOT NULL DEFAULT FALSE,
confidence DOUBLE PRECISION,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
16 changes: 16 additions & 0 deletions src/multiplayer/dto/create-puzzle-session.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { IsInt, IsString, Max, Min } from 'class-validator';

export class CreatePuzzleSessionDto {
@IsString()
puzzleId: string;

@IsInt()
@Min(2)
@Max(50)
maxPlayers = 10;

@IsInt()
@Min(60)
@Max(7200)
durationSeconds = 1800;
}
6 changes: 6 additions & 0 deletions src/multiplayer/dto/join-puzzle-session.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { IsUUID } from 'class-validator';

export class JoinPuzzleSessionDto {
@IsUUID()
sessionId: string;
}
17 changes: 17 additions & 0 deletions src/multiplayer/dto/puzzle-session-query.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { IsInt, IsOptional, IsPositive, Max } from 'class-validator';
import { Type } from 'class-transformer';

export class PuzzleSessionQueryDto {
@IsOptional()
@Type(() => Number)
@IsInt()
@IsPositive()
@Max(100)
limit = 20;

@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
offset = 0;
}
23 changes: 23 additions & 0 deletions src/multiplayer/dto/submit-solution.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { IsNumber, IsOptional, IsString, IsUUID, Min } from 'class-validator';

export class SubmitSolutionDto {
@IsUUID()
sessionId: string;

@IsString()
content: string;

@IsOptional()
@IsString()
stepId?: string;

@IsOptional()
@IsNumber()
@Min(0)
confidence?: number;

@IsOptional()
@IsNumber()
@Min(0)
clientVersion?: number;
}
23 changes: 23 additions & 0 deletions src/multiplayer/dto/update-solution.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { IsNumber, IsOptional, IsString, IsUUID, Min } from 'class-validator';

export class UpdatePartialSolutionDto {
@IsUUID()
sessionId: string;

@IsString()
content: string;

@IsOptional()
@IsString()
stepId?: string;

@IsOptional()
@IsNumber()
@Min(0)
confidence?: number;

@IsOptional()
@IsNumber()
@Min(0)
clientVersion?: number;
}
22 changes: 22 additions & 0 deletions src/multiplayer/entities/puzzle-session-event.entity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn } from 'typeorm';

@Entity('puzzle_session_events')
export class PuzzleSessionEvent {
@PrimaryGeneratedColumn('uuid')
id: string;

@Column({ name: 'session_id' })
sessionId: string;

@Column({ name: 'user_id', nullable: true, length: 128 })
userId: string | null;

@Column({ length: 64 })
type: string;

@Column({ type: 'jsonb', nullable: true })
payload: Record<string, unknown> | null;

@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt: Date;
}
42 changes: 42 additions & 0 deletions src/multiplayer/entities/puzzle-session-player.entity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import {
Column,
Entity,
Index,
PrimaryGeneratedColumn,
} from 'typeorm';
import { PuzzlePlayerStatus } from '../enums/puzzle-player-status.enum';

@Entity('puzzle_session_players')
@Index(['sessionId', 'userId'], { unique: true })
export class PuzzleSessionPlayer {
@PrimaryGeneratedColumn('uuid')
id: string;

@Column({ name: 'session_id' })
sessionId: string;

@Column({ name: 'user_id', length: 128 })
userId: string;

@Column({
type: 'enum',
enum: PuzzlePlayerStatus,
default: PuzzlePlayerStatus.CONNECTED,
})
status: PuzzlePlayerStatus;

@Column({ name: 'score', default: 0 })
score: number;

@Column({ name: 'progress', type: 'float', default: 0 })
progress: number;

@Column({ name: 'joined_at', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' })
joinedAt: Date;

@Column({ name: 'last_seen_at', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' })
lastSeenAt: Date;

@Column({ name: 'left_at', type: 'timestamptz', nullable: true })
leftAt: Date | null;
}
Loading
Loading