Skip to content
Open
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
105 changes: 104 additions & 1 deletion backend/src/api/rooms.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,25 @@
import { Router } from "express";
import {
createRoomSchema,
drawingSchema,
guessSchema,
HttpError,
joinRoomSchema,
participantActionSchema,
roomCodeParamsSchema,
roomViewerQuerySchema
} from "./schemas.js";
import { createRoom, getRoom, joinRoom, toRoomSnapshot } from "../services/roomStore.js";
import {
clearDrawing,
createRoom,
getRoom,
joinRoom,
restartGame,
startGame,
submitGuess,
toRoomSnapshot,
updateDrawing
} from "../services/roomStore.js";

export function createRoomsRouter() {
const router = Router();
Expand Down Expand Up @@ -44,6 +57,96 @@ export function createRoomsRouter() {
}
});

router.post("/:code/start", (request, response, next) => {
try {
const { code } = roomCodeParamsSchema.parse(request.params);
const { participantId } = participantActionSchema.parse(request.body);
const room = startGame(code.toUpperCase(), participantId);

if (!room) {
throw new HttpError(404, "Unable to start game");
}

response.json({
room: toRoomSnapshot(room, participantId)
});
} catch (error) {
next(error);
}
});

router.post("/:code/drawing", (request, response, next) => {
try {
const { code } = roomCodeParamsSchema.parse(request.params);
const { participantId, drawingDataUrl } = drawingSchema.parse(request.body);
const room = updateDrawing(code.toUpperCase(), participantId, drawingDataUrl);

if (!room) {
throw new HttpError(404, "Unable to update drawing");
}

response.json({
room: toRoomSnapshot(room, participantId)
});
} catch (error) {
next(error);
}
});

router.post("/:code/clear", (request, response, next) => {
try {
const { code } = roomCodeParamsSchema.parse(request.params);
const { participantId } = participantActionSchema.parse(request.body);
const room = clearDrawing(code.toUpperCase(), participantId);

if (!room) {
throw new HttpError(404, "Unable to clear drawing");
}

response.json({
room: toRoomSnapshot(room, participantId)
});
} catch (error) {
next(error);
}
});

router.post("/:code/guesses", (request, response, next) => {
try {
const { code } = roomCodeParamsSchema.parse(request.params);
const { participantId, guess } = guessSchema.parse(request.body);
const room = submitGuess(code.toUpperCase(), participantId, guess);

if (!room) {
throw new HttpError(404, "Unable to submit guess");
}

response.json({
room: toRoomSnapshot(room, participantId)
});
} catch (error) {
next(error);
}
});

router.post("/:code/restart", (request, response, next) => {
try {
const { code } = roomCodeParamsSchema.parse(request.params);
const { participantId } = participantActionSchema.parse(request.body);
const room = restartGame(code.toUpperCase(), participantId);

if (!room) {
throw new HttpError(404, "Unable to restart game");
}

response.json({
room: toRoomSnapshot(room, participantId)
});
} catch (error) {
next(error);
}
});

router.get("/:code", (request, response, next) => {
try {
const { code } = roomCodeParamsSchema.parse(request.params);
Expand Down
5 changes: 3 additions & 2 deletions backend/src/api/router.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { NextFunction, Request, Response } from "express";
import { Router } from "express";
import { ZodError } from "zod";
import { createRoomsRouter } from "./rooms.js";

export function createApiRouter() {
Expand Down Expand Up @@ -29,8 +30,8 @@ export function errorHandler(
response: Response,
_next: NextFunction
) {
if (error.name === "ZodError") {
response.status(400).json({ message: "Invalid request payload" });
if (error instanceof ZodError) {
response.status(400).json({ message: error.issues[0]?.message ?? "Invalid request payload" });
return;
}

Expand Down
20 changes: 18 additions & 2 deletions backend/src/api/schemas.test.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,30 @@
import { describe, expect, it } from "vitest";
import { createRoomSchema, roomCodeParamsSchema } from "./schemas.js";
import { createRoomSchema, guessSchema, roomCodeParamsSchema } from "./schemas.js";

describe("schemas", () => {
it("createRoomSchema accepts a valid body with playerName", () => {
const result = createRoomSchema.parse({ playerName: "Alice" });
const result = createRoomSchema.parse({ playerName: " Alice " });

expect(result.playerName).toBe("Alice");
});

it("createRoomSchema rejects empty player names", () => {
expect(() => createRoomSchema.parse({ playerName: " " })).toThrow("Player name is required");
});

it("roomCodeParamsSchema rejects missing code", () => {
expect(() => roomCodeParamsSchema.parse({})).toThrow();
});

it("roomCodeParamsSchema rejects malformed room codes", () => {
expect(() => roomCodeParamsSchema.parse({ code: "A" })).toThrow(
"Room code must be 4 letters or numbers"
);
});

it("guessSchema trims valid guesses", () => {
const result = guessSchema.parse({ participantId: "p1", guess: " Rocket " });

expect(result.guess).toBe("Rocket");
});
});
38 changes: 35 additions & 3 deletions backend/src/api/schemas.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,53 @@
import { z } from "zod";

const requiredTrimmedString = (message: string) =>
z
.string({
required_error: message,
invalid_type_error: message
})
.trim()
.min(1, message);

const participantIdSchema = requiredTrimmedString("Participant id is required");

export const createRoomSchema = z.object({
playerName: z.string().optional()
playerName: requiredTrimmedString("Player name is required")
});

export const joinRoomSchema = z.object({
playerName: z.string().optional()
playerName: requiredTrimmedString("Player name is required")
});

export const roomCodeParamsSchema = z.object({
code: z.string()
code: requiredTrimmedString("Room code is required").regex(
/^[A-Za-z0-9]{4}$/,
"Room code must be 4 letters or numbers"
)
});

export const roomViewerQuerySchema = z.object({
participantId: z.string().optional()
});

export const participantActionSchema = z.object({
participantId: participantIdSchema
});

export const drawingSchema = participantActionSchema.extend({
drawingDataUrl: z
.string({
invalid_type_error: "Drawing data must be a data URL"
})
.startsWith("data:image/png;base64,", "Drawing data must be a PNG data URL")
.max(1_000_000, "Drawing data is too large")
.nullable()
});

export const guessSchema = participantActionSchema.extend({
guess: requiredTrimmedString("Guess is required")
});

export class HttpError extends Error {
statusCode: number;

Expand Down
45 changes: 44 additions & 1 deletion backend/src/models/game.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,69 @@
export type ParticipantRole = "drawer" | "guesser";
export type RoomStatus = "lobby";
export type RoomStatus = "lobby" | "playing" | "result";

export interface Participant {
id: string;
name: string;
isHost: boolean;
joinedAt: string;
}

export interface Guess {
id: string;
participantId: string;
playerName: string;
text: string;
correct: boolean;
scoreDelta: number;
createdAt: string;
}

export interface Round {
drawerId: string;
word: string;
drawingDataUrl: string | null;
guesses: Guess[];
startedAt: string;
endedAt: string | null;
}

export interface Room {
code: string;
status: RoomStatus;
hostId: string;
participants: Participant[];
scores: Record<string, number>;
round: Round | null;
createdAt: string;
updatedAt: string;
}

export interface ScoreEntry {
participantId: string;
playerName: string;
score: number;
}

export interface RoomResult {
word: string;
endedAt: string;
}

export interface RoomSnapshot {
code: string;
status: RoomStatus;
hostId: string;
participants: Participant[];
availableWords: string[];
roles: ParticipantRole[];
drawerId: string | null;
drawerName: string | null;
secretWord: string | null;
wordLength: number | null;
drawingDataUrl: string | null;
guesses: Guess[];
scores: ScoreEntry[];
result: RoomResult | null;
}

export interface RoomSessionResponse {
Expand Down
Loading