diff --git a/.github/workflows/CI.yaml b/.github/workflows/CI.yaml new file mode 100644 index 0000000..35c0cc2 --- /dev/null +++ b/.github/workflows/CI.yaml @@ -0,0 +1,39 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + +jobs: + deep-sea-stories: + runs-on: ubuntu-latest + defaults: + run: + working-directory: deep-sea-stories + + steps: + - name: Checkout code + uses: actions/checkout@v5 + + - name: Enable corepack + run: corepack enable + + - name: Set up Node.js + uses: actions/setup-node@v5 + with: + node-version-file: ./deep-sea-stories/package.json + cache: yarn + cache-dependency-path: ./deep-sea-stories/yarn.lock + + - name: Install dependencies + run: yarn --immutable + + - name: Run format + run: yarn format:check + + - name: Run lint + run: yarn lint + + - name: Run backend tests + run: yarn workspace backend test diff --git a/.github/workflows/deep-sea-stories-deploy.yml b/.github/workflows/deep-sea-stories-deploy.yml new file mode 100644 index 0000000..5c0a9e9 --- /dev/null +++ b/.github/workflows/deep-sea-stories-deploy.yml @@ -0,0 +1,33 @@ +name: Deploy deep-sea-stories + +on: + push: + branches: ["main"] + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Deploy + uses: appleboy/ssh-action@v0.1.7 + with: + host: ${{ secrets.VM_HOST }} + username: ${{ secrets.VM_USERNAME }} + key: ${{ secrets.VM_SSH_KEY }} + script: | + cd ~/examples/deep-sea-stories + git switch main + git pull + + umask 077 + cat > .env << 'EOF' + + FISHJAM_ID=${{ secrets.FISHJAM_ID }} + FISHJAM_MANAGEMENT_TOKEN=${{ secrets.FISHJAM_MANAGEMENT_TOKEN }} + ELEVENLABS_API_KEY=${{ secrets.ELEVENLABS_API_KEY }} + + EOF + + docker compose down + docker compose up -d --build \ No newline at end of file diff --git a/deep-sea-stories/.gitignore b/deep-sea-stories/.gitignore new file mode 100644 index 0000000..fd2d95b --- /dev/null +++ b/deep-sea-stories/.gitignore @@ -0,0 +1,170 @@ +### Node ### +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) +web_modules/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional stylelint cache +.stylelintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next +out + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# vuepress v2.x temp and cache directory +.temp + +# Docusaurus cache and generated files +.docusaurus + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# yarn v2 +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* + +### Node Patch ### +# Serverless Webpack directories +.webpack/ + +# Optional stylelint cache + +# SvelteKit build / generate output +.svelte-kit + +### react ### +.DS_* +**/*.backup.* +**/*.back.* + +node_modules + +*.sublime* + +psd +thumb +sketch + +### yarn ### +# https://yarnpkg.com/getting-started/qa#which-files-should-be-gitignored + +.yarn/* +!.yarn/releases +!.yarn/patches +!.yarn/plugins +!.yarn/sdks +!.yarn/versions + +# if you are NOT using Zero-installs, then: +# comment the following lines +!.yarn/cache + +# and uncomment the following lines +# .pnp.* + diff --git a/deep-sea-stories/.yarnrc.yml b/deep-sea-stories/.yarnrc.yml new file mode 100644 index 0000000..3186f3f --- /dev/null +++ b/deep-sea-stories/.yarnrc.yml @@ -0,0 +1 @@ +nodeLinker: node-modules diff --git a/deep-sea-stories/README.md b/deep-sea-stories/README.md new file mode 100644 index 0000000..cf21f1a --- /dev/null +++ b/deep-sea-stories/README.md @@ -0,0 +1 @@ +# Deep Sea Stories Example App diff --git a/deep-sea-stories/biome.json b/deep-sea-stories/biome.json new file mode 100644 index 0000000..c5c9476 --- /dev/null +++ b/deep-sea-stories/biome.json @@ -0,0 +1,35 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.2.4/schema.json", + "vcs": { + "enabled": false, + "clientKind": "git", + "useIgnoreFile": false + }, + "files": { + "includes": ["**", "!**/dist"], + "ignoreUnknown": false + }, + "formatter": { + "enabled": true, + "indentStyle": "tab" + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true + } + }, + "javascript": { + "formatter": { + "quoteStyle": "single" + } + }, + "assist": { + "enabled": true, + "actions": { + "source": { + "organizeImports": "on" + } + } + } +} diff --git a/deep-sea-stories/docker-compose.yml b/deep-sea-stories/docker-compose.yml new file mode 100644 index 0000000..ff2e90c --- /dev/null +++ b/deep-sea-stories/docker-compose.yml @@ -0,0 +1,38 @@ +services: + backend: + build: + context: . + dockerfile: packages/backend/Dockerfile + ports: + - "8000:8000" + environment: + - NODE_ENV=production + - PORT=8000 + - FISHJAM_ID=${FISHJAM_ID} + - FISHJAM_MANAGEMENT_TOKEN=${FISHJAM_MANAGEMENT_TOKEN} + - ELEVENLABS_API_KEY=${ELEVENLABS_API_KEY} + restart: unless-stopped + + web: + build: + context: . + dockerfile: packages/web/Dockerfile + args: + - VITE_FISHJAM_ID=${FISHJAM_ID} + - VITE_BACKEND_URL=${BACKEND_URL:-http://nginx/api} + environment: + - VITE_BACKEND_URL=http://nginx/api + restart: unless-stopped + depends_on: + - backend + + nginx: + image: nginx:alpine + ports: + - "5000:80" + volumes: + - ./nginx.conf:/etc/nginx/nginx.conf:ro + depends_on: + - backend + - web + restart: unless-stopped \ No newline at end of file diff --git a/deep-sea-stories/nginx.conf b/deep-sea-stories/nginx.conf new file mode 100644 index 0000000..48b1345 --- /dev/null +++ b/deep-sea-stories/nginx.conf @@ -0,0 +1,92 @@ +user nginx; +worker_processes auto; +error_log /var/log/nginx/error.log warn; +pid /var/run/nginx.pid; + +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + + access_log /var/log/nginx/access.log main; + + sendfile on; + tcp_nopush on; + tcp_nodelay on; + keepalive_timeout 65; + types_hash_max_size 2048; + + # Gzip compression + gzip on; + gzip_vary on; + gzip_types text/plain text/css text/xml text/javascript + application/x-javascript application/xml+rss + application/javascript application/json; + + upstream backend { + server backend:8000; + } + + upstream web { + server web:3000; + } + + server { + listen 80; + server_name _; + client_max_body_size 100M; + + # Backend API routes + location /api { + proxy_pass http://backend; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_redirect off; + } + + # WebSocket support for backend + location /socket { + proxy_pass http://backend; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_redirect off; + } + + # Frontend - all other requests + location / { + proxy_pass http://web; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_redirect off; + } + + # Health check endpoint + location /health { + access_log off; + return 200 "healthy\n"; + add_header Content-Type text/plain; + } + } +} diff --git a/deep-sea-stories/package.json b/deep-sea-stories/package.json new file mode 100644 index 0000000..1168ced --- /dev/null +++ b/deep-sea-stories/package.json @@ -0,0 +1,23 @@ +{ + "name": "deep-sea-stories", + "packageManager": "yarn@4.9.2", + "private": true, + "workspaces": [ + "packages/*" + ], + "devDependencies": { + "@biomejs/biome": "2.2.4", + "typescript": "^5.9.2" + }, + "scripts": { + "build": "yarn workspaces foreach -A -p run build", + "format": "biome format --write", + "format:check": "biome format", + "lint": "biome lint", + "check": "biome check --write", + "typecheck": "yarn workspaces foreach -A -p run typecheck" + }, + "engines": { + "node": ">= 24.0.0" + } +} diff --git a/deep-sea-stories/packages/backend/.env.example b/deep-sea-stories/packages/backend/.env.example new file mode 100644 index 0000000..429ef28 --- /dev/null +++ b/deep-sea-stories/packages/backend/.env.example @@ -0,0 +1,3 @@ +FISHJAM_ID="your-fishjam-id" +FISHJAM_MANAGEMENT_TOKEN="your-management-token" +ELEVENLABS_API_KEY="your-elevenlabs-api-key" diff --git a/deep-sea-stories/packages/backend/Dockerfile b/deep-sea-stories/packages/backend/Dockerfile new file mode 100644 index 0000000..c30be57 --- /dev/null +++ b/deep-sea-stories/packages/backend/Dockerfile @@ -0,0 +1,35 @@ +FROM node:24-alpine AS builder + +WORKDIR /app + +RUN corepack enable + +COPY package.json yarn.lock .yarnrc.yml ./ +COPY packages ./packages + +RUN yarn install --frozen-lockfile + +RUN yarn workspace backend build + +FROM node:24-alpine AS runner + +WORKDIR /app + +RUN apk add --no-cache dumb-init +RUN corepack enable + +COPY package.json yarn.lock .yarnrc.yml ./ +COPY packages ./packages + +RUN yarn install --frozen-lockfile + +COPY --from=builder /app/packages/backend/dist ./packages/backend/dist + +RUN addgroup -g 1001 -S nodejs && adduser -S nodejs -u 1001 +USER nodejs + +EXPOSE 8000 + +ENTRYPOINT ["dumb-init", "--"] + +CMD ["node", "packages/backend/dist/src/main.js"] diff --git a/deep-sea-stories/packages/backend/package.json b/deep-sea-stories/packages/backend/package.json new file mode 100644 index 0000000..1ef3e6a --- /dev/null +++ b/deep-sea-stories/packages/backend/package.json @@ -0,0 +1,32 @@ +{ + "name": "backend", + "private": true, + "main": "./src/main.ts", + "type": "module", + "dependencies": { + "@elevenlabs/elevenlabs-js": "^2.19.0", + "@fastify/cors": "^11.1.0", + "@fishjam-cloud/js-server-sdk": "^0.22.0", + "@trpc/server": "^11.6.0", + "@types/nunjucks": "^3.2.6", + "@types/ws": "^8.18.1", + "dotenv": "^17.2.3", + "fastify": "^5.6.1", + "nunjucks": "^3.2.4", + "pino-pretty": "^13.1.1", + "zod": "^4.1.11" + }, + "devDependencies": { + "@tsconfig/node24": "^24.0.1", + "@types/node": "^24.5.2", + "tsx": "^4.20.6", + "typescript": "^5.9.2" + }, + "scripts": { + "build": "tsc -p tsconfig.json && mkdir -p dist/src/prompts && cp -R src/prompts/* dist/src/prompts/", + "start": "tsx watch src/main.ts", + "typecheck": "tsc --noEmit", + "test": "tsx --test --test-global-setup=./tests/setup-module.ts", + "test:watch": "tsx --test --watch --test-global-setup=./tests/setup-module.ts" + } +} diff --git a/deep-sea-stories/packages/backend/src/config.ts b/deep-sea-stories/packages/backend/src/config.ts new file mode 100644 index 0000000..4b0567a --- /dev/null +++ b/deep-sea-stories/packages/backend/src/config.ts @@ -0,0 +1,36 @@ +import dotenv from 'dotenv'; +import z from 'zod'; +import * as fs from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; +import type { Story } from './types.js'; +import type { PeerOptions } from '@fishjam-cloud/js-server-sdk'; + +dotenv.config({ quiet: true }); + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +export const configSchema = z.object({ + PORT: z.coerce.number().int().default(8000), + FISHJAM_ID: z.string(), + FISHJAM_MANAGEMENT_TOKEN: z.string(), + ELEVENLABS_API_KEY: z.string(), +}); + +export const stories: Story[] = JSON.parse( + fs.readFileSync(join(__dirname, 'prompts', 'stories.json'), 'utf8'), +); + +export const CONFIG = configSchema.parse(process.env); + +export const AGENT_INSTRUCTIONS_TEMPLATE = fs.readFileSync( + join(__dirname, 'prompts', 'instructions-template.md'), + 'utf8', +); + +export const FISHJAM_AGENT_OPTIONS: PeerOptions = { + output: { + audioSampleRate: 16_000, + }, +}; diff --git a/deep-sea-stories/packages/backend/src/context.ts b/deep-sea-stories/packages/backend/src/context.ts new file mode 100644 index 0000000..7decc11 --- /dev/null +++ b/deep-sea-stories/packages/backend/src/context.ts @@ -0,0 +1,14 @@ +import { FishjamClient } from '@fishjam-cloud/js-server-sdk'; +import type { CreateFastifyContextOptions } from '@trpc/server/adapters/fastify'; +import { CONFIG } from './config.js'; + +const fishjam = new FishjamClient({ + fishjamId: CONFIG.FISHJAM_ID, + managementToken: CONFIG.FISHJAM_MANAGEMENT_TOKEN, +}); + +export function createContext({ req, res }: CreateFastifyContextOptions) { + return { req, res, fishjam }; +} + +export type Context = Awaited>; diff --git a/deep-sea-stories/packages/backend/src/controllers/peers.ts b/deep-sea-stories/packages/backend/src/controllers/peers.ts new file mode 100644 index 0000000..6f5390f --- /dev/null +++ b/deep-sea-stories/packages/backend/src/controllers/peers.ts @@ -0,0 +1,38 @@ +import type { RoomId } from '@fishjam-cloud/js-server-sdk'; +import { createPeerInputSchema } from '../schemas.js'; +import { publicProcedure } from '../trpc.js'; +import { roomService } from '../service/room.js'; +import { GameSession } from '../service/game-session.js'; + +export const createPeer = publicProcedure + .input(createPeerInputSchema) + .mutation(async ({ ctx, input }) => { + const room = await ctx.fishjam.getRoom(input.roomId as RoomId); + if (!room) { + throw new Error(`Room with id ${input.roomId} does not exist`); + } + + let gameSession = roomService.getGameSession(room.id); + if (!gameSession) { + gameSession = new GameSession(room.id); + roomService.setGameSession(room.id, gameSession); + } + + const roomAgent = gameSession.getFishjamAgent(); + if (room.peers.length > 0 && !roomAgent) { + throw new Error( + `Room with id ${input.roomId} already has a peer and no agent`, + ); + } + + if (room.peers.length === 0) { + await gameSession.createFishjamAgent(ctx.fishjam); + } + + const { peer, peerToken } = await gameSession.createPeer(ctx.fishjam); + + return { + peer: peer, + token: peerToken, + }; + }); diff --git a/deep-sea-stories/packages/backend/src/controllers/rooms.ts b/deep-sea-stories/packages/backend/src/controllers/rooms.ts new file mode 100644 index 0000000..2080b63 --- /dev/null +++ b/deep-sea-stories/packages/backend/src/controllers/rooms.ts @@ -0,0 +1,14 @@ +import type { RoomId } from '@fishjam-cloud/js-server-sdk'; +import { getRoomInputSchema } from '../schemas.js'; +import { publicProcedure } from '../trpc.js'; + +export const createRoom = publicProcedure.mutation(async ({ ctx }) => { + const room = await ctx.fishjam.createRoom(); + return room; +}); + +export const getRoom = publicProcedure + .input(getRoomInputSchema) + .query(async ({ ctx, input }) => { + return await ctx.fishjam.getRoom(input.roomId as RoomId); + }); diff --git a/deep-sea-stories/packages/backend/src/controllers/stories.ts b/deep-sea-stories/packages/backend/src/controllers/stories.ts new file mode 100644 index 0000000..074fffb --- /dev/null +++ b/deep-sea-stories/packages/backend/src/controllers/stories.ts @@ -0,0 +1,39 @@ +import { publicProcedure } from '../trpc.js'; +import { stories } from '../config.js'; +import { startStoryInputSchema } from '../schemas.js'; +import type { RoomId } from '@fishjam-cloud/js-server-sdk'; +import { roomService } from '../service/room.js'; +import { FailedToStartStoryError } from '../domain/errors.js'; + +export const startStory = publicProcedure + .input(startStoryInputSchema) + .mutation(async ({ input }) => { + const selectedStory = stories.find((s) => s.id === input.storyId); + if (!selectedStory) { + throw new Error(`Story with id ${input.storyId} does not exist`); + } + + try { + const gameSession = roomService.getGameSession(input.roomId as RoomId); + await gameSession?.startGame(selectedStory); + + return { + success: true, + message: `Story "${input.storyId}" started successfully`, + }; + } catch (error) { + console.error(`Failed to start story: %o`, error); + throw new FailedToStartStoryError( + input.storyId, + (error as Error).message, + ); + } + }); + +export const getStories = publicProcedure.query(() => { + return stories.map((s) => ({ + id: s.id, + title: s.title, + front: s.front, + })); +}); diff --git a/deep-sea-stories/packages/backend/src/domain/errors.ts b/deep-sea-stories/packages/backend/src/domain/errors.ts new file mode 100644 index 0000000..bfcebac --- /dev/null +++ b/deep-sea-stories/packages/backend/src/domain/errors.ts @@ -0,0 +1,68 @@ +export abstract class DomainError extends Error { + code: string; + statusCode: number; + + constructor(code: string, message: string, statusCode: number = 500) { + super(message); + this.code = code; + this.statusCode = statusCode; + } +} + +export class GameSessionNotFoundError extends DomainError { + constructor(roomId: string) { + super( + 'GAME_SESSION_NOT_FOUND', + `No game session found for room ${roomId}`, + 404, + ); + this.name = 'GameSessionNotFoundError'; + } +} + +export class StoryNotFoundError extends DomainError { + constructor(roomId: string) { + super('STORY_NOT_FOUND', `No story available for room ${roomId}`, 400); + this.name = 'StoryNotFoundError'; + } +} + +export class NoPeersConnectedError extends DomainError { + constructor(roomId: string) { + super('NO_PEERS_CONNECTED', `No connected peers in room ${roomId}`, 400); + this.name = 'NoPeersConnectedError'; + } +} + +export class NoVoiceSessionManagerError extends DomainError { + constructor(roomId: string) { + super( + 'NO_VOICE_SESSION_MANAGER', + `No voice session manager configured for room ${roomId}`, + 500, + ); + this.name = 'NoVoiceSessionManagerError'; + } +} + +export class AudioConnectionError extends DomainError { + constructor(peerId: string, reason: string) { + super( + 'AUDIO_CONNECTION_ERROR', + `Failed to establish audio connection for peer ${peerId}: ${reason}`, + 500, + ); + this.name = 'AudioConnectionError'; + } +} + +export class FailedToStartStoryError extends DomainError { + constructor(storyId: number, reason: string) { + super( + 'FAILED_TO_START_STORY', + `Failed to start story ${storyId}: ${reason}`, + 500, + ); + this.name = 'FailedToStartStoryError'; + } +} diff --git a/deep-sea-stories/packages/backend/src/main.ts b/deep-sea-stories/packages/backend/src/main.ts new file mode 100644 index 0000000..b37c6ec --- /dev/null +++ b/deep-sea-stories/packages/backend/src/main.ts @@ -0,0 +1,42 @@ +import { + type FastifyTRPCPluginOptions, + fastifyTRPCPlugin, +} from '@trpc/server/adapters/fastify'; +import Fastify from 'fastify'; +import cors from '@fastify/cors'; +import { CONFIG } from './config.js'; +import { createContext } from './context.js'; +import { type AppRouter, appRouter } from './router.js'; +import { notifierService } from './service/notifier.js'; + +const fastify = Fastify({ + logger: { transport: { target: 'pino-pretty' } }, +}); + +await fastify.register(cors, { + origin: true, + credentials: true, +}); + +fastify.register(fastifyTRPCPlugin, { + prefix: '/api/v1', + trpcOptions: { + router: appRouter, + createContext, + onError({ path, error }) { + fastify.log.error('Error in tRPC handler on path %s: %O', path, error); + }, + } satisfies FastifyTRPCPluginOptions['trpcOptions'], +}); + +try { + await notifierService.initialize(); + + await fastify.ready(); + await fastify.listen({ port: CONFIG.PORT }); +} catch (err) { + fastify.log.error(err); + process.exit(1); +} + +export type { AppRouter }; diff --git a/deep-sea-stories/packages/backend/src/prompts/instructions-template.md b/deep-sea-stories/packages/backend/src/prompts/instructions-template.md new file mode 100644 index 0000000..5f2c863 --- /dev/null +++ b/deep-sea-stories/packages/backend/src/prompts/instructions-template.md @@ -0,0 +1,45 @@ +We will play a game called Black Stories +Game Rules + +Black Stories is a storytelling and guessing game designed to challenge players' deductive reasoning skills through mysterious and often morbid scenarios. Here's how it generally works: + + Players: The game requires at least two players – one person acts as the "riddle master" (the one who knows the full story of the scenario), and the rest are the guessers. + + + Game Cards: The game consists of cards, each containing a brief and cryptic description of a mysterious, usually dark incident on the front, such as an unusual death or crime. The back of the card reveals the full backstory and how the scenario occurred. + + + Objective: The goal for the guessers is to reconstruct the full story of the scenario using yes-or-no questions based on the limited information provided. + + + Gameplay: + + The riddle master reads the initial scenario or mystery to the guessers. + + The guessers ask the riddle master questions that can only be answered with "yes" or "no". + + The riddle master responds truthfully to each question. + + If the guessers think they have correctly pieced together the backstory of the scenario, as detailed on the back of the card, then they may tell the riddle master the full story. + + If they guess all the key aspects of the backstory correctly, then they win. The riddle master tells them that they have won and reads them the exact wording of the back of the card. Otherwise, the game continues and the riddle master may try to give a suggestion to a part of the story that the guessers have not included in their answer. + + + + Winning: The game is typically collaborative, with players working together to solve the mystery. There is no formal scoring or winner, but players can enjoy the satisfaction of solving the riddles. + How we will play +Your role + + You will play as the riddle master. + + +My role + + I will play as a guesser. + +Your card content is: + +### Front of the Card +{{ FRONT }} +### Back of the Card +{{ BACK }} \ No newline at end of file diff --git a/deep-sea-stories/packages/backend/src/prompts/stories.json b/deep-sea-stories/packages/backend/src/prompts/stories.json new file mode 100644 index 0000000..2641a55 --- /dev/null +++ b/deep-sea-stories/packages/backend/src/prompts/stories.json @@ -0,0 +1,62 @@ +[ + { + "id": 1, + "title": "Dead Skydiver in a Field", + "front": "A man was found dead in the middle of a field. There were no footprints around him and he had nothing in his hands. The only thing on him was a small parachute harness without a parachute attached.", + "back": "The man was a skydiver. He had gone on a jump with a group of skydivers and had accidentally grabbed a faulty parachute. Mid-air, when he realized it was not functional, he attempted to fix it but couldn't. As he descended, the wind blew him away from the group, causing him to land in the open field, without any footprints nearby due to being airborne before the landing." + }, + { + "id": 2, + "title": "Locked Apartment Mystery", + "front": "A man is found dead in his locked apartment. The door is bolted from the inside, all the windows are closed, and there are no signs of forced entry. Next to him lies a puddle of water and some shattered glass.", + "back": "The man had kept a large ice block propped against the door as an improvised barricade, fearing someone was after him. When he sat down to rest, the ice eventually melted, leaving only the puddle of water. The door closed and locked itself, and no one had to enter. He later died of unrelated causes (a heart attack)." + }, + { + "id": 3, + "title": "Dead Men in a Mountain Cabin", + "front": "Two men are found dead in a cabin on a mountain. The windows are shattered, but there are no footprints leading away from the cabin.", + "back": "The men had been flying in a small airplane that crashed into the mountainside. What the rescuers thought was a 'cabin' was actually the remains of the airplane fuselage. They died on impact, and the shattered 'windows' were airplane windows, not a house." + }, + { + "id": 4, + "title": "Death by Thirst in a Bar", + "front": "A man dies of thirst in the middle of a bar full of people. Nobody helped him.", + "back": "The bar was not a drinking establishment, but a sandbar in the desert. He was stranded, saw no one around, and perished from dehydration. The wording misleads the guessers into thinking of an alcohol-serving bar." + }, + { + "id": 5, + "title": "Library Blunt Force Trauma", + "front": "A body is found in a library, with dozens of books scattered around. Cause of death: blunt force trauma. There are no weapons nearby.", + "back": "The man died when a heavy bookshelf collapsed on him while he was trying to reach a book from the top. The books scattered everywhere during the fall, concealing the true cause of his death until closer inspection." + }, + { + "id": 6, + "title": "Hanging in an Empty Room", + "front": "A man is found hanged in a room with a ceiling 4 meters high. There is no chair, no furniture, and the floor is bare.", + "back": "The man stood on a large block of ice to hang himself. Over time, the ice melted completely, leaving no trace and creating the illusion of an impossible hanging." + }, + { + "id": 7, + "title": "Sailor with Sand-Filled Pockets", + "front": "A sailor is found dead on the deck of his ship. His pockets are full of sand, and there are no signs of injury.", + "back": "The sailor had been rescued after a shipwreck. During the accident, he swallowed a large amount of seawater containing sand and silt. Though he survived the wreck, he later collapsed and died from internal damage caused by inhaling sand-filled water into his lungs." + }, + { + "id": 8, + "title": "Death in a Phone Booth", + "front": "A man lies dead in a phone booth. The glass is shattered, and he is clutching torn pages from a phone book.", + "back": "The man was a fisherman who had boasted about the size of a fish he caught. His friends didn’t believe him, so he rushed to the phone booth to call and confirm it with someone. He angrily tore pages looking for the number but accidentally broke the glass and cut himself fatally in his frustration." + }, + { + "id": 9, + "title": "Smiling Woman and Static TV", + "front": "A woman is found dead in front of her television. The TV is on, but the screen shows only static. She has a smile on her face.", + "back": "The woman had been terminally ill and bedridden. She watched a TV show religiously every week, and on the night of her death, the show aired its final episode. She smiled, having seen the ending she long awaited, and then peacefully passed away. The static was just the end of transmission." + }, + { + "id": 10, + "title": "Shot in a Locked Car", + "front": "A man is found shot to death in a car. All the doors are locked from the inside, and there is no gun in the vehicle.", + "back": "The man was killed in a drive-by shooting. After he was shot, his car rolled forward and hit an obstacle, automatically locking the central locking system on impact. The attacker left with the weapon, leaving behind a locked car with a dead man inside." + } +] diff --git a/deep-sea-stories/packages/backend/src/router.ts b/deep-sea-stories/packages/backend/src/router.ts new file mode 100644 index 0000000..374ce84 --- /dev/null +++ b/deep-sea-stories/packages/backend/src/router.ts @@ -0,0 +1,14 @@ +import { createRoom, getRoom } from './controllers/rooms.js'; +import { createPeer } from './controllers/peers.js'; +import { startStory, getStories } from './controllers/stories.js'; +import { router } from './trpc.js'; + +export const appRouter = router({ + createRoom, + getRoom, + createPeer, + startStory, + getStories, +}); + +export type AppRouter = typeof appRouter; diff --git a/deep-sea-stories/packages/backend/src/schemas.ts b/deep-sea-stories/packages/backend/src/schemas.ts new file mode 100644 index 0000000..bb2d1e1 --- /dev/null +++ b/deep-sea-stories/packages/backend/src/schemas.ts @@ -0,0 +1,10 @@ +import z from 'zod'; + +export const getRoomInputSchema = z.object({ roomId: z.string() }); + +export const createPeerInputSchema = z.object({ roomId: z.string() }); + +export const startStoryInputSchema = z.object({ + roomId: z.string(), + storyId: z.number(), +}); diff --git a/deep-sea-stories/packages/backend/src/service/audio-streaming-orchestrator.ts b/deep-sea-stories/packages/backend/src/service/audio-streaming-orchestrator.ts new file mode 100644 index 0000000..7d4259f --- /dev/null +++ b/deep-sea-stories/packages/backend/src/service/audio-streaming-orchestrator.ts @@ -0,0 +1,94 @@ +import type { + FishjamAgent, + PeerId, + TrackId, +} from '@fishjam-cloud/js-server-sdk'; +import type { VoiceAgentSessionManager } from '../types.js'; + +export class AudioStreamingOrchestrator { + private fishjamAgent: FishjamAgent; + private sessionManager: VoiceAgentSessionManager; + private connectedPeers: Set; + + constructor( + fishjamAgent: FishjamAgent, + sessionManager: VoiceAgentSessionManager, + connectedPeers: Set, + ) { + this.fishjamAgent = fishjamAgent; + this.sessionManager = sessionManager; + this.connectedPeers = connectedPeers; + } + + setupIncomingAudioPipeline(): void { + this.fishjamAgent.on('trackData', (trackMsg) => { + if (!this.connectedPeers.has(trackMsg.peerId)) { + return; + } + + const session = this.sessionManager.getSession(trackMsg.peerId); + if (session && trackMsg.data) { + try { + const audioBuffer = Buffer.from(trackMsg.data); + session.sendAudio(audioBuffer); + } catch (error) { + console.error( + `Error sending audio to AI voice agent for peer ${trackMsg.peerId}:`, + error, + ); + } + } + }); + } + + setupOutgoingAudioPipeline(): void { + const audioTrack = this.fishjamAgent.createTrack({ + encoding: 'pcm16', + sampleRate: 16000, + channels: 1, + }); + + for (const peerId of this.connectedPeers) { + const session = this.sessionManager.getSession(peerId); + if (!session) { + console.warn(`No session found for peer ${peerId}`); + continue; + } + + session.on('agentAudio', (audioEvent) => { + try { + const audioBuffer = this.decodeAudioEvent(audioEvent); + if (audioBuffer && audioBuffer.length > 0) { + this.fishjamAgent.sendData(audioTrack.id as TrackId, audioBuffer); + } else { + console.error('Received empty audio buffer from AI voice agent'); + } + } catch (error) { + console.error('Error sending agent audio track to room:', error); + } + }); + } + } + + setupAudioPipelines(): void { + this.setupIncomingAudioPipeline(); + this.setupOutgoingAudioPipeline(); + } + + private decodeAudioEvent(audioEvent: { + audio_base_64?: string; + }): Uint8Array | null { + if (!audioEvent.audio_base_64) { + return null; + } + + try { + return Uint8Array.from(atob(audioEvent.audio_base_64), (c) => + c.charCodeAt(0), + ); + } catch (error) { + console.error('Error decoding audio event:', error); + return null; + } + } +} diff --git a/deep-sea-stories/packages/backend/src/service/elevenlabs-conversation.ts b/deep-sea-stories/packages/backend/src/service/elevenlabs-conversation.ts new file mode 100644 index 0000000..9261818 --- /dev/null +++ b/deep-sea-stories/packages/backend/src/service/elevenlabs-conversation.ts @@ -0,0 +1,293 @@ +import { CONFIG } from '../config.js'; +import { EventEmitter } from 'node:events'; +import { ElevenLabsClient } from '@elevenlabs/elevenlabs-js'; +import type { Conversation } from '../types.js'; + +interface ConversationInitiationMetadataEvent { + conversation_id: string; + agent_output_audio_format: string; + user_input_audio_format: string; +} + +interface ElevenLabsMessage { + type: string; + conversation_initiation_metadata_event?: ConversationInitiationMetadataEvent; + agent_response_event?: { + agent_response?: string; + }; + user_transcription_event?: { + user_transcript?: string; + }; + audio_event?: unknown; + interruption_event?: unknown; + ping_event?: { + event_id?: string; + }; + vad_score_event?: unknown; + tentative_agent_response_internal_event?: unknown; + client_tool_call?: unknown; +} + +export interface AgentId { + agent_id: string; +} + +/** + * WebSocket-based conversation with ElevenLabs for real-time audio streaming + * https://elevenlabs.io/docs/agents-platform/api-reference/agents-platform/websocket + * + * Events emitted: + * - 'ready': ({ conversationId, audioFormat, inputFormat }) - When conversation is ready + * - 'agentResponse': (AgentResponseEvent) - Text response from agent + * - 'userTranscript': (UserTranscriptEvent) - User speech transcription + * - 'agentAudio': (AudioEvent) - Audio response from agent + * - 'interruption': (InterruptionEvent) - When conversation is interrupted + * - 'vadScore': (VadScoreEvent) - Voice activity detection score + * - 'tentativeResponse': (TentativeAgentResponseEvent) - Tentative response + * - 'clientToolCall': (ClientToolCall) - Client tool call request + * - 'disconnected': ({ code, reason }) - When WebSocket disconnects + */ +export class ElevenLabsConversation + extends EventEmitter + implements Conversation +{ + private ws: WebSocket | null = null; + private conversationId: string | null = null; + private isConnected = false; + private audioFormat: string | null = null; + private inputFormat: string | null = null; + private agentId: string; + private apiKey: string; + private baseUrl: string; + + constructor( + agentId: string, + apiKey: string, + baseUrl: string = 'wss://api.elevenlabs.io', + ) { + super(); + this.agentId = agentId; + this.apiKey = apiKey; + this.baseUrl = baseUrl; + } + + /** + * Start a conversation session with the ElevenLabs agent + */ + async connect(): Promise { + return new Promise((resolve, reject) => { + try { + const wsUrl = `${this.baseUrl}/v1/convai/conversation?agent_id=${this.agentId}`; + + this.ws = new WebSocket(wsUrl, { + headers: { + 'xi-api-key': this.apiKey, + 'User-Agent': 'Deep-Sea-Stories-Backend/1.0.0', + }, + }); + + this.ws.addEventListener('open', () => { + console.log('Connected to ElevenLabs WebSocket'); + this.isConnected = true; + + this.sendMessage({ + type: 'conversation_initiation_client_data', + conversation_config_override: {}, + }); + + resolve(); + }); + + this.ws.addEventListener('message', (event) => { + try { + const message = JSON.parse(event.data.toString()); + this.handleMessage(message); + } catch (error) { + console.error('Failed to parse WebSocket message:', error); + } + }); + + this.ws.addEventListener('close', (event) => { + console.log( + `ElevenLabs WebSocket connection closed: ${event.code} - ${event.reason}`, + ); + this.isConnected = false; + this.emit('disconnected', { code: event.code, reason: event.reason }); + }); + + this.ws.addEventListener('error', (event) => { + console.error('ElevenLabs WebSocket error:', event); + this.isConnected = false; + reject(new Error('WebSocket error occurred')); + }); + } catch (error) { + reject(error); + } + }); + } + + sendAudio(audioBuffer: Buffer): void { + if (!this.isConnected || !this.ws) { + console.warn('Cannot send audio: WebSocket not connected'); + return; + } + + try { + const audioBase64 = audioBuffer.toString('base64'); + + this.sendMessage({ + user_audio_chunk: audioBase64, + }); + } catch (error) { + console.error('Failed to send audio to ElevenLabs:', error); + } + } + + sendUserMessage(text: string): void { + this.sendMessage({ + type: 'user_message', + text: text, + }); + } + + sendContextualUpdate(text: string): void { + this.sendMessage({ + type: 'contextual_update', + text: text, + }); + } + + sendUserActivity(): void { + this.sendMessage({ + type: 'user_activity', + }); + } + + sendClientToolResult( + toolCallId: string, + result: string, + isError: boolean = false, + ): void { + this.sendMessage({ + type: 'client_tool_result', + tool_call_id: toolCallId, + result: result, + is_error: isError, + }); + } + + async disconnect(): Promise { + if (this.ws) { + this.isConnected = false; + this.ws.close(); + this.ws = null; + } + } + + isSessionActive(): boolean { + return this.isConnected && this.ws?.readyState === WebSocket.OPEN; + } + + private sendMessage(message: Record): void { + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + this.ws.send(JSON.stringify(message)); + } + } + + private handleMessage(message: ElevenLabsMessage): void { + switch (message.type) { + case 'conversation_initiation_metadata': { + const metadata = message.conversation_initiation_metadata_event; + if (metadata) { + this.conversationId = metadata.conversation_id; + this.audioFormat = metadata.agent_output_audio_format; + this.inputFormat = metadata.user_input_audio_format; + console.log('Conversation initiated:', { + conversationId: this.conversationId, + audioFormat: this.audioFormat, + inputFormat: this.inputFormat, + }); + this.emit('ready', { + conversationId: this.conversationId, + audioFormat: this.audioFormat, + inputFormat: this.inputFormat, + }); + } + break; + } + + case 'agent_response': + console.log( + 'Agent response:', + message.agent_response_event?.agent_response, + ); + this.emit('agentResponse', message.agent_response_event); + break; + + case 'user_transcript': + console.log( + 'User transcript:', + message.user_transcription_event?.user_transcript, + ); + this.emit('userTranscript', message.user_transcription_event); + break; + + case 'audio': + this.emit('agentAudio', message.audio_event); + break; + + case 'interruption': + console.log('Conversation interrupted'); + this.emit('interruption', message.interruption_event); + break; + + case 'ping': + console.log('Received ping, sending pong'); + this.sendMessage({ + type: 'pong', + event_id: message.ping_event?.event_id, + }); + break; + + case 'vad_score': + this.emit('vadScore', message.vad_score_event); + break; + + case 'internal_tentative_agent_response': + this.emit( + 'tentativeResponse', + message.tentative_agent_response_internal_event, + ); + break; + + case 'client_tool_call': + console.log('Client tool call:', message.client_tool_call); + this.emit('clientToolCall', message.client_tool_call); + break; + + case 'contextual_update': + this.emit('contextualUpdate', message); + break; + + default: + console.log('Received unknown message type:', message.type); + this.emit('message', message); + } + } + + getConversationId(): string | null { + return this.conversationId; + } + + getAudioFormat(): string | null { + return this.audioFormat; + } + + getInputFormat(): string | null { + return this.inputFormat; + } +} + +export const elevenLabs = new ElevenLabsClient({ + apiKey: CONFIG.ELEVENLABS_API_KEY, +}); diff --git a/deep-sea-stories/packages/backend/src/service/elevenlabs-session.ts b/deep-sea-stories/packages/backend/src/service/elevenlabs-session.ts new file mode 100644 index 0000000..0000abf --- /dev/null +++ b/deep-sea-stories/packages/backend/src/service/elevenlabs-session.ts @@ -0,0 +1,110 @@ +import type { RoomId, PeerId } from '@fishjam-cloud/js-server-sdk'; +import { + elevenLabs, + ElevenLabsConversation, +} from './elevenlabs-conversation.js'; +import { roomService } from './room.js'; +import { getInstructionsForStory } from '../utils.js'; +import { CONFIG } from '../config.js'; +import type { VoiceAgentSessionManager } from '../types.js'; +import { + GameSessionNotFoundError, + StoryNotFoundError, +} from '../domain/errors.js'; + +export class ElevenLabsSessionManager implements VoiceAgentSessionManager { + private sessions = new Map(); + private inFlightCreations = new Map< + PeerId, + Promise + >(); + + private async resolveStory(roomId: RoomId) { + const gameSession = roomService.getGameSession(roomId); + if (!gameSession) { + throw new GameSessionNotFoundError(roomId); + } + + const story = gameSession.getStory(); + if (!story) { + throw new StoryNotFoundError(roomId); + } + + return story; + } + + async createSession( + peerId: PeerId, + roomId: RoomId, + ): Promise { + if (this.inFlightCreations.has(peerId)) { + return this.inFlightCreations.get( + peerId, + ) as Promise; + } + + const promise = this._createSessionInternal(peerId, roomId).finally(() => + this.inFlightCreations.delete(peerId), + ); + + this.inFlightCreations.set(peerId, promise); + return promise; + } + + private async _createSessionInternal( + peerId: PeerId, + roomId: RoomId, + ): Promise { + await this.deleteSession(peerId); + + const story = await this.resolveStory(roomId); + const instructions = getInstructionsForStory(story); + + const { agentId } = await elevenLabs.conversationalAi.agents.create({ + conversationConfig: { + agent: { + firstMessage: 'Welcome to Deepsea stories', + language: 'en', + prompt: { + prompt: instructions, + }, + }, + }, + }); + + const session = new ElevenLabsConversation( + agentId, + CONFIG.ELEVENLABS_API_KEY, + ); + await session.connect(); + + this.sessions.set(peerId, session); + return session; + } + + async deleteSession(peerId: PeerId): Promise { + const session = this.sessions.get(peerId); + if (session) { + try { + await session.disconnect(); + } catch (error) { + console.error(`Error closing session for peer ${peerId}:`, error); + } + this.sessions.delete(peerId); + } + } + + getSession(peerId: PeerId): ElevenLabsConversation | undefined { + return this.sessions.get(peerId); + } + + async cleanup(): Promise { + const promises = Array.from(this.sessions.keys()).map((peerId) => + this.deleteSession(peerId).catch((error) => { + console.error(`Failed to cleanup session for peer ${peerId}:`, error); + }), + ); + + await Promise.allSettled(promises); + } +} diff --git a/deep-sea-stories/packages/backend/src/service/game-session.ts b/deep-sea-stories/packages/backend/src/service/game-session.ts new file mode 100644 index 0000000..aa821d1 --- /dev/null +++ b/deep-sea-stories/packages/backend/src/service/game-session.ts @@ -0,0 +1,181 @@ +import type { + FishjamAgent, + FishjamClient, + Peer, + PeerId, + RoomId, +} from '@fishjam-cloud/js-server-sdk'; +import type { Story, VoiceAgentSessionManager } from '../types.js'; +import { FISHJAM_AGENT_OPTIONS } from '../config.js'; +import { AudioStreamingOrchestrator } from './audio-streaming-orchestrator.js'; +import { + NoPeersConnectedError, + NoVoiceSessionManagerError, +} from '../domain/errors.js'; +import { ElevenLabsSessionManager } from './elevenlabs-session.js'; + +export class GameSession { + private roomId: RoomId; + private story: Story | undefined; + private peers: Peer[]; + private connectedPeers: Set; + private fishjamAgent: FishjamAgent | undefined; + private fishjamAgentId: PeerId | undefined; + private voiceSessionManager: VoiceAgentSessionManager | undefined; + + constructor(roomId: RoomId) { + this.roomId = roomId; + this.story = undefined; + this.peers = []; + this.connectedPeers = new Set(); + } + + getStory(): Story | undefined { + return this.story; + } + + getPeers(): Peer[] { + return this.peers; + } + + getConnectedPeers(): PeerId[] { + return Array.from(this.connectedPeers); + } + + getFishjamAgent(): { + fishjamAgent: FishjamAgent | undefined; + peerId: PeerId | undefined; + } { + return { + fishjamAgent: this.fishjamAgent, + peerId: this.fishjamAgentId, + }; + } + + getVoiceSessionManager(): VoiceAgentSessionManager | undefined { + return this.voiceSessionManager; + } + + setStory(story: Story | undefined) { + this.story = story; + } + + async createPeer( + fishjam: FishjamClient, + ): Promise<{ peer: Peer; peerToken: string }> { + const { peer, peerToken } = await fishjam.createPeer(this.roomId); + this.peers.push(peer); + return { peer, peerToken }; + } + + setConnectedPeer(peerId: PeerId) { + this.connectedPeers.add(peerId); + } + + removeConnectedPeer(peerId: PeerId) { + this.connectedPeers.delete(peerId); + } + + async createFishjamAgent(fishjam: FishjamClient) { + const { agent, peer } = await fishjam.createAgent( + this.roomId, + FISHJAM_AGENT_OPTIONS, + { + onError: (event: Event) => { + console.log( + `Fishjam Agent for room: ${this.roomId} encountered an error event:`, + event, + ); + }, + onClose: (code: number, reason: string) => { + console.log( + `Fishjam Agent for room: ${this.roomId} closed with code: ${code}, reason: ${reason}`, + ); + }, + }, + ); + + this.fishjamAgent = agent; + this.fishjamAgentId = peer.id; + } + + setVoiceSessionManager(manager: VoiceAgentSessionManager) { + this.voiceSessionManager = manager; + } + + async startGame(story: Story): Promise { + this.setStory(story); + + if (this.connectedPeers.size === 0) { + throw new NoPeersConnectedError(this.roomId); + } + + console.log( + `Starting game for ${this.connectedPeers.size} connected peers in room ${this.roomId}`, + ); + + const gameSession = new ElevenLabsSessionManager(); + this.setVoiceSessionManager(gameSession); + + const peerIds = Array.from(this.connectedPeers); + await Promise.all( + peerIds.map(async (peerId) => { + try { + await this.startGameForPeer(peerId); + } catch (error) { + console.error( + `Failed to start game for peer ${peerId} in room ${this.roomId}:`, + error, + ); + } + }), + ); + this.setupAudioStreaming(); + } + + async startGameForPeer(peerId: PeerId): Promise { + if (!this.voiceSessionManager) { + throw new NoVoiceSessionManagerError(this.roomId); + } + + try { + await this.voiceSessionManager.createSession(peerId, this.roomId); + console.log( + `Started game session for peer ${peerId} in room ${this.roomId}`, + ); + } catch (error) { + console.error(`Failed to start game session for peer ${peerId}:`, error); + throw error; + } + } + + private setupAudioStreaming(): void { + if (!this.fishjamAgent || !this.voiceSessionManager) { + console.error( + `Cannot setup audio streaming: missing agent or session manager for room ${this.roomId}`, + ); + return; + } + + const orchestrator = new AudioStreamingOrchestrator( + this.fishjamAgent, + this.voiceSessionManager, + this.connectedPeers, + ); + + orchestrator.setupAudioPipelines(); + } + + async stopGame(roomId: RoomId): Promise { + this.voiceSessionManager?.cleanup(); + this.setStory(undefined); + console.log(`Stopped game for room ${roomId}`); + } + + async removePeerFromGame(roomId: RoomId, peerId: PeerId): Promise { + if (this.voiceSessionManager) { + await this.voiceSessionManager.deleteSession(peerId); + console.log(`Removed peer ${peerId} from game in room ${roomId}`); + } + } +} diff --git a/deep-sea-stories/packages/backend/src/service/notifier.ts b/deep-sea-stories/packages/backend/src/service/notifier.ts new file mode 100644 index 0000000..b8ff233 --- /dev/null +++ b/deep-sea-stories/packages/backend/src/service/notifier.ts @@ -0,0 +1,79 @@ +import { FishjamWSNotifier } from '@fishjam-cloud/js-server-sdk'; +import { CONFIG } from '../config.js'; +import { roomService } from './room.js'; + +class NotifierService { + private notifier: FishjamWSNotifier | null = null; + + async initialize() { + if (this.notifier !== null) { + return; + } + this.notifier = new FishjamWSNotifier( + { + fishjamId: CONFIG.FISHJAM_ID, + managementToken: CONFIG.FISHJAM_MANAGEMENT_TOKEN, + }, + (msg) => { + console.log(`FishjamWSNotifier got error: ${msg}`); + }, + (code, reason) => { + console.log( + `FishjamWSNotifier closed with code: ${code}, reason: ${reason}`, + ); + }, + ); + + this.setupEventHandlers(); + } + + private setupEventHandlers() { + if (!this.notifier) return; + + this.notifier.on('peerConnected', async (msg) => { + console.log(`Peer connected: ${msg.peerId} in room ${msg.roomId}`); + const gameSession = roomService.getGameSession(msg.roomId); + if (!gameSession) { + console.warn( + `No game session found for room ${msg.roomId} when peer ${msg.peerId} connected.`, + ); + return; + } + const { peerId } = gameSession.getFishjamAgent(); + if (msg.peerId === peerId) { + return; + } + + gameSession.setConnectedPeer(msg.peerId); + if (!roomService.isGameActive(msg.roomId)) return; + + try { + await gameSession.startGameForPeer(msg.peerId); + } catch (error) { + console.error( + `Failed to start game for newly connected peer ${msg.peerId}:`, + error, + ); + } + }); + + this.notifier.on('peerDisconnected', async (msg) => { + console.log(`Peer disconnected: ${msg.peerId} from room ${msg.roomId}`); + + const gameSession = roomService.getGameSession(msg.roomId); + if (!gameSession) { + console.warn( + `No game session found for room ${msg.roomId} when peer ${msg.peerId} disconnected.`, + ); + return; + } + gameSession.removeConnectedPeer(msg.peerId); + + if (roomService.isGameActive(msg.roomId)) { + await gameSession.removePeerFromGame(msg.roomId, msg.peerId); + } + }); + } +} + +export const notifierService = new NotifierService(); diff --git a/deep-sea-stories/packages/backend/src/service/room.ts b/deep-sea-stories/packages/backend/src/service/room.ts new file mode 100644 index 0000000..71fa6c4 --- /dev/null +++ b/deep-sea-stories/packages/backend/src/service/room.ts @@ -0,0 +1,21 @@ +import type { RoomId } from '@fishjam-cloud/js-server-sdk'; +import type { GameSession } from './game-session.js'; + +class RoomService { + private RoomToGameSession = new Map(); + + getGameSession(roomId: RoomId): GameSession | undefined { + return this.RoomToGameSession.get(roomId); + } + + setGameSession(roomId: RoomId, gameSession: GameSession) { + this.RoomToGameSession.set(roomId, gameSession); + } + + isGameActive(roomId: RoomId): boolean { + const gameSession = this.RoomToGameSession.get(roomId); + return gameSession !== undefined && gameSession.getStory() !== undefined; + } +} + +export const roomService = new RoomService(); diff --git a/deep-sea-stories/packages/backend/src/trpc.ts b/deep-sea-stories/packages/backend/src/trpc.ts new file mode 100644 index 0000000..c745c90 --- /dev/null +++ b/deep-sea-stories/packages/backend/src/trpc.ts @@ -0,0 +1,7 @@ +import { initTRPC } from '@trpc/server'; +import type { Context } from './context.js'; + +const t = initTRPC.context().create(); + +export const router = t.router; +export const publicProcedure = t.procedure; diff --git a/deep-sea-stories/packages/backend/src/types.ts b/deep-sea-stories/packages/backend/src/types.ts new file mode 100644 index 0000000..5942424 --- /dev/null +++ b/deep-sea-stories/packages/backend/src/types.ts @@ -0,0 +1,20 @@ +import type { PeerId, RoomId } from '@fishjam-cloud/js-server-sdk'; +import type { EventEmitter } from 'node:events'; + +export interface Story { + id: number; + title: string; + front: string; + back: string; +} + +export interface Conversation extends EventEmitter { + sendAudio(audioBuffer: Buffer): void; +} + +export interface VoiceAgentSessionManager { + createSession(peerId: PeerId, roomId: RoomId): Promise; + deleteSession(peerId: PeerId): Promise; + getSession(peerId: PeerId): Conversation | undefined; + cleanup(): Promise; +} diff --git a/deep-sea-stories/packages/backend/src/utils.ts b/deep-sea-stories/packages/backend/src/utils.ts new file mode 100644 index 0000000..f9257f7 --- /dev/null +++ b/deep-sea-stories/packages/backend/src/utils.ts @@ -0,0 +1,10 @@ +import nunjucks from 'nunjucks'; +import { AGENT_INSTRUCTIONS_TEMPLATE } from './config.js'; +import type { Story } from './types.js'; + +export function getInstructionsForStory(story: Story): string { + return nunjucks.renderString(AGENT_INSTRUCTIONS_TEMPLATE, { + FRONT: story.front, + BACK: story.back, + }); +} diff --git a/deep-sea-stories/packages/backend/tests/.env.test b/deep-sea-stories/packages/backend/tests/.env.test new file mode 100644 index 0000000..5474b3c --- /dev/null +++ b/deep-sea-stories/packages/backend/tests/.env.test @@ -0,0 +1,6 @@ +# Test environment variables for running tests +FISHJAM_ID=test-fishjam-id +FISHJAM_MANAGEMENT_TOKEN=test-management-token +FISHJAM_URL=https://test.fishjam.cloud +ELEVENLABS_API_KEY=test-elevenlabs-api-key +PORT=8001 \ No newline at end of file diff --git a/deep-sea-stories/packages/backend/tests/config.test.ts b/deep-sea-stories/packages/backend/tests/config.test.ts new file mode 100644 index 0000000..9cf0d8f --- /dev/null +++ b/deep-sea-stories/packages/backend/tests/config.test.ts @@ -0,0 +1,108 @@ +import { test, describe } from 'node:test'; +import assert from 'node:assert'; +import { + stories, + AGENT_INSTRUCTIONS_TEMPLATE, + configSchema, +} from '../src/config.js'; + +describe('Configuration', () => { + test('stories should be loaded and be a valid array', () => { + assert(Array.isArray(stories), 'Stories should be an array'); + assert(stories.length > 0, 'Stories array should not be empty'); + }); + + test('each story should have valid front and back properties', () => { + stories.forEach((story, index) => { + assert(typeof story === 'object', `Story ${index} should be an object`); + assert(story !== null, `Story ${index} should not be null`); + + assert( + typeof story.front === 'string', + `Story ${index} should have a front property of type string`, + ); + assert( + typeof story.back === 'string', + `Story ${index} should have a back property of type string`, + ); + + assert( + story.front.length > 0, + `Story ${index} front should not be empty`, + ); + assert(story.back.length > 0, `Story ${index} back should not be empty`); + }); + }); + + test('AGENT_INSTRUCTIONS_TEMPLATE should be loaded and contain placeholders', () => { + assert( + typeof AGENT_INSTRUCTIONS_TEMPLATE === 'string', + 'Instructions template should be a string', + ); + assert( + AGENT_INSTRUCTIONS_TEMPLATE.length > 0, + 'Instructions template should not be empty', + ); + + assert( + AGENT_INSTRUCTIONS_TEMPLATE.includes('{{ FRONT }}'), + 'Template should contain {{ FRONT }} placeholder', + ); + assert( + AGENT_INSTRUCTIONS_TEMPLATE.includes('{{ BACK }}'), + 'Template should contain {{ BACK }} placeholder', + ); + }); + + test('configSchema should validate required environment variables', () => { + assert.throws(() => { + configSchema.parse({}); + }, 'Should throw when required FISHJAM_ID is missing'); + + assert.throws(() => { + configSchema.parse({ + FISHJAM_ID: 'test-id', + }); + }, 'Should throw when required FISHJAM_MANAGEMENT_TOKEN is missing'); + + assert.throws(() => { + configSchema.parse({ + FISHJAM_ID: 'test-id', + FISHJAM_MANAGEMENT_TOKEN: 'test-token', + }); + }, 'Should throw when required ELEVENLABS_API_KEY is missing'); + + assert.doesNotThrow(() => { + configSchema.parse({ + FISHJAM_ID: 'test-id', + FISHJAM_MANAGEMENT_TOKEN: 'test-token', + ELEVENLABS_API_KEY: 'test-api-key', + }); + }, 'Should not throw with all required fields'); + }); + + test('configSchema should provide default PORT value', () => { + const config = configSchema.parse({ + FISHJAM_ID: 'test-id', + FISHJAM_MANAGEMENT_TOKEN: 'test-token', + ELEVENLABS_API_KEY: 'test-api-key', + }); + + assert.strictEqual(config.PORT, 8000, 'Should default PORT to 8000'); + }); + + test('configSchema should accept custom PORT value', () => { + const config = configSchema.parse({ + FISHJAM_ID: 'test-id', + FISHJAM_MANAGEMENT_TOKEN: 'test-token', + ELEVENLABS_API_KEY: 'test-api-key', + PORT: '3000', + }); + + assert.strictEqual( + config.PORT, + 3000, + 'Should accept and convert custom PORT value', + ); + }); +}); diff --git a/deep-sea-stories/packages/backend/tests/setup-module.ts b/deep-sea-stories/packages/backend/tests/setup-module.ts new file mode 100644 index 0000000..e62a004 --- /dev/null +++ b/deep-sea-stories/packages/backend/tests/setup-module.ts @@ -0,0 +1,14 @@ +import dotenv from 'dotenv'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { dirname } from 'node:path'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +export async function globalSetup() { + dotenv.config({ + path: join(__dirname, '.env.test'), + override: true, + }); +} diff --git a/deep-sea-stories/packages/backend/tests/utils.test.ts b/deep-sea-stories/packages/backend/tests/utils.test.ts new file mode 100644 index 0000000..e165dca --- /dev/null +++ b/deep-sea-stories/packages/backend/tests/utils.test.ts @@ -0,0 +1,38 @@ +import { test, describe } from 'node:test'; +import assert from 'node:assert'; +import { getInstructionsForStory } from '../src/utils.js'; +import type { Story } from '../src/types.js'; + +describe('Stories Service', () => { + test('getInstructionsForStory should replace template placeholders', () => { + const testStory: Story = { + id: 999, + title: 'Test Story', + front: 'Test front story', + back: 'Test back story', + }; + + const instructions = getInstructionsForStory(testStory); + + assert( + !instructions.includes('{{FRONT}}'), + 'Should replace {{FRONT}} placeholder', + ); + assert( + !instructions.includes('{{BACK}}'), + 'Should replace {{BACK}} placeholder', + ); + + assert( + instructions.includes(testStory.front), + 'Should include front story content', + ); + assert( + instructions.includes(testStory.back), + 'Should include back story content', + ); + + assert(typeof instructions === 'string', 'Should return a string'); + assert(instructions.length > 0, 'Should return non-empty instructions'); + }); +}); diff --git a/deep-sea-stories/packages/backend/tsconfig.json b/deep-sea-stories/packages/backend/tsconfig.json new file mode 100644 index 0000000..4e37b26 --- /dev/null +++ b/deep-sea-stories/packages/backend/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "@tsconfig/node24/tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "declaration": true + } +} diff --git a/deep-sea-stories/packages/common/package.json b/deep-sea-stories/packages/common/package.json new file mode 100644 index 0000000..1f4dc35 --- /dev/null +++ b/deep-sea-stories/packages/common/package.json @@ -0,0 +1,25 @@ +{ + "name": "@deep-sea-stories/common", + "private": true, + "version": "0.0.0", + "type": "module", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./src/index.ts" + } + }, + "main": "./dist/index.js", + "types": "./src/index.ts", + "files": [ + "dist", + "src" + ], + "scripts": { + "build": "tsc -p tsconfig.json", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "typescript": "^5.9.2" + } +} diff --git a/deep-sea-stories/packages/common/src/events.ts b/deep-sea-stories/packages/common/src/events.ts new file mode 100644 index 0000000..612ca9b --- /dev/null +++ b/deep-sea-stories/packages/common/src/events.ts @@ -0,0 +1,16 @@ +export interface BaseEvent { + type: string; + timestamp: number; +} + +export interface JoinEvent extends BaseEvent { + type: 'join'; + name: string; +} + +export interface TranscriptionEvent extends BaseEvent { + type: 'transcription'; + text: string; +} + +export type AgentEvent = JoinEvent | TranscriptionEvent; diff --git a/deep-sea-stories/packages/common/src/index.ts b/deep-sea-stories/packages/common/src/index.ts new file mode 100644 index 0000000..2e2d0e9 --- /dev/null +++ b/deep-sea-stories/packages/common/src/index.ts @@ -0,0 +1,8 @@ +export type { + AgentEvent, + BaseEvent, + JoinEvent, + TranscriptionEvent, +} from './events.js'; + +export type { StoryData } from './types'; diff --git a/deep-sea-stories/packages/common/src/types.ts b/deep-sea-stories/packages/common/src/types.ts new file mode 100644 index 0000000..c4a5a98 --- /dev/null +++ b/deep-sea-stories/packages/common/src/types.ts @@ -0,0 +1,5 @@ +export interface StoryData { + id: number; + title: string; + front: string; +} diff --git a/deep-sea-stories/packages/common/tsconfig.json b/deep-sea-stories/packages/common/tsconfig.json new file mode 100644 index 0000000..ef5d4bb --- /dev/null +++ b/deep-sea-stories/packages/common/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "composite": true, + "declaration": true, + "emitDeclarationOnly": false, + "outDir": "dist", + "rootDir": "src", + "module": "ESNext", + "target": "ES2022", + "moduleResolution": "bundler", + "strict": true, + "skipLibCheck": true + }, + "include": ["src/**/*"] +} diff --git a/deep-sea-stories/packages/web/.env.example b/deep-sea-stories/packages/web/.env.example new file mode 100644 index 0000000..a041231 --- /dev/null +++ b/deep-sea-stories/packages/web/.env.example @@ -0,0 +1,2 @@ +VITE_BACKEND_URL="" +VITE_FISHJAM_ID="" diff --git a/deep-sea-stories/packages/web/.gitignore b/deep-sea-stories/packages/web/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/deep-sea-stories/packages/web/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/deep-sea-stories/packages/web/Dockerfile b/deep-sea-stories/packages/web/Dockerfile new file mode 100644 index 0000000..03acccf --- /dev/null +++ b/deep-sea-stories/packages/web/Dockerfile @@ -0,0 +1,35 @@ +FROM node:24-alpine AS builder + +WORKDIR /app + +RUN corepack enable + +COPY package.json yarn.lock .yarnrc.yml ./ +COPY packages ./packages + +RUN yarn install --frozen-lockfile + +ARG VITE_FISHJAM_ID +ARG VITE_BACKEND_URL + +ENV VITE_FISHJAM_ID=$VITE_FISHJAM_ID +ENV VITE_BACKEND_URL=$VITE_BACKEND_URL + +RUN ./node_modules/.bin/vite build packages/web + +FROM node:24-alpine + +WORKDIR /app + +RUN apk add --no-cache dumb-init + +COPY --from=builder /app/packages/web/dist ./packages/web/dist + +RUN addgroup -g 1001 -S nodejs && adduser -S nodejs -u 1001 +USER nodejs + +EXPOSE 3000 + +ENTRYPOINT ["dumb-init", "--"] + +CMD ["npx", "serve", "-s", "packages/web/dist", "-l", "3000"] diff --git a/deep-sea-stories/packages/web/README.md b/deep-sea-stories/packages/web/README.md new file mode 100644 index 0000000..b473640 --- /dev/null +++ b/deep-sea-stories/packages/web/README.md @@ -0,0 +1,75 @@ +# React + TypeScript + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh + +## React Compiler + +The React Compiler is enabled on this template. See [this documentation](https://react.dev/learn/react-compiler) for more information. + +Note: This will impact Vite dev & build performances. + +## Expanding the ESLint configuration + +If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: + +```js +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + + // Remove tseslint.configs.recommended and replace with this + tseslint.configs.recommendedTypeChecked, + // Alternatively, use this for stricter rules + tseslint.configs.strictTypeChecked, + // Optionally, add this for stylistic rules + tseslint.configs.stylisticTypeChecked, + + // Other configs... + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) +``` + +You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: + +```js +// eslint.config.js +import reactX from 'eslint-plugin-react-x' +import reactDom from 'eslint-plugin-react-dom' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + // Enable lint rules for React + reactX.configs['recommended-typescript'], + // Enable lint rules for React DOM + reactDom.configs.recommended, + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) +``` diff --git a/deep-sea-stories/packages/web/biome.json b/deep-sea-stories/packages/web/biome.json new file mode 100644 index 0000000..061a384 --- /dev/null +++ b/deep-sea-stories/packages/web/biome.json @@ -0,0 +1,9 @@ +{ + "root": false, + "extends": "//", + "linter": { + "domains": { + "react": "recommended" + } + } +} diff --git a/deep-sea-stories/packages/web/components.json b/deep-sea-stories/packages/web/components.json new file mode 100644 index 0000000..e05588c --- /dev/null +++ b/deep-sea-stories/packages/web/components.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "default", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/index.css", + "baseColor": "slate", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "registries": {} +} diff --git a/deep-sea-stories/packages/web/index.html b/deep-sea-stories/packages/web/index.html new file mode 100644 index 0000000..af88f03 --- /dev/null +++ b/deep-sea-stories/packages/web/index.html @@ -0,0 +1,13 @@ + + + + + + + web + + +
+ + + diff --git a/deep-sea-stories/packages/web/package.json b/deep-sea-stories/packages/web/package.json new file mode 100644 index 0000000..dd3538c --- /dev/null +++ b/deep-sea-stories/packages/web/package.json @@ -0,0 +1,46 @@ +{ + "name": "web", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "start": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@deep-sea-stories/common": "workspace:*", + "@fishjam-cloud/react-client": "^0.22.0", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-label": "^2.1.7", + "@radix-ui/react-scroll-area": "^1.2.10", + "@radix-ui/react-select": "^2.2.6", + "@radix-ui/react-slot": "^1.2.3", + "@tailwindcss/vite": "^4.1.13", + "@tanstack/react-query": "^5.90.2", + "@trpc/client": "^11.6.0", + "@trpc/server": "^11.6.0", + "@trpc/tanstack-react-query": "^11.6.0", + "backend": "workspace:*", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^0.544.0", + "next-themes": "^0.4.6", + "react": "^19.1.1", + "react-dom": "^19.1.1", + "react-router": "^7.9.3", + "sonner": "^2.0.7", + "tailwind-merge": "^3.3.1", + "tailwindcss": "^4.1.13" + }, + "devDependencies": { + "@types/react": "^19.1.13", + "@types/react-dom": "^19.1.9", + "@vitejs/plugin-react": "^5.0.3", + "babel-plugin-react-compiler": "^19.1.0-rc.3", + "globals": "^16.4.0", + "tw-animate-css": "^1.4.0", + "vite": "^7.1.7" + } +} diff --git a/deep-sea-stories/packages/web/src/Layout.tsx b/deep-sea-stories/packages/web/src/Layout.tsx new file mode 100644 index 0000000..c124bde --- /dev/null +++ b/deep-sea-stories/packages/web/src/Layout.tsx @@ -0,0 +1,13 @@ +import type { FC, PropsWithChildren } from 'react'; +import { Toaster } from '@/components/ui/sonner'; + +const Layout: FC = (props) => { + return ( +
+ {props.children} + +
+ ); +}; + +export default Layout; diff --git a/deep-sea-stories/packages/web/src/assets/angler.webp b/deep-sea-stories/packages/web/src/assets/angler.webp new file mode 100644 index 0000000..a5fcff3 Binary files /dev/null and b/deep-sea-stories/packages/web/src/assets/angler.webp differ diff --git a/deep-sea-stories/packages/web/src/assets/blob.png b/deep-sea-stories/packages/web/src/assets/blob.png new file mode 100644 index 0000000..92273d4 Binary files /dev/null and b/deep-sea-stories/packages/web/src/assets/blob.png differ diff --git a/deep-sea-stories/packages/web/src/assets/elevenlabs.svg b/deep-sea-stories/packages/web/src/assets/elevenlabs.svg new file mode 100644 index 0000000..5410000 --- /dev/null +++ b/deep-sea-stories/packages/web/src/assets/elevenlabs.svg @@ -0,0 +1,4 @@ + + + + diff --git a/deep-sea-stories/packages/web/src/assets/fishjam.svg b/deep-sea-stories/packages/web/src/assets/fishjam.svg new file mode 100644 index 0000000..50b09b2 --- /dev/null +++ b/deep-sea-stories/packages/web/src/assets/fishjam.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/deep-sea-stories/packages/web/src/assets/fonts/AktivGrotesk.otf b/deep-sea-stories/packages/web/src/assets/fonts/AktivGrotesk.otf new file mode 100644 index 0000000..914fb0e Binary files /dev/null and b/deep-sea-stories/packages/web/src/assets/fonts/AktivGrotesk.otf differ diff --git a/deep-sea-stories/packages/web/src/assets/fonts/AktivGrotesk.woff b/deep-sea-stories/packages/web/src/assets/fonts/AktivGrotesk.woff new file mode 100644 index 0000000..a3de2c1 Binary files /dev/null and b/deep-sea-stories/packages/web/src/assets/fonts/AktivGrotesk.woff differ diff --git a/deep-sea-stories/packages/web/src/assets/fonts/AktivGrotesk.woff2 b/deep-sea-stories/packages/web/src/assets/fonts/AktivGrotesk.woff2 new file mode 100644 index 0000000..6642d80 Binary files /dev/null and b/deep-sea-stories/packages/web/src/assets/fonts/AktivGrotesk.woff2 differ diff --git a/deep-sea-stories/packages/web/src/assets/fonts/JuneExptActive.ttf b/deep-sea-stories/packages/web/src/assets/fonts/JuneExptActive.ttf new file mode 100644 index 0000000..57fe6ea Binary files /dev/null and b/deep-sea-stories/packages/web/src/assets/fonts/JuneExptActive.ttf differ diff --git a/deep-sea-stories/packages/web/src/assets/fonts/JuneExptCurious.otf b/deep-sea-stories/packages/web/src/assets/fonts/JuneExptCurious.otf new file mode 100644 index 0000000..7ae0748 Binary files /dev/null and b/deep-sea-stories/packages/web/src/assets/fonts/JuneExptCurious.otf differ diff --git a/deep-sea-stories/packages/web/src/assets/github.svg b/deep-sea-stories/packages/web/src/assets/github.svg new file mode 100644 index 0000000..e0810b6 --- /dev/null +++ b/deep-sea-stories/packages/web/src/assets/github.svg @@ -0,0 +1,4 @@ + + GitHub + + diff --git a/deep-sea-stories/packages/web/src/components/AgentPanel.tsx b/deep-sea-stories/packages/web/src/components/AgentPanel.tsx new file mode 100644 index 0000000..789fde4 --- /dev/null +++ b/deep-sea-stories/packages/web/src/components/AgentPanel.tsx @@ -0,0 +1,77 @@ +import { LogIn, type LucideIcon, MessageSquare } from 'lucide-react'; +import type { FC, PropsWithChildren } from 'react'; +import type { AgentEvent } from '@deep-sea-stories/common'; +import blob from '@/assets/blob.png'; +import { ScrollArea } from './ui/scroll-area'; + +type PanelEventProps = { + icon: LucideIcon; + timestamp: number; +}; + +const PanelEvent: FC> = ({ + icon: Icon, + children, + timestamp, +}) => ( +
+ +
{children}
+
+ {new Date(timestamp).toLocaleTimeString()} +
+
+); + +const renderEvent = (event: AgentEvent) => { + switch (event.type) { + case 'join': + return ( + +
+ {event.name} + has joined the game +
+
+ ); + case 'transcription': + return ( + +
Storyteller
+
+

{event.text}

+
+
+ ); + } +}; + +const AgentPanel = () => { + const events: AgentEvent[] = [ + { + type: 'join', + name: 'Gordon', + timestamp: Date.now(), + }, + { + type: 'transcription', + text: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed laoreet, dui quis tempus varius, ex ipsum suscipit ipsum, sed varius nunc arcu in lorem.', + timestamp: Date.now() + 1000 * 60 * 7, + }, + ]; + + return ( +
+ agent-visualizer + + {events.map(renderEvent)} + +
+ ); +}; + +export default AgentPanel; diff --git a/deep-sea-stories/packages/web/src/components/CopyButton.tsx b/deep-sea-stories/packages/web/src/components/CopyButton.tsx new file mode 100644 index 0000000..8953693 --- /dev/null +++ b/deep-sea-stories/packages/web/src/components/CopyButton.tsx @@ -0,0 +1,33 @@ +import { Copy } from 'lucide-react'; +import type React from 'react'; +import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; + +export type CopyButtonProps = { + value: string; + onCopy: () => void; +} & React.ComponentProps; + +export default function CopyButton({ + value, + onCopy, + children, + className, + ...buttonProps +}: CopyButtonProps) { + const copyToClipboard = () => { + navigator.clipboard.writeText(value); + onCopy(); + }; + + return ( + + ); +} diff --git a/deep-sea-stories/packages/web/src/components/DeviceSelect.tsx b/deep-sea-stories/packages/web/src/components/DeviceSelect.tsx new file mode 100644 index 0000000..4fdfa52 --- /dev/null +++ b/deep-sea-stories/packages/web/src/components/DeviceSelect.tsx @@ -0,0 +1,54 @@ +import type { DeviceItem } from '@fishjam-cloud/react-client'; +import type { FC, PropsWithChildren } from 'react'; + +import { Label } from './ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from './ui/select'; + +type DeviceSelectProps = { + placeholder: string; + devices: DeviceItem[]; + onSelectDevice: (deviceId: string) => void; + defaultDevice: DeviceItem | null; +}; + +export const DeviceSelect: FC> = ({ + placeholder, + devices, + onSelectDevice, + defaultDevice, + children, +}) => { + const filteredDevices = devices.filter( + (device, idx) => + devices.findIndex(({ deviceId }) => deviceId === device.deviceId) === idx, + ); + + if (!filteredDevices.length) { + return ; + } + + return ( + + ); +}; diff --git a/deep-sea-stories/packages/web/src/components/Footer.tsx b/deep-sea-stories/packages/web/src/components/Footer.tsx new file mode 100644 index 0000000..bd06928 --- /dev/null +++ b/deep-sea-stories/packages/web/src/components/Footer.tsx @@ -0,0 +1,31 @@ +import type { FC } from 'react'; +import elevenlabs from '@/assets/elevenlabs.svg'; +import fishjam from '@/assets/fishjam.svg'; +import HowItWorks from './HowItWorks'; +import HowToPlay from './HowToPlay'; +import Icon from './Icon'; +import LinkButton from './LinkButton'; + +const Footer: FC = () => { + return ( +
+
+

Created with

+ + Fishjam + + + + ElevenLabs + + +
+
+ + +
+
+ ); +}; + +export default Footer; diff --git a/deep-sea-stories/packages/web/src/components/HowItWorks.tsx b/deep-sea-stories/packages/web/src/components/HowItWorks.tsx new file mode 100644 index 0000000..311b257 --- /dev/null +++ b/deep-sea-stories/packages/web/src/components/HowItWorks.tsx @@ -0,0 +1,47 @@ +import type { FC } from 'react'; +import { Button, type ButtonProps } from './ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTrigger, +} from './ui/dialog'; + +const HowItWorks: FC = ({ variant, ...props }) => ( + + + + + + + How to play Deep Sea Stories? + + +

+ “Deep Sea Stories” are a loose adaptation of the well-known game + called “Dark Stories”. With the help of an AI agent and room you can + play together with your friends fully online. +

+

+ Choose one of four predefined scenarios that will circle around sea + stories and listen the background of it, giving you some clues and + directions to find the real reason and perpetrator of the event. Then, + you can ask questions and get “yes” or “no” responses from the + Storyteller that will be your only way to gather more clues. +

+

+ If you are ready to guess the story and win the game, say out loud + “I’m guessing now...” and say your deduced reasons. If you are right, + Storyteller is going to stop the game and congratulate you. If you + missed something, Storyteller is going to continue the game and let + you ask more questions. +

+
+
+
+); + +export default HowItWorks; diff --git a/deep-sea-stories/packages/web/src/components/HowToPlay.tsx b/deep-sea-stories/packages/web/src/components/HowToPlay.tsx new file mode 100644 index 0000000..750145b --- /dev/null +++ b/deep-sea-stories/packages/web/src/components/HowToPlay.tsx @@ -0,0 +1,47 @@ +import type { FC } from 'react'; +import { Button, type ButtonProps } from './ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTrigger, +} from './ui/dialog'; + +const HowToPlay: FC = ({ variant, ...props }) => ( + + + + + + + How to play Deep Sea Stories? + + +

+ “Deep Sea Stories” are a loose adaptation of the well-known game + called “Dark Stories”. With the help of an AI agent and room you can + play together with your friends fully online. +

+

+ Choose one of four predefined scenarios that will circle around sea + stories and listen the background of it, giving you some clues and + directions to find the real reason and perpetrator of the event. Then, + you can ask questions and get “yes” or “no” responses from the + Storyteller that will be your only way to gather more clues. +

+

+ If you are ready to guess the story and win the game, say out loud + “I’m guessing now...” and say your deduced reasons. If you are right, + Storyteller is going to stop the game and congratulate you. If you + missed something, Storyteller is going to continue the game and let + you ask more questions. +

+
+
+
+); + +export default HowToPlay; diff --git a/deep-sea-stories/packages/web/src/components/Icon.tsx b/deep-sea-stories/packages/web/src/components/Icon.tsx new file mode 100644 index 0000000..dac4f9d --- /dev/null +++ b/deep-sea-stories/packages/web/src/components/Icon.tsx @@ -0,0 +1,12 @@ +import type { FC } from 'react'; + +export type IconProps = { + img: string; + alt: string; +}; + +const Icon: FC = ({ img, alt }) => ( + {alt} +); + +export default Icon; diff --git a/deep-sea-stories/packages/web/src/components/LinkButton.tsx b/deep-sea-stories/packages/web/src/components/LinkButton.tsx new file mode 100644 index 0000000..76d9d87 --- /dev/null +++ b/deep-sea-stories/packages/web/src/components/LinkButton.tsx @@ -0,0 +1,21 @@ +import type { FC, PropsWithChildren } from 'react'; +import { Link } from 'react-router'; +import { Button, type ButtonProps } from './ui/button'; + +type LinkButtonProps = { + to: string; + newTab?: boolean; +} & ButtonProps; + +const LinkButton: FC> = ({ + to, + newTab, + children, + ...props +}) => ( + + + +); + +export default LinkButton; diff --git a/deep-sea-stories/packages/web/src/components/PeerTile.tsx b/deep-sea-stories/packages/web/src/components/PeerTile.tsx new file mode 100644 index 0000000..526496c --- /dev/null +++ b/deep-sea-stories/packages/web/src/components/PeerTile.tsx @@ -0,0 +1,54 @@ +import { type FC, type HTMLAttributes, useEffect, useRef } from 'react'; +import { cn } from '@/lib/utils'; + +export type PeerTileProps = { + stream?: MediaStream | null; + audioStream?: MediaStream | null; + name: string; +} & HTMLAttributes; + +export const PeerTile: FC = ({ + stream, + audioStream, + name, + className, + ...props +}) => { + const videoRef = useRef(null); + const audioRef = useRef(null); + + useEffect(() => { + if (!videoRef.current) return; + videoRef.current.srcObject = stream ?? null; + }, [stream]); + + useEffect(() => { + if (!audioRef.current) return; + audioRef.current.srcObject = audioStream ?? null; + }, [audioStream]); + + return ( +
+ {stream ? ( + + ) : ( +
{name}
+ )} + {/* biome-ignore lint/a11y/useMediaCaption: Peer audio feed from WebRTC doesn't have captions */} +
+ ); +}; diff --git a/deep-sea-stories/packages/web/src/components/RoomControls.tsx b/deep-sea-stories/packages/web/src/components/RoomControls.tsx new file mode 100644 index 0000000..63e793c --- /dev/null +++ b/deep-sea-stories/packages/web/src/components/RoomControls.tsx @@ -0,0 +1,59 @@ +import { Check } from 'lucide-react'; +import type { FC } from 'react'; +import { useEffect, useState } from 'react'; +import CopyButton from './CopyButton'; +import HowItWorks from './HowItWorks'; +import HowToPlay from './HowToPlay'; +import StorySelectionPanel from './StorySelectionPanel'; +import { Button } from './ui/button'; +import { toast } from './ui/sonner'; +import { useTRPCClient } from '@/contexts/trpc'; + +export type RoomControlsProps = { + roomId: string; +}; + +const RoomControls: FC = ({ roomId }) => { + const url = `https://dss.fishjam.io/${roomId}`; + const [isStoryPanelOpen, setIsStoryPanelOpen] = useState(false); + const trpc = useTRPCClient(); + + useEffect(() => { + void trpc.getStories.query(); + }, [trpc]); + + return ( +
+
+ Deep Sea Stories +
+
+ +
+
+ + + toast('Gameroom link copied to clipboard', Check)} + value={url} + > + {url.length > 40 ? `${url.substring(0, 37)}...` : url} + +
+ setIsStoryPanelOpen(false)} + roomId={roomId} + /> +
+ ); +}; + +export default RoomControls; diff --git a/deep-sea-stories/packages/web/src/components/StorySelectionPanel.tsx b/deep-sea-stories/packages/web/src/components/StorySelectionPanel.tsx new file mode 100644 index 0000000..07e2c07 --- /dev/null +++ b/deep-sea-stories/packages/web/src/components/StorySelectionPanel.tsx @@ -0,0 +1,136 @@ +import { Check } from 'lucide-react'; +import type { FC } from 'react'; +import { useEffect, useState } from 'react'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from './ui/dialog'; +import { Button } from './ui/button'; +import { useTRPCClient } from '@/contexts/trpc'; +import { toast } from './ui/sonner'; +import type { StoryData } from '@deep-sea-stories/common'; + +export type StorySelectionPanelProps = { + isOpen: boolean; + onClose: () => void; + roomId: string; + onStorySelected?: (storyIndex: number) => void; +}; + +const StorySelectionPanel: FC = ({ + isOpen, + onClose, + roomId, + onStorySelected, +}) => { + const trpcClient = useTRPCClient(); + const [selectedStoryId, setSelectedStoryId] = useState(null); + const [isStarting, setIsStarting] = useState(false); + const [stories, setStories] = useState([]); + const [isLoadingStories, setIsLoadingStories] = useState(false); + + useEffect(() => { + if (!isOpen) return; + + const fetchStories = async () => { + setIsLoadingStories(true); + try { + const fetchedStories = await trpcClient.getStories.query(); + setStories(fetchedStories); + } catch (error) { + console.error('Failed to fetch stories:', error); + toast('Failed to load stories', Check); + } finally { + setIsLoadingStories(false); + } + }; + + fetchStories(); + }, [isOpen, trpcClient]); + + const handleStartStory = async () => { + if (!selectedStoryId) return; + + setIsStarting(true); + try { + await trpcClient.startStory.mutate({ + roomId, + storyId: selectedStoryId, + }); + toast('Story started successfully', Check); + onStorySelected?.(selectedStoryId); + onClose(); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : 'Failed to start story'; + toast(`Error: ${errorMessage}`, Check); + } finally { + setIsStarting(false); + } + }; + + return ( + + + + Choose a Story + + Select a mystery story to play with your friends + + + +
+ {isLoadingStories ? ( +
+ Loading stories... +
+ ) : ( + stories.map((story) => ( + + )) + )} +
+ + {selectedStoryId && stories.length > 0 && ( +
+

Preview

+

+ {stories.find((s: StoryData) => s.id === selectedStoryId)?.front} +

+
+ )} + +
+ + +
+
+
+ ); +}; + +export default StorySelectionPanel; diff --git a/deep-sea-stories/packages/web/src/components/TitleBar.tsx b/deep-sea-stories/packages/web/src/components/TitleBar.tsx new file mode 100644 index 0000000..f8468f0 --- /dev/null +++ b/deep-sea-stories/packages/web/src/components/TitleBar.tsx @@ -0,0 +1,14 @@ +import type { FC } from 'react'; + +const TitleBar: FC = () => { + return ( +
+

Deep Sea Stories

+

+ Hear the most mysterious stories and try to deduce how they happened. +

+
+ ); +}; + +export default TitleBar; diff --git a/deep-sea-stories/packages/web/src/components/ui/button.tsx b/deep-sea-stories/packages/web/src/components/ui/button.tsx new file mode 100644 index 0000000..e8f7c26 --- /dev/null +++ b/deep-sea-stories/packages/web/src/components/ui/button.tsx @@ -0,0 +1,49 @@ +import { Slot } from '@radix-ui/react-slot'; +import { cva, type VariantProps } from 'class-variance-authority'; +import * as React from 'react'; + +import { cn } from '@/lib/utils'; + +const buttonVariants = cva( + 'inline-flex gap-2 items-center justify-center whitespace-nowrap rounded-full font-display transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive', + { + variants: { + variant: { + default: + 'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90', + outline: + 'border-2 border-foreground bg-background shadow-xs hover:bg-accent', + }, + size: { + default: 'px-6 h-12', + large: 'px-8 h-16 text-lg', + }, + }, + defaultVariants: { + variant: 'default', + size: 'default', + }, + }, +); + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean; +} + +const Button = React.forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : 'button'; + return ( + + ); + }, +); +Button.displayName = 'Button'; + +export { Button, buttonVariants }; diff --git a/deep-sea-stories/packages/web/src/components/ui/dialog.tsx b/deep-sea-stories/packages/web/src/components/ui/dialog.tsx new file mode 100644 index 0000000..aed27fa --- /dev/null +++ b/deep-sea-stories/packages/web/src/components/ui/dialog.tsx @@ -0,0 +1,120 @@ +import * as DialogPrimitive from '@radix-ui/react-dialog'; +import { X } from 'lucide-react'; +import * as React from 'react'; + +import { cn } from '@/lib/utils'; + +const Dialog = DialogPrimitive.Root; + +const DialogTrigger = DialogPrimitive.Trigger; + +const DialogPortal = DialogPrimitive.Portal; + +const DialogClose = DialogPrimitive.Close; + +const DialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; + +const DialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + {children} + + + Close + + + +)); +DialogContent.displayName = DialogPrimitive.Content.displayName; + +const DialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +DialogHeader.displayName = 'DialogHeader'; + +const DialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +DialogFooter.displayName = 'DialogFooter'; + +const DialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogTitle.displayName = DialogPrimitive.Title.displayName; + +const DialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogDescription.displayName = DialogPrimitive.Description.displayName; + +export { + Dialog, + DialogPortal, + DialogOverlay, + DialogClose, + DialogTrigger, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +}; diff --git a/deep-sea-stories/packages/web/src/components/ui/input.tsx b/deep-sea-stories/packages/web/src/components/ui/input.tsx new file mode 100644 index 0000000..f7eeac5 --- /dev/null +++ b/deep-sea-stories/packages/web/src/components/ui/input.tsx @@ -0,0 +1,22 @@ +import * as React from 'react'; + +import { cn } from '@/lib/utils'; + +const Input = React.forwardRef>( + ({ className, type, ...props }, ref) => { + return ( + + ); + }, +); +Input.displayName = 'Input'; + +export { Input }; diff --git a/deep-sea-stories/packages/web/src/components/ui/label.tsx b/deep-sea-stories/packages/web/src/components/ui/label.tsx new file mode 100644 index 0000000..617ff98 --- /dev/null +++ b/deep-sea-stories/packages/web/src/components/ui/label.tsx @@ -0,0 +1,24 @@ +import * as LabelPrimitive from '@radix-ui/react-label'; +import { cva, type VariantProps } from 'class-variance-authority'; +import * as React from 'react'; + +import { cn } from '@/lib/utils'; + +const labelVariants = cva( + 'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70', +); + +const Label = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & + VariantProps +>(({ className, ...props }, ref) => ( + +)); +Label.displayName = LabelPrimitive.Root.displayName; + +export { Label }; diff --git a/deep-sea-stories/packages/web/src/components/ui/scroll-area.tsx b/deep-sea-stories/packages/web/src/components/ui/scroll-area.tsx new file mode 100644 index 0000000..5d611a2 --- /dev/null +++ b/deep-sea-stories/packages/web/src/components/ui/scroll-area.tsx @@ -0,0 +1,46 @@ +import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area'; +import * as React from 'react'; + +import { cn } from '@/lib/utils'; + +const ScrollArea = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + {children} + + + + +)); +ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName; + +const ScrollBar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, orientation = 'vertical', ...props }, ref) => ( + + + +)); +ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName; + +export { ScrollArea, ScrollBar }; diff --git a/deep-sea-stories/packages/web/src/components/ui/select.tsx b/deep-sea-stories/packages/web/src/components/ui/select.tsx new file mode 100644 index 0000000..e8fda90 --- /dev/null +++ b/deep-sea-stories/packages/web/src/components/ui/select.tsx @@ -0,0 +1,158 @@ +import * as SelectPrimitive from '@radix-ui/react-select'; +import { Check, ChevronDown, ChevronUp } from 'lucide-react'; +import * as React from 'react'; + +import { cn } from '@/lib/utils'; + +const Select = SelectPrimitive.Root; + +const SelectGroup = SelectPrimitive.Group; + +const SelectValue = SelectPrimitive.Value; + +const SelectTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + span]:line-clamp-1', + className, + )} + {...props} + > + {children} + + + + +)); +SelectTrigger.displayName = SelectPrimitive.Trigger.displayName; + +const SelectScrollUpButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)); +SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName; + +const SelectScrollDownButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)); +SelectScrollDownButton.displayName = + SelectPrimitive.ScrollDownButton.displayName; + +const SelectContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, position = 'popper', ...props }, ref) => ( + + + + + {children} + + + + +)); +SelectContent.displayName = SelectPrimitive.Content.displayName; + +const SelectLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +SelectLabel.displayName = SelectPrimitive.Label.displayName; + +const SelectItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + + {children} + +)); +SelectItem.displayName = SelectPrimitive.Item.displayName; + +const SelectSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +SelectSeparator.displayName = SelectPrimitive.Separator.displayName; + +export { + Select, + SelectGroup, + SelectValue, + SelectTrigger, + SelectContent, + SelectLabel, + SelectItem, + SelectSeparator, + SelectScrollUpButton, + SelectScrollDownButton, +}; diff --git a/deep-sea-stories/packages/web/src/components/ui/sonner.tsx b/deep-sea-stories/packages/web/src/components/ui/sonner.tsx new file mode 100644 index 0000000..58c871f --- /dev/null +++ b/deep-sea-stories/packages/web/src/components/ui/sonner.tsx @@ -0,0 +1,49 @@ +import type { LucideIcon } from 'lucide-react'; +import { useTheme } from 'next-themes'; +import { Toaster as Sonner, toast as sonnerToast } from 'sonner'; + +type ToasterProps = React.ComponentProps; + +export function toast(title: string, icon: LucideIcon) { + return sonnerToast.custom(() => ); +} + +type ToastProps = { + icon: LucideIcon; + title: string; +}; + +function Toast({ title, icon: Icon }: ToastProps) { + return ( +
+ +

{title}

+
+ ); +} + +const Toaster = ({ ...props }: ToasterProps) => { + const { theme = 'system' } = useTheme(); + + return ( + + ); +}; + +export { Toaster }; diff --git a/deep-sea-stories/packages/web/src/contexts/trpc.tsx b/deep-sea-stories/packages/web/src/contexts/trpc.tsx new file mode 100644 index 0000000..c6a4471 --- /dev/null +++ b/deep-sea-stories/packages/web/src/contexts/trpc.tsx @@ -0,0 +1,33 @@ +import type { QueryClient } from '@tanstack/react-query'; +import { createTRPCClient, httpBatchLink } from '@trpc/client'; +import { createTRPCContext } from '@trpc/tanstack-react-query'; +import type { AppRouter } from 'backend'; +import { type FC, type PropsWithChildren, useState } from 'react'; + +export const { TRPCProvider, useTRPC, useTRPCClient } = + createTRPCContext(); + +interface TRPCClientProviderProps extends PropsWithChildren { + queryClient: QueryClient; +} + +export const TRPCClientProvider: FC = ({ + queryClient, + children, +}) => { + const [trpcClient] = useState(() => + createTRPCClient({ + links: [ + httpBatchLink({ + url: import.meta.env.VITE_BACKEND_URL, + }), + ], + }), + ); + + return ( + + {children} + + ); +}; diff --git a/deep-sea-stories/packages/web/src/index.css b/deep-sea-stories/packages/web/src/index.css new file mode 100644 index 0000000..ab4e770 --- /dev/null +++ b/deep-sea-stories/packages/web/src/index.css @@ -0,0 +1,162 @@ +/* biome-ignore-all lint/suspicious/noUnknownAtRules: tailwind */ + +@import "tailwindcss"; +@import "tw-animate-css"; + +@source "inline:grid-cols-{1..4..1}"; + +@custom-variant dark (&:is(.dark *)); + +@theme { + --black: #221f1c; + --blacker: #090a0b; + --white: #f4f4f4; + --whiter: #ffffff; + --light-gray: #908f8d; + --dark-gray: #3e3d3c; + + --font-sans: aktiv-grotesk, sans-serif; + --font-display: june-expt, sans-serif; + --font-title: june-curious, sans-serif; +} + +:root { + --radius: 0.625rem; + --background: var(--black); + --foreground: var(--white); + --card: var(--black); + --card-foreground: var(--white); + --popover: var(--black); + --popover-foreground: var(--black); + --primary: var(--white); + --primary-foreground: var(--black); + --secondary: var(--whiter); + --secondary-foreground: var(--blacker); + --muted: var(--dark-gray); + --muted-foreground: var(--light-gray); + --accent: var(--dark-gray); + --accent-foreground: var(--light-gray); + --destructive: oklch(0.577 0.245 27.325); + --border: var(--dark-gray); + --input: var(--black); + --ring: oklch(0.704 0.04 256.788); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --sidebar: oklch(0.984 0.003 247.858); + --sidebar-foreground: var(--black); + --sidebar-primary: oklch(0.208 0.042 265.755); + --sidebar-primary-foreground: oklch(0.984 0.003 247.858); + --sidebar-accent: oklch(0.968 0.007 247.896); + --sidebar-accent-foreground: oklch(0.208 0.042 265.755); + --sidebar-border: oklch(0.929 0.013 255.508); + --sidebar-ring: oklch(0.704 0.04 256.788); +} + +@font-face { + font-family: aktiv-grotesk; + src: + url("./assets/fonts/AktivGrotesk.woff2") format("woff2"), + url("./assets/fonts/AktivGrotesk.woff") format("woff"), + url("./assets/fonts/AktivGrotesk.otf") format("opentype"); + font-display: auto; +} + +@font-face { + font-family: june-expt; + src: url("./assets/fonts/JuneExptActive.ttf") format("truetype"); + font-display: auto; +} + +@font-face { + font-family: june-curious; + src: url("./assets/fonts/JuneExptCurious.otf") format("opentype"); + font-display: auto; +} + +@theme inline { + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); + --color-sidebar: var(--sidebar); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); +} + +.dark { + --background: oklch(0.129 0.042 264.695); + --foreground: oklch(0.984 0.003 247.858); + --card: oklch(0.208 0.042 265.755); + --card-foreground: oklch(0.984 0.003 247.858); + --popover: oklch(0.208 0.042 265.755); + --popover-foreground: oklch(0.984 0.003 247.858); + --primary: oklch(0.929 0.013 255.508); + --primary-foreground: oklch(0.208 0.042 265.755); + --secondary: oklch(0.279 0.041 260.031); + --secondary-foreground: oklch(0.984 0.003 247.858); + --muted: oklch(0.279 0.041 260.031); + --muted-foreground: oklch(0.704 0.04 256.788); + --accent: oklch(0.279 0.041 260.031); + --accent-foreground: oklch(0.984 0.003 247.858); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.551 0.027 264.364); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.208 0.042 265.755); + --sidebar-foreground: oklch(0.984 0.003 247.858); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.984 0.003 247.858); + --sidebar-accent: oklch(0.279 0.041 260.031); + --sidebar-accent-foreground: oklch(0.984 0.003 247.858); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.551 0.027 264.364); +} +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } + + button:not([disabled]), + [role="button"]:not([disabled]) { + cursor: pointer; + } +} diff --git a/deep-sea-stories/packages/web/src/lib/utils.ts b/deep-sea-stories/packages/web/src/lib/utils.ts new file mode 100644 index 0000000..51f4330 --- /dev/null +++ b/deep-sea-stories/packages/web/src/lib/utils.ts @@ -0,0 +1,45 @@ +import { type ClassValue, clsx } from 'clsx'; +import { twMerge } from 'tailwind-merge'; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} + +const adjectives = [ + 'abyssal', + 'deep', + 'azure', + 'sunken', + 'coral', + 'oceanic', + 'tidal', + 'bioluminescent', + 'mysterious', + 'ancient', + 'dark', + 'glowing', +]; + +const nouns = [ + 'trench', + 'reef', + 'anemone', + 'whale', + 'kraken', + 'octopus', + 'leviathan', + 'shark', + 'jellyfish', + 'nautilus', + 'squid', + 'shipwreck', + 'angler', +]; + +export function generateDeepSeaSlug(): string { + const randomAdjective = + adjectives[Math.floor(Math.random() * adjectives.length)]; + const randomNoun = nouns[Math.floor(Math.random() * nouns.length)]; + + return `${randomAdjective}-${randomNoun}-${Math.floor(Math.random() * 100)}`; +} diff --git a/deep-sea-stories/packages/web/src/main.tsx b/deep-sea-stories/packages/web/src/main.tsx new file mode 100644 index 0000000..1cebfbd --- /dev/null +++ b/deep-sea-stories/packages/web/src/main.tsx @@ -0,0 +1,38 @@ +import { FishjamProvider } from '@fishjam-cloud/react-client'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import { TRPCClientProvider } from './contexts/trpc.tsx'; +import './index.css'; +import { BrowserRouter, Route, Routes } from 'react-router'; +import Layout from './Layout.tsx'; +import HomeView from './views/HomeView.tsx'; +import RoomView from './views/RoomView.tsx'; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retryDelay: 2000, + }, + }, +}); + +// biome-ignore lint/style/noNonNullAssertion: root always exists +createRoot(document.getElementById('root')!).render( + + + + + + + + } /> + } /> + + + + + + + , +); diff --git a/deep-sea-stories/packages/web/src/views/GameView.tsx b/deep-sea-stories/packages/web/src/views/GameView.tsx new file mode 100644 index 0000000..6b4fa0a --- /dev/null +++ b/deep-sea-stories/packages/web/src/views/GameView.tsx @@ -0,0 +1,44 @@ +import { usePeers } from '@fishjam-cloud/react-client'; +import type { FC } from 'react'; +import AgentPanel from '@/components/AgentPanel'; +import { PeerTile } from '@/components/PeerTile'; +import RoomControls from '@/components/RoomControls'; + +export type GameViewProps = { + roomId: string; +}; + +const GameView: FC = ({ roomId }) => { + const { remotePeers, localPeer } = usePeers<{ name: string }>(); + const peers = remotePeers.length + 1; + return ( + <> +
+ + +
+
+ + {remotePeers.map((peer) => ( + + ))} +
+ + ); +}; +export default GameView; diff --git a/deep-sea-stories/packages/web/src/views/HomeView.tsx b/deep-sea-stories/packages/web/src/views/HomeView.tsx new file mode 100644 index 0000000..afbe202 --- /dev/null +++ b/deep-sea-stories/packages/web/src/views/HomeView.tsx @@ -0,0 +1,41 @@ +import Footer from '@/components/Footer'; +import TitleBar from '@/components/TitleBar'; +import { useTRPCClient } from '@/contexts/trpc'; +import { useNavigate } from 'react-router'; +import { Button } from '@/components/ui/button'; +import { useState } from 'react'; + +export default function HomeView() { + const navigate = useNavigate(); + const trpcClient = useTRPCClient(); + const [isLoading, setIsLoading] = useState(false); + + const handleCreateRoom = async () => { + setIsLoading(true); + try { + const room = await trpcClient.createRoom.mutate(); + navigate(`/${room.id}`); + } catch (error) { + console.error('Failed to create room:', error); + } finally { + setIsLoading(false); + } + }; + + return ( + <> + +
+ +
+