diff --git a/app/composables/peer.ts b/app/composables/peer.ts deleted file mode 100644 index 11f855b..0000000 --- a/app/composables/peer.ts +++ /dev/null @@ -1,78 +0,0 @@ -interface Message { - status: 'opened' | 'started' | 'closed' - extractions: number[] -} - -export function usePeer(id: MaybeRefOrGetter, type: 'host' | 'client') { - const router = useRouter(), route = useRoute(), toast = useToast() - const { t } = useI18n() - - const protocol = computed(() => location.protocol.includes('s') ? 's' : '') - - const wsUrl = computed(() => `ws${protocol.value}://${location.host}/_ws?id=${toValue(id)}&type=${type}`) - - const extractions = ref([]) - - const { status, data: wsData, send, open, close } = useWebSocket(wsUrl, { - heartbeat: { - interval: 5000, - pongTimeout: 5000, - message: 'ping', - responseMessage: 'pong', - }, - onConnected() { - router.replace({ query: { id: toValue(id), ...route.query } }) - console.warn('WebSocket connected') - }, - onDisconnected(ws, e) { - console.warn('WebSocket disconnected:', e) - navigateTo({ path: '/', query: {} }, { redirectCode: 302 }) - if (type === 'client' && !e.wasClean) { - toast.add({ - title: t('game.error.title'), - description: t('game.error.description'), - color: 'error', - }) - } - }, - onError(_ws, event) { - console.error('WebSocket error:', event) - }, - }) - - watch(wsData, (data) => { - if (data === null) return - let content: Message - - try { - content = JSON.parse(data) - } - catch (error) { - console.error(error) - console.error('Failed to parse WebSocket data:', data) - return - } - - extractions.value = content.extractions - - if (content.status === 'closed' && type === 'client') { - toast.add({ - title: t('game.end.title'), - description: t('game.end.description'), - color: 'info', - }) - close() - } - }) - - function sendExtraction(extracted: number) { - send(JSON.stringify({ extracted })) - } - - return { - status, - extractions, - open, - sendExtraction, - } -} diff --git a/app/pages/board.vue b/app/pages/board.vue index 4b042c2..2dd2e88 100644 --- a/app/pages/board.vue +++ b/app/pages/board.vue @@ -1,24 +1,25 @@ @@ -155,8 +162,8 @@ function extractNumber() { size="xl" highlight :aria-label="$t('board.smirk')" :items="smirkOptions" /> - + {{ currentSmirk.number }}
diff --git a/app/pages/cards.vue b/app/pages/cards.vue index ca6f0d6..0003b10 100644 --- a/app/pages/cards.vue +++ b/app/pages/cards.vue @@ -6,7 +6,37 @@ definePageMeta({ }) const gameId = useRouteQuery('id') -const { extractions } = usePeer(gameId, 'client') +const { t } = useI18n(), toast = useToast() +const extractions = ref([]) +const { data, error } = useEventSource(() => `/game?id=${gameId.value}`) + +watchImmediate(error, (err) => { + if (err) { + navigateTo({ path: '/', query: {} }, { redirectCode: 302 }) + toast.add({ + title: t('game.error.title'), + description: t('game.error.description'), + color: 'error', + }) + } +}) + +watch(data, (val) => { + if (val === null) return + + let content: number[] + + try { + content = JSON.parse(val) + } + catch (error) { + console.error(error) + console.error('Failed to parse WebSocket data:', data) + return + } + + extractions.value = content +}) const lastExtractions = computed(() => extractions.value.slice(-5)) diff --git a/app/pages/index.vue b/app/pages/index.vue index dfd587e..f613b6a 100644 --- a/app/pages/index.vue +++ b/app/pages/index.vue @@ -1,7 +1,22 @@
diff --git a/bun.lockb b/bun.lockb index 6ac4c88..b0caa88 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/i18n/locales/en-GB.json b/i18n/locales/en-GB.json index 363eabd..35f724d 100644 --- a/i18n/locales/en-GB.json +++ b/i18n/locales/en-GB.json @@ -65,10 +65,6 @@ "game": { "title": "{0} - {1} remaining numbers", "id": "Game ID", - "end": { - "title": "Game ended", - "description": "The game was terminated by the player who created it." - }, "error": { "title": "Game not found", "description": "The game you are trying to access does not exist." diff --git a/i18n/locales/it-IT.json b/i18n/locales/it-IT.json index 26c1211..4de6751 100644 --- a/i18n/locales/it-IT.json +++ b/i18n/locales/it-IT.json @@ -66,10 +66,6 @@ "id": "ID partita", "title": "{0} - {1} numeri rimanenti", "description": "Condividi questo ID con i tuoi amici per permettere loro di partecipare a questa partita!", - "end": { - "title": "Partita terminata", - "description": "La partita รจ stata terminata dal giocatore che l'ha creata." - }, "error": { "title": "Partita non trovata", "description": "La partita a cui stai cercando di accedere non esiste." diff --git a/nuxt.config.ts b/nuxt.config.ts index 238f17c..13a1ab3 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -2,12 +2,6 @@ export default defineNuxtConfig({ devtools: { enabled: true }, - nitro: { - experimental: { - websocket: true, - }, - }, - modules: [ 'nuxt-lodash', '@nuxthub/core', diff --git a/package.json b/package.json index 526ccaa..0f3033a 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,8 @@ "nuxt-lodash": "2.5.3", "pinia": "^2.3.0", "uncrypto": "^0.1.3", - "unenv": "^1.10.0" + "unenv": "^1.10.0", + "zod": "^3.24.1" }, "devDependencies": { "@antfu/eslint-config": "^3.12.2", diff --git a/server/api/game/create.post.ts b/server/api/game/create.post.ts new file mode 100644 index 0000000..72fb39e --- /dev/null +++ b/server/api/game/create.post.ts @@ -0,0 +1,19 @@ +import { randomUUID } from 'uncrypto' + +export default defineEventHandler(async () => { + const [id, ...hostId] = randomUUID().split('-') + + const game = await setGame(id, { + id, + extractions: [], + clients: [], + host: [id, ...hostId].join('-'), + }) + + console.info(`[Game: ${game.id}] Host ${game.host} created game`) + console.info(`Active games: ${await getActiveGames()}`) + + return { + gameId: id, + } +}) diff --git a/server/api/game/extract.post.ts b/server/api/game/extract.post.ts new file mode 100644 index 0000000..0a95e43 --- /dev/null +++ b/server/api/game/extract.post.ts @@ -0,0 +1,28 @@ +import { z } from 'zod' + +const extractionSchema = z.object({ + gameId: z.string().length(8), + number: z.number().min(1).max(90), +}) + +export default defineEventHandler(async (event) => { + const { gameId, number } = await readValidatedBody(event, body => extractionSchema.parse(body)) + + const game = await getGame(gameId) + + if (!game) { + throw createError({ + statusCode: 400, + statusMessage: 'Game not found', + }) + } + + game.extractions.push(number) + await setGame(gameId, game) + + console.info(`[Game: ${gameId}] Host ${game.host} extracted number ${number}`) + + return { + extractions: game.extractions, + } +}) diff --git a/server/api/game/join.get.ts b/server/api/game/join.get.ts new file mode 100644 index 0000000..b7a87b7 --- /dev/null +++ b/server/api/game/join.get.ts @@ -0,0 +1,15 @@ +import { z } from 'zod' + +const checkSchema = z.object({ + id: z.string().length(8), +}) + +export default defineEventHandler(async (event) => { + const { id } = await getValidatedQuery(event, body => checkSchema.parse(body)) + + const game = await getGame(id) + + return { + found: !!game, + } +}) diff --git a/server/routes/_ws.ts b/server/routes/_ws.ts deleted file mode 100644 index 065f91e..0000000 --- a/server/routes/_ws.ts +++ /dev/null @@ -1,79 +0,0 @@ -import type { Peer } from 'crossws' - -interface Game { - id: string - extractions: number[] - host: string - clients: string[] -} - -const getActiveGames = async () => (await hubKV().keys('game')).length - -const setGame = (id: string, game: Game) => hubKV().set(`game:${id}`, game, { ttl: 60 * 60 * 24 }) - -async function getGame(peer: Peer) { - const params = new URL(peer.websocket.url!).searchParams - const id = params.get('id')! - const game = await hubKV().get(`game:${id}`) - const type = params.get('type')! - if (game) { - if (type === 'host' && game.host === peer.id) return game - peer.subscribe(id) - game.clients = [...new Set([...game.clients, peer.id])] - await setGame(id, game) - console.log(`[Peer] Client ${peer.id} connected to game ${id}`) - return game - } - else if (type === 'host') { - const newGame = { id, extractions: [], host: peer.id, clients: [] } - await setGame(id, newGame) - console.log(`[Peer] Host ${peer.id} created game ${id}`) - console.log(`[Peer] Active games: ${await getActiveGames()}`) - return newGame - } - else return null -} - -export default defineWebSocketHandler({ - async open(peer) { - const game = await getGame(peer) - if (!game) peer.terminate() - else peer.send(JSON.stringify({ status: 'opened', extractions: game.extractions })) - }, - async message(peer, message) { - if (message.text().includes('ping')) peer.send('pong') - else { - const game = await getGame(peer) - if (!game) return peer.close(1011, 'Game not found') - - const { extracted } = message.json<{ extracted: number }>() - - if (game.host === peer.id) { - game.extractions = [...game.extractions, extracted] - await setGame(game.id, game) - peer.publish(game.id, JSON.stringify({ status: 'started', extractions: game.extractions })) - } - } - }, - async close(peer) { - const game = await getGame(peer) - if (!game) return - - if (peer.id === game.host) { - await hubKV().del(`game:${game.id}`) - console.warn(`[Peer] Host ${peer.id} deleted game ${game.id}`) - peer.publish(game.id, JSON.stringify({ status: 'closed', extractions: game.extractions })) - } - else { - peer.unsubscribe(game.id) - game.clients = game.clients.filter(client => client !== peer.id) - await setGame(game.id, game) - console.warn(`[Peer] Client ${peer.id} disconnected from game ${game.id}`) - } - - console.log(`[Peer] Active games: ${await getActiveGames()}`) - }, - error(peer, error) { - console.error(`[Peer] Error: ${error.message}`) - }, -}) diff --git a/server/routes/game.ts b/server/routes/game.ts new file mode 100644 index 0000000..a5ab397 --- /dev/null +++ b/server/routes/game.ts @@ -0,0 +1,45 @@ +import { randomUUID } from 'uncrypto' +import { z } from 'zod' + +const gameSchema = z.object({ + id: z.string().length(8), +}) + +export default defineEventHandler(async (event) => { + const { id } = await getValidatedQuery(event, data => gameSchema.parse(data)) + + let game = await getGame(id) + + if (!game) { + throw createError({ + statusCode: 400, + statusMessage: 'Game not found', + }) + } + + const clientId = randomUUID() + + game.clients.push(clientId) + game = await setGame(id, game) + + console.info(`[Game: ${game.id}] Client ${clientId} joined game`) + + const eventStream = createEventStream(event) + + const interval = setInterval(async () => { + const game = await getGame(id) + if (!game) { + clearInterval(interval) + await eventStream.close() + return + } + await eventStream.push(JSON.stringify(game.extractions)) + }, 1000) + + eventStream.onClosed(async () => { + clearInterval(interval) + await eventStream.close() + }) + + return eventStream.send() +}) diff --git a/server/utils/game.ts b/server/utils/game.ts new file mode 100644 index 0000000..76d8508 --- /dev/null +++ b/server/utils/game.ts @@ -0,0 +1,18 @@ +export interface Game { + id: string + extractions: number[] + host: string + clients: string[] +} + +export async function getActiveGames() { + const keys = await hubKV().keys('game') + return keys.length +} + +export async function setGame(id: string, game: Game) { + await hubKV().set(`game:${id}`, game, { ttl: 60 * 60 * 24 }) + return game +} + +export const getGame = (id: string) => hubKV().get(`game:${id}`)