diff --git a/game/src/activities.ts b/game/src/activities.ts index 92d2a26..8d94eb9 100644 --- a/game/src/activities.ts +++ b/game/src/activities.ts @@ -1,7 +1,8 @@ -import { Snake, Round, Lobby } from './workflows'; +import { Snake, Round } from './workflows'; import { io } from 'socket.io-client'; const socket = io('http://localhost:5173'); +const lobbySocket = io('http://localhost:5173/lobby'); export async function snakeNom(snakeId: string, durationMs: number) { await new Promise((resolve) => setTimeout(resolve, durationMs)); @@ -12,14 +13,6 @@ export async function snakeMovedNotification(snake: Snake) { socket.emit('snakeMoved', { snakeId: snake.id, segments: snake.segments }); } -export async function playerInvitation(playerId: string, snakeId: string) { - socket.emit('playerInvitation', { playerId, snakeId }); -} - -export async function lobbyNotification(lobby: Lobby) { - socket.emit('lobby', { lobby }); -} - export async function roundStartedNotification(round: Round) { socket.emit('roundStarted', { round }); } diff --git a/game/src/workflows.ts b/game/src/workflows.ts index 5b142cf..07c63b7 100644 --- a/game/src/workflows.ts +++ b/game/src/workflows.ts @@ -6,7 +6,6 @@ import { condition, log, startChild, - workflowInfo, sleep, getExternalWorkflowHandle, defineQuery, @@ -25,7 +24,7 @@ const { snakeNom } = proxyActivities({ startToCloseTimeout: '5 seconds', }); -const { playerInvitation, snakeMovedNotification, roundStartedNotification, roundUpdateNotification, roundFinishedNotification, lobbyNotification } = proxyLocalActivities({ +const { snakeMovedNotification, roundStartedNotification, roundUpdateNotification, roundFinishedNotification } = proxyLocalActivities({ startToCloseTimeout: '5 seconds', }); @@ -41,32 +40,11 @@ type Game = { teams: Teams; }; -export type Lobby = { - teams: TeamSummaries; -} - -type TeamSummaries = Record; - type Team = { name: string; - players: Player[]; score: number; }; - -type TeamSummary = { - name: string; - players: number; - score: number; -}; - -type Player = { - id: string; - name: string; - score: number; -}; - export type Teams = Record; -type Snakes = Record; export type Round = { config: GameConfig; @@ -95,11 +73,12 @@ type Segment = { export type Snake = { id: string; - teamName: string; playerId: string; + teamName: string; segments: Segment[]; ateApple?: boolean; }; +type Snakes = Record; type Direction = 'up' | 'down' | 'left' | 'right'; @@ -121,23 +100,15 @@ function oppositeDirection(direction: Direction): Direction { } export const gameStateQuery = defineQuery('gameState'); -export const lobbyQuery = defineQuery('lobby'); export const roundStateQuery = defineQuery('roundState'); type RoundStartSignal = { + snakes: Snake[]; duration: number; } // UI -> GameWorkflow to start round export const roundStartSignal = defineSignal<[RoundStartSignal]>('roundStart'); -// Player UI -> GameWorkflow to join team -type PlayerJoinSignal = { - id: string; - name: string; - teamName: string; -} -export const playerJoinSignal = defineSignal<[PlayerJoinSignal]>('playerJoin'); - // Player UI -> SnakeWorkflow to change direction export const snakeChangeDirectionSignal = defineSignal<[Direction]>('snakeChangeDirection'); @@ -150,13 +121,7 @@ export async function GameWorkflow(config: GameConfig): Promise { const game: Game = { config, teams: config.teamNames.reduce((acc, name) => { - acc[name] = { name, players: [], score: 0 }; - return acc; - }, {}), - }; - const lobby: Lobby = { - teams: config.teamNames.reduce((acc, name) => { - acc[name] = { name, players: 0, score: 0 }; + acc[name] = { name, score: 0 }; return acc; }, {}), }; @@ -164,49 +129,35 @@ export async function GameWorkflow(config: GameConfig): Promise { setHandler(gameStateQuery, () => { return game; }); - setHandler(lobbyQuery, () => { - return lobby; - }); - - setHandler(playerJoinSignal, async ({ id, name, teamName }) => { - const team = game.teams[teamName]; - team.players.push({ id, name, score: 0 }); - lobby.teams[teamName].players = team.players.length; - - await lobbyNotification(lobby); - }); - - let roundStart = false; - let roundDuration = 0; - setHandler(roundStartSignal, async ({ duration }) => { - roundStart = true; - roundDuration = duration; + let newRound: RoundWorkflowInput | undefined; + setHandler(roundStartSignal, async ({ duration, snakes }) => { + newRound = { config, teams: buildRoundTeams(game), duration, snakes }; }); while (true) { - await condition(() => roundStart); - roundStart = false; + await condition(() => newRound !== undefined); const roundWf = await startChild(RoundWorkflow, { workflowId: ROUND_WF_ID, - args: [{ config, teams: buildRoundTeams(game), duration: roundDuration }] + args: [newRound!] }); const round = await roundWf.result(); for (const team of Object.values(round.teams)) { game.teams[team.name].score += team.score; - lobby.teams[team.name].score += team.score; } + newRound = undefined; } } type RoundWorkflowInput = { config: GameConfig; teams: Teams; + snakes: Snake[]; duration: number; } -export async function RoundWorkflow({ config, teams, duration }: RoundWorkflowInput): Promise { +export async function RoundWorkflow({ config, teams, snakes, duration }: RoundWorkflowInput): Promise { log.info('Starting round', { duration }); const round: Round = { @@ -214,7 +165,7 @@ export async function RoundWorkflow({ config, teams, duration }: RoundWorkflowIn duration: duration, apple: OutOfBounds, teams: teams, - snakes: buildSnakes(config, teams), + snakes: snakes.reduce((acc, snake) => { acc[snake.id] = snake; return acc; }, {}), }; randomizeRound(round); @@ -405,65 +356,32 @@ function randomEmptyPoint(round: Round): Point { return { x: Math.ceil(Math.random() * round.config.width), y: Math.ceil(Math.random() * round.config.height) }; } -function buildSnakes(config: GameConfig, teams: Teams): Snakes { - const snakes: Snakes = {}; - - for (const teamName in teams) { - for (let i = 0; i < config.snakesPerTeam; i++) { - const snake = { - id: `${teamName}-${i}`, - teamName: teamName, - segments: [{ head: OutOfBounds, length: 1, direction: 'down' as Direction }], - playerId: teams[teamName].players[i].id, - }; - snakes[snake.id] = snake; - } - }; - - return snakes; -} - function buildRoundTeams(game: Game): Teams { const teams: Teams = {}; for (const team of Object.values(game.teams)) { - const players = Array.from({ length: game.config.snakesPerTeam }).map(() => nextPlayer(team)); - - teams[team.name] = { name: team.name, players: players, score: 0 }; + teams[team.name] = { name: team.name, score: 0 }; } return teams; } async function startSnakes(snakes: Snakes) { - const commands = Object.values(snakes).flatMap((snake) => - [ - startChild(SnakeWorkflow, { - workflowId: snake.id, - args: [{ roundId: ROUND_WF_ID, id: snake.id, direction: snake.segments[0].direction }] - }), - playerInvitation(snake.playerId, snake.id) - ] + const commands = Object.values(snakes).map((snake) => + startChild(SnakeWorkflow, { + workflowId: snake.id, + args: [{ roundId: ROUND_WF_ID, id: snake.id, direction: snake.segments[0].direction }] + }) ) - // TODO: Do these all get started in same WFT? await Promise.all(commands); } -function nextPlayer(team: Team): Player { - const nextPlayer = team.players.shift(); - if (!nextPlayer) { - throw new Error('No players left on team'); - } - team.players.push(nextPlayer); - return nextPlayer; -} - function randomizeRound(round: Round) { round.apple = randomEmptyPoint(round); for (const snake of Object.values(round.snakes)) { - snake.segments[0].head = randomEmptyPoint(round); - snake.segments[0].direction = randomDirection(); + snake.segments = [ + { head: randomEmptyPoint(round), direction: randomDirection(), length: 1 } + ] } } - diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..baf5af5 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "replay-2024-demo", + "lockfileVersion": 3, + "requires": true, + "packages": {} +} diff --git a/snakes/package-lock.json b/snakes/package-lock.json index ff5346e..1e91766 100644 --- a/snakes/package-lock.json +++ b/snakes/package-lock.json @@ -16,11 +16,13 @@ "devDependencies": { "@fontsource/fira-mono": "^5.0.0", "@neoconfetti/svelte": "^2.0.0", + "@svelte-put/qr": "^1.2.1", "@sveltejs/adapter-auto": "^3.0.0", "@sveltejs/kit": "^2.0.0", "@sveltejs/vite-plugin-svelte": "^3.0.0", "@types/socket.io": "^3.0.2", "@types/socket.io-client": "^3.0.0", + "@types/uuid": "^10.0.0", "autoprefixer": "^10.4.20", "postcss": "^8.4.41", "prettier": "^3.1.1", @@ -911,6 +913,19 @@ "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==" }, + "node_modules/@svelte-put/qr": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@svelte-put/qr/-/qr-1.2.1.tgz", + "integrity": "sha512-VIAze6mCVWdiyq6xnMFhbD3dbTvz9CDFVN3lVkDvx6SYFcstgHuIyb7Pi3aJIr2ClDc4U+W2U+SGhFuQBskvBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "qrcode-generator": "^1.4.4" + }, + "peerDependencies": { + "svelte": "^3.55.0 || ^4.0.0 || ^5.0.0" + } + }, "node_modules/@sveltejs/adapter-auto": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/@sveltejs/adapter-auto/-/adapter-auto-3.2.4.tgz", @@ -1107,6 +1122,13 @@ "socket.io-client": "*" } }, + "node_modules/@types/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==", + "dev": true, + "license": "MIT" + }, "node_modules/abort-controller": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", @@ -2668,6 +2690,13 @@ "node": ">=12.0.0" } }, + "node_modules/qrcode-generator": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/qrcode-generator/-/qrcode-generator-1.4.4.tgz", + "integrity": "sha512-HM7yY8O2ilqhmULxGMpcHSF1EhJJ9yBj8gvDEuZ6M+KGJ0YY2hKpnXvRD+hZPLrDVck3ExIGhmPtSdcjC+guuw==", + "dev": true, + "license": "MIT" + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", diff --git a/snakes/package.json b/snakes/package.json index deb3217..c6b64a5 100644 --- a/snakes/package.json +++ b/snakes/package.json @@ -19,6 +19,7 @@ "@sveltejs/vite-plugin-svelte": "^3.0.0", "@types/socket.io": "^3.0.2", "@types/socket.io-client": "^3.0.0", + "@types/uuid": "^10.0.0", "autoprefixer": "^10.4.20", "postcss": "^8.4.41", "prettier": "^3.1.1", diff --git a/snakes/src/lib/pages/PlayerRegisterPage.svelte b/snakes/src/lib/pages/PlayerRegisterPage.svelte deleted file mode 100644 index a07ca1d..0000000 --- a/snakes/src/lib/pages/PlayerRegisterPage.svelte +++ /dev/null @@ -1,30 +0,0 @@ - - - - Register for Team - - - -
-

{team.toUpperCase()} TEAM

-
- - -
-
diff --git a/snakes/src/lib/pages/SnakeRoundPage.svelte b/snakes/src/lib/pages/SnakeRoundPage.svelte index 7e6a29e..02820bd 100644 --- a/snakes/src/lib/pages/SnakeRoundPage.svelte +++ b/snakes/src/lib/pages/SnakeRoundPage.svelte @@ -1,7 +1,7 @@ - \ No newline at end of file diff --git a/snakes/src/routes/[id]/lobby/+page.svelte b/snakes/src/routes/[id]/lobby/+page.svelte index 31d610c..f905a40 100644 --- a/snakes/src/routes/[id]/lobby/+page.svelte +++ b/snakes/src/routes/[id]/lobby/+page.svelte @@ -3,33 +3,27 @@ import { page } from '$app/stores'; import { io } from 'socket.io-client'; import QR from '@svelte-put/qr/svg/QR.svelte'; - import { demoPlayersJoin } from '$lib/utilities/game-controls'; import type { Lobby } from '$lib/snake/types'; $: ({ id: workflowId } = $page.params); - const socket = io(); + const lobbySocket = io("/lobby"); + let redPlayers = 0; let bluePlayers = 0; let redScore = 0; let blueScore = 0; - const registerDemoPlayers = async () => { - await demoPlayersJoin(socket); - } - const startRound = () => { goto(`/${workflowId}/round`); } - socket.on('lobby', ({ lobby }: { lobby: Lobby }) => { - redPlayers = lobby.teams.red?.players; - redScore = lobby.teams.red?.score; - bluePlayers = lobby.teams.blue?.players; - blueScore = lobby.teams.blue?.score; + lobbySocket.on('lobby', ({ lobby }: { lobby: Lobby }) => { + redPlayers = lobby.teams.red?.players || 0; + redScore = lobby.teams.red?.score || 0; + bluePlayers = lobby.teams.blue?.players || 0; + blueScore = lobby.teams.blue?.score || 0; }); - - socket.emit('fetchLobby'); @@ -41,14 +35,14 @@
- RED TEAM + RED TEAM
Players: {redPlayers}
@@ -59,25 +53,24 @@

Lobby

-
- BLUE TEAM -
- Players: {bluePlayers} -
-
- Score: {blueScore} -
+ BLUE TEAM +
+ Players: {bluePlayers} +
+
+ Score: {blueScore} +
diff --git a/snakes/src/routes/[id]/red/+page.svelte b/snakes/src/routes/[id]/red/+page.svelte deleted file mode 100644 index 29526d1..0000000 --- a/snakes/src/routes/[id]/red/+page.svelte +++ /dev/null @@ -1,5 +0,0 @@ - - \ No newline at end of file diff --git a/snakes/src/routes/[id]/round/[player]/+page.svelte b/snakes/src/routes/[id]/round/[player]/+page.svelte deleted file mode 100644 index d02591b..0000000 --- a/snakes/src/routes/[id]/round/[player]/+page.svelte +++ /dev/null @@ -1,60 +0,0 @@ - - -
-
- -
- - -
- -
- - \ No newline at end of file diff --git a/snakes/src/routes/[id]/team/[team]/+page.svelte b/snakes/src/routes/[id]/team/[team]/+page.svelte new file mode 100644 index 0000000..aee06bf --- /dev/null +++ b/snakes/src/routes/[id]/team/[team]/+page.svelte @@ -0,0 +1,125 @@ + + + + {team.toUpperCase()} Lobby + + +{#if snake && playing} +
+
+ +
+ + +
+ +
+ + +{:else} +
+

{team.toUpperCase()} Lobby

+
+
+
+ {#if invite} +
+ +
+ {:else if accepted} +
+

Invite Accepted

+

Please wait...

+
+ {/if} +
+ Players: {players} +
+
+
+
+
+{/if} \ No newline at end of file diff --git a/snakes/vite.config.ts b/snakes/vite.config.ts index f0be920..a1584c2 100644 --- a/snakes/vite.config.ts +++ b/snakes/vite.config.ts @@ -2,9 +2,77 @@ import { sveltekit } from '@sveltejs/kit/vite'; import { type ViteDevServer, defineConfig } from 'vite'; import { Server } from 'socket.io'; import { Client } from '@temporalio/client'; -import type { Lobby, Round } from '$lib/snake/types'; +import type { Lobby, Round, Snake } from '$lib/snake/types'; const GAME_WORKFLOW_ID = 'SnakeGame'; +const ROUND_WORKFLOW_ID = 'SnakeGameRound'; +const INVITE_TIMEOUT = 10000; + +type SocketLobby = { + teams: Record; +} + +type LobbyTeam = { + players: LobbyPlayers; + playerCount: number; +} + +type LobbyPlayer = { + id: string; + sockets: number; +} + +type LobbyPlayers = Record; + +const addPlayerSocket = (socketLobby: SocketLobby, id: string, teamName: string): boolean => { + let playerAdded = false; + + let team = socketLobby.teams[teamName]; + if (!team) { + console.log(`Creating team ${teamName}`); + team = socketLobby.teams[teamName] = { players: {}, playerCount: 0 }; + } + let player = team.players[id]; + if (!player) { + console.log(`Creating player ${id} in team ${teamName}`); + player = team.players[id] = { id, sockets: 0 }; + team.playerCount++; + playerAdded = true; + } + player.sockets += 1; + + return playerAdded; +} + +const removePlayerSocket = (socketLobby: SocketLobby, id: string, teamName: string): boolean => { + let playerRemoved = false; + + const team = socketLobby.teams[teamName]; + const player = team.players[id]; + player.sockets -= 1; + if (player.sockets === 0) { + console.log(`Removing player ${id} in team ${teamName}`); + delete team.players[id]; + team.playerCount--; + playerRemoved = true; + } + + return playerRemoved; +} + +const lobbySummary = (socketLobby: SocketLobby): Lobby => { + const lobby: Lobby = { teams: {}}; + + for (const teamName of Object.keys(socketLobby.teams)) { + lobby.teams[teamName] = { + name: teamName, + players: socketLobby.teams[teamName].playerCount, + score: 0, + }; + } + + return lobby +} const webSocketServer = { name: 'websocket', @@ -15,20 +83,72 @@ const webSocketServer = { globalThis.io = io; const temporal = new Client(); - io.on('connection', (socket) => { - // Game -> Player UI - socket.on('playerInvitation', ({ playerId, snakeId }) => { - io.to(`player-${playerId}`).emit('playerInvitation', { snakeId }); + const socketLobby: SocketLobby = { teams: {} }; + + const lobbyIO = io.of("/lobby"); + + lobbyIO.on('connection', (socket) => { + const id: string = socket.handshake.auth.id; + const team: string = socket.handshake.auth.team; + + if (id && team) { + socket.data.id = id; + socket.data.team = team; + + socket.join(`player-${id}`); + socket.join(`team-${team}`); + + if (addPlayerSocket(socketLobby, id, team)) { + lobbyIO.emit('lobby', { lobby: lobbySummary(socketLobby) }); + } else { + socket.emit('lobby', { lobby: lobbySummary(socketLobby) }); + } + } else { + lobbyIO.emit('lobby', { lobby: lobbySummary(socketLobby) }); + } + + socket.on('findPlayers', async ({ teams, playersPerTeam }: { teams: string[], playersPerTeam: number }, cb) => { + const playersInvites = teams.map((team) => { + return new Promise((resolve) => { + lobbyIO.to(`team-${team}`).timeout(INVITE_TIMEOUT).emit('roundInvite', (err: any, responses: string[]) => { + if (err) { console.log('errors', err); } + resolve(responses); + }); + }); + }) + const responses = await Promise.all(playersInvites); + const players: Record = {}; + responses.forEach((playerIds, i) => { players[teams[i]] = playerIds.slice(0, playersPerTeam); }); + cb(players); + teams.forEach((team) => { lobbyIO.to(`team-${team}`).emit('roundReady'); }); }); + socket.on('disconnect', () => { + const id: string = socket.data.id; + const team: string = socket.data.team; + if (!id || !team) { + return; + } + + if (removePlayerSocket(socketLobby, id, team)) { + lobbyIO.emit('lobby', { lobby: lobbySummary(socketLobby) }); + } + }); + }); + + io.on('connection', (socket) => { + // Game -> Player UI socket.on('roundStarted', ({ round }) => { io.emit('roundStarted', { round }); }); socket.on('roundUpdate', ({ round }) => { io.emit('roundUpdate', { round }); }); - socket.on('roundFinished', ({ round }) => { + socket.on('roundFinished', ({ round }: { round: Round }) => { io.emit('roundFinished', { round }); + for (const snake of Object.values(round.snakes)) { + lobbyIO.to(`player-${snake.playerId}`).emit('roundFinished'); + } }); socket.on('snakeNom', ({ snakeId }) => { @@ -38,22 +158,21 @@ const webSocketServer = { io.emit('snakeMoved', { snakeId, segments }); }); - socket.on('lobby', ({ lobby }) => { - io.emit('lobby', { lobby }); - }); - // Player UI -> Game - socket.on('roundStart', async ({ duration }) => { + socket.on('roundStart', async ({ duration, snakes }: { duration: number, snakes: Snake[] }) => { try { - await temporal.workflow.getHandle(GAME_WORKFLOW_ID).signal('roundStart', { duration }); + await temporal.workflow.getHandle(GAME_WORKFLOW_ID).signal('roundStart', { duration, snakes }); } catch (err) { console.error(err); } + for (const snake of snakes) { + lobbyIO.to(`player-${snake.playerId}`).emit('roundPlaying', { snake }); + } }); socket.on('fetchRound', async () => { try { - const round = await temporal.workflow.getHandle('SnakeGameRound').query('roundState'); + const round = await temporal.workflow.getHandle(ROUND_WORKFLOW_ID).query('roundState'); if (round && !round.finished) { socket.emit('roundStarted', { round }); } else { @@ -64,15 +183,6 @@ const webSocketServer = { } }); - socket.on('fetchLobby', async () => { - try { - const lobby = await temporal.workflow.getHandle(GAME_WORKFLOW_ID).query('lobby'); - socket.emit('lobby', { lobby }); - } catch (err) { - console.error(err); - } - }); - socket.on('playerJoin', ({ id, name, teamName }, cb) => { temporal.workflow.getHandle('SnakeGame') .signal('playerJoin', { id, name, teamName })