diff --git a/.gitignore b/.gitignore old mode 100644 new mode 100755 index f79d6ed..0c12830 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,3 @@ -# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. - # dependencies /node_modules /.pnp @@ -8,17 +6,6 @@ # testing /coverage -# next.js -/.next/ -/out/ - -# production -/build - -# misc -.DS_Store -*.pem - # debug npm-debug.log* yarn-debug.log* @@ -30,16 +17,15 @@ yarn-error.log* .env.test.local .env.production.local -# vercel -.vercel +# build outputs +package-lock.json +dist/ +target/ +# common artifact extensions **/*.trace **/*.zip **/*.tar.gz **/*.tgz **/*.log -package-lock.json **/*.bun - -dist/ -target/ diff --git a/.goosehints b/.goosehints old mode 100644 new mode 100755 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml old mode 100644 new mode 100755 diff --git a/.prettierrc.json b/.prettierrc.json old mode 100644 new mode 100755 diff --git a/README.md b/README.md old mode 100644 new mode 100755 diff --git a/ansible/deploy.yml b/ansible/deploy.yml new file mode 100755 index 0000000..c7b5734 --- /dev/null +++ b/ansible/deploy.yml @@ -0,0 +1,84 @@ +--- +- name: Deploy gomoku project + hosts: all + become: yes + vars: + app_user: 'gomoku' + app_home: '/home/{{ app_user }}' + + tasks: + - name: Ensure user exists + user: + name: '{{ app_user }}' + shell: /bin/bash + create_home: yes + home: '{{ app_home }}' + + - name: Create data directory + file: + path: '{{ app_home }}/public' + state: directory + owner: '{{ app_user }}' + group: '{{ app_user }}' + + - name: Copy binary to target location + copy: + src: ../target/gomoku + dest: '{{ app_home }}/gomoku' + owner: '{{ app_user }}' + group: '{{ app_user }}' + mode: '0755' + + - name: Copy data directory + synchronize: + src: ../public/ + dest: '{{ app_home }}/public/' + recursive: yes + owner: no + group: no + archive: yes + delete: yes + + - name: Ensure Caddy directory exists + file: + path: /etc/caddy/sites + state: directory + + - name: Copy Caddy configuration + copy: + src: gomoku.caddy + dest: /etc/caddy/sites/gomoku.caddy + owner: root + group: root + mode: '0644' + register: caddy_config_copy + + - name: Restart Caddy service if config changed + systemd: + name: caddy + state: restarted + daemon_reload: yes + when: caddy_config_copy.changed + + - name: Ensure Supervisor directory exists + file: + path: /etc/supervisor/conf.d + state: directory + + - name: Copy Supervisor configuration + copy: + src: gomoku.supervisor + dest: /etc/supervisor/conf.d/gomoku.conf + owner: root + group: root + mode: '0644' + + - name: Reload Supervisor + supervisorctl: + name: gomoku + state: present + + - name: Restart the service + supervisorctl: + name: gomoku + state: restarted diff --git a/ansible/gomoku.caddy b/ansible/gomoku.caddy new file mode 100755 index 0000000..794590f --- /dev/null +++ b/ansible/gomoku.caddy @@ -0,0 +1,3 @@ +gomoku.sepiatones.xyz { + reverse_proxy localhost:3002 +} diff --git a/ansible/gomoku.supervisor b/ansible/gomoku.supervisor new file mode 100755 index 0000000..f5e5b3a --- /dev/null +++ b/ansible/gomoku.supervisor @@ -0,0 +1,10 @@ +[program:gomoku] +command=/home/gomoku/gomoku +directory=/home/gomoku +user=gomoku +autostart=true +autorestart=true +startretries=3 +stderr_logfile=/var/log/supervisor/gomoku.err.log +stdout_logfile=/var/log/supervisor/gomoku.out.log +environment=PORT=3002 diff --git a/ansible/hosts.ini b/ansible/hosts.ini new file mode 100755 index 0000000..2a3e189 --- /dev/null +++ b/ansible/hosts.ini @@ -0,0 +1,3 @@ +# It is expected that you have sepiatonesxyz defined in your .ssh/config +[production] +sepiatonesxyz ansible_host=sepiatonesxyz ansible_python_interpreter=/usr/bin/python3.11 diff --git a/justfile b/justfile old mode 100644 new mode 100755 index 601b4af..0e5ff4b --- a/justfile +++ b/justfile @@ -8,7 +8,7 @@ build: bun build --compile --minify --target bun --outfile ./target/gomoku ./src/index.ts deploy: build - rsync -avz target/gomoku sepiatonesxyz:~/gomoku + ansible-playbook -i ansible/hosts.ini ansible/deploy.yml test: bun test diff --git a/package.json b/package.json old mode 100644 new mode 100755 diff --git a/public/icons/accept.svg b/public/icons/accept.svg old mode 100644 new mode 100755 diff --git a/public/icons/decline.svg b/public/icons/decline.svg old mode 100644 new mode 100755 diff --git a/public/icons/draw.svg b/public/icons/draw.svg old mode 100644 new mode 100755 diff --git a/public/icons/heart.svg b/public/icons/heart.svg deleted file mode 100755 index 611c11e..0000000 --- a/public/icons/heart.svg +++ /dev/null @@ -1,3 +0,0 @@ - \ No newline at end of file diff --git a/public/icons/resign.svg b/public/icons/resign.svg old mode 100644 new mode 100755 diff --git a/public/icons/rotate-right.svg b/public/icons/rotate-right.svg old mode 100644 new mode 100755 diff --git a/public/icons/undo.svg b/public/icons/undo.svg old mode 100644 new mode 100755 diff --git a/public/index.html b/public/index.html old mode 100644 new mode 100755 index 0b99d68..72ad5d7 --- a/public/index.html +++ b/public/index.html @@ -47,11 +47,14 @@
- + + + + diff --git a/public/scripts/client-info.js b/public/scripts/client-info.js new file mode 100755 index 0000000..b31a0bb --- /dev/null +++ b/public/scripts/client-info.js @@ -0,0 +1,12 @@ +document.addEventListener('htmx:wsAfterMessage', function (e) { + let msg; + try { + msg = JSON.parse(e.detail.message); + } catch (_) { + return; + } + if (msg.type !== 'client-info') { + return; + } + console.log(`Message from server: ${msg.message}`); +}); diff --git a/public/scripts/copy-game-link.js b/public/scripts/copy-game-link.js old mode 100644 new mode 100755 diff --git a/public/scripts/handle-redirects.js b/public/scripts/handle-redirects.js old mode 100644 new mode 100755 diff --git a/public/scripts/make-animations.js b/public/scripts/make-animations.js new file mode 100755 index 0000000..0aea83b --- /dev/null +++ b/public/scripts/make-animations.js @@ -0,0 +1,217 @@ +// Pre-loaded SVG content cache +let blackStoneSvg = null; +let whiteStoneSvg = null; + +// Pre-load SVG content when the script loads +async function preloadStoneSvgs() { + try { + // Fetch black stone SVG + const blackResponse = await fetch('/black-stone.svg'); + if (blackResponse.ok) { + blackStoneSvg = await blackResponse.text(); + } else { + console.error('Failed to load black stone SVG:', blackResponse.status); + } + + // Fetch white stone SVG + const whiteResponse = await fetch('/white-stone.svg'); + if (whiteResponse.ok) { + whiteStoneSvg = await whiteResponse.text(); + } else { + console.error('Failed to load white stone SVG:', whiteResponse.status); + } + } catch (error) { + console.error('Error pre-loading stone SVGs:', error); + } +} + +// Pre-load the SVGs immediately +preloadStoneSvgs(); + +// Create bouncing stone animation without anime.js +function createBouncingStonesAnimation(whiteCount = 20, blackCount = 20) { + const gameBoard = document.getElementById('game-board'); + if (!gameBoard) return; + + // Clear any existing animation elements + const existingAnimations = gameBoard.querySelectorAll('.animation-stone'); + existingAnimations.forEach((stone) => stone.remove()); + + const boardRect = gameBoard.getBoundingClientRect(); + const stones = []; + + // Create stone elements with pre-loaded SVG content + function createStones() { + for (let i = 0; i < whiteCount + blackCount; i++) { + const stone = document.createElement('div'); + stone.className = 'animation-stone'; + stone.style.position = 'absolute'; + stone.style.width = '30px'; + stone.style.height = '30px'; + stone.style.pointerEvents = 'none'; + stone.style.zIndex = '1000'; + stone.style.opacity = '1'; + + // Set the pre-loaded SVG content as innerHTML + stone.innerHTML = i < whiteCount ? whiteStoneSvg : blackStoneSvg; + + // Make sure the SVG inside fills the container + const svg = stone.querySelector('svg'); + if (svg) { + svg.style.width = '100%'; + svg.style.height = '100%'; + } + + // Random starting position within the board + const startX = Math.random() * (boardRect.width - 30); + const startY = Math.random() * (boardRect.height - 30); + + stone.style.left = startX + 'px'; + stone.style.top = startY + 'px'; + + gameBoard.appendChild(stone); + stones.push({ + element: stone, + x: startX, + y: startY, + vx: (Math.random() - 0.5) * 8, // Random velocity between -4 and 4 + vy: (Math.random() - 0.5) * 8, + }); + } + } + + // Create the stones (no need to wait since we're using pre-loaded content) + createStones(); + + // Animation state + let animationId; + let startTime; + let isPlaying = false; + + // Animation function + function animateStones() { + stones.forEach((stone) => { + // Update position + stone.x += stone.vx; + stone.y += stone.vy; + + // Bounce off edges + if (stone.x <= 0 || stone.x >= boardRect.width - 30) { + stone.vx = -stone.vx; + stone.x = Math.max(0, Math.min(boardRect.width - 30, stone.x)); + } + if (stone.y <= 0 || stone.y >= boardRect.height - 30) { + stone.vy = -stone.vy; + stone.y = Math.max(0, Math.min(boardRect.height - 30, stone.y)); + } + + // Apply position + stone.element.style.left = stone.x + 'px'; + stone.element.style.top = stone.y + 'px'; + }); + } + + // Fade out function + function fadeOut() { + const animationStones = document.querySelectorAll('.animation-stone'); + const fadeStartTime = performance.now(); + const fadeDuration = 500; + + function fade(currentTime) { + const elapsed = currentTime - fadeStartTime; + const progress = Math.min(elapsed / fadeDuration, 1); + const opacity = 1 - progress; + + animationStones.forEach((stone) => { + stone.style.opacity = opacity; + }); + + if (progress < 1) { + requestAnimationFrame(fade); + } else { + // Remove stones after fade is complete + animationStones.forEach((stone) => stone.remove()); + } + } + + requestAnimationFrame(fade); + } + + // Main animation loop + function animate(currentTime) { + if (!startTime) startTime = currentTime; + const elapsed = currentTime - startTime; + + // Wait 15s to fade away + if (elapsed < 15000) { + animateStones(); + animationId = requestAnimationFrame(animate); + } else { + // Animation complete - start fade out + fadeOut(); + } + } + + // Animation control object + const animationControl = { + play: function () { + if (!isPlaying) { + isPlaying = true; + startTime = null; // Reset start time + animationId = requestAnimationFrame(animate); + } + }, + pause: function () { + if (animationId) { + cancelAnimationFrame(animationId); + isPlaying = false; + } + }, + stop: function () { + if (animationId) { + cancelAnimationFrame(animationId); + isPlaying = false; + } + // Remove all stones immediately + const animationStones = document.querySelectorAll('.animation-stone'); + animationStones.forEach((stone) => stone.remove()); + }, + }; + + return animationControl; +} + +const animations = {}; + +// We use functions so that we have time to pre-load the svgs before trying to create the animations +// which isn't actually ideal because what if the animation plays right away, but, it works! +animations['black-victory'] = () => { + return createBouncingStonesAnimation(0, 40); +}; + +animations['white-victory'] = () => { + return createBouncingStonesAnimation(40, 0); +}; + +animations['draw'] = () => { + return createBouncingStonesAnimation(20, 20); +}; + +document.addEventListener('htmx:wsAfterMessage', async function (e) { + let msg; + try { + msg = JSON.parse(e.detail.message); + } catch (_) { + return; + } + if (msg.type !== 'animation') { + return; + } + + const animationFn = animations[msg.animation]; + if (animationFn) { + animationFn().play(); + } else { + console.error('Unknown animation:', msg.animation); + } +}); diff --git a/public/scripts/make-sounds.js b/public/scripts/make-sounds.js new file mode 100755 index 0000000..31a3b83 --- /dev/null +++ b/public/scripts/make-sounds.js @@ -0,0 +1,22 @@ +const sounds = {}; +sounds['victory'] = new Audio('/sounds/victory.ogg'); +sounds['defeat'] = new Audio('/sounds/defeat.ogg'); +sounds['draw'] = new Audio('/sounds/draw.ogg'); +sounds['move'] = new Audio('/sounds/move.ogg'); + +Object.values(sounds).forEach((sound) => { + sound.volume = 0.25; +}); + +document.addEventListener('htmx:wsAfterMessage', function (e) { + let msg; + try { + msg = JSON.parse(e.detail.message); + } catch (_) { + return; + } + if (msg.type !== 'sound') { + return; + } + sounds[msg.sound].play(); +}); diff --git a/public/scripts/display-ws-connection.js b/public/scripts/make-ws-connection.js old mode 100644 new mode 100755 similarity index 76% rename from public/scripts/display-ws-connection.js rename to public/scripts/make-ws-connection.js index 803ad28..b532407 --- a/public/scripts/display-ws-connection.js +++ b/public/scripts/make-ws-connection.js @@ -8,7 +8,13 @@ const gameId = gameIdMeta.content; const playerId = playerIdMeta.content; // Dynamically construct WebSocket URL -const wsUrl = `ws://${window.location.host}/ws?gameId=${gameId}&playerId=${playerId}`; +let wsProtocol; +if (window.location.protocol === 'https:') { + wsProtocol = 'wss'; +} else { + wsProtocol = 'ws'; +} +const wsUrl = `${wsProtocol}://${window.location.host}/ws?gameId=${gameId}&playerId=${playerId}`; // Get the game container element const gameContainer = document.getElementById('ws-container'); diff --git a/public/scripts/profile-editor.js b/public/scripts/profile-editor.js old mode 100644 new mode 100755 diff --git a/public/scripts/send-ws-messages.js b/public/scripts/send-ws-messages.js old mode 100644 new mode 100755 diff --git a/public/sounds/defeat.ogg b/public/sounds/defeat.ogg new file mode 100755 index 0000000..5db6d6b Binary files /dev/null and b/public/sounds/defeat.ogg differ diff --git a/public/sounds/draw.ogg b/public/sounds/draw.ogg new file mode 100755 index 0000000..101735f Binary files /dev/null and b/public/sounds/draw.ogg differ diff --git a/public/sounds/move.ogg b/public/sounds/move.ogg new file mode 100755 index 0000000..de81b9e Binary files /dev/null and b/public/sounds/move.ogg differ diff --git a/public/sounds/victory.ogg b/public/sounds/victory.ogg new file mode 100755 index 0000000..537115f Binary files /dev/null and b/public/sounds/victory.ogg differ diff --git a/public/style.css b/public/style.css old mode 100644 new mode 100755 index 7bae680..15767fc --- a/public/style.css +++ b/public/style.css @@ -1,3 +1,5 @@ +@import url('https://fonts.googleapis.com/css2?family=Cabin:ital,wght@0,400..700;1,400..700&display=swap'); + :root { /* PRIMARY BRAND COLORS */ --color-primary: #c12675; /* Main brand color - primary buttons, links, highlights */ @@ -76,8 +78,11 @@ --color-bg-accent: #fdf2f8; /* Very subtle pink background for special sections */ } +* { + font-family: Cabin, Arial, sans-serif; +} + body { - font-family: Arial, sans-serif; display: flex; flex-direction: column; align-items: center; @@ -149,48 +154,20 @@ body { width: 24px; height: 24px; margin: auto; - overflow: hidden; } -.stone-black-heart::before, -.stone-black-heart::after, -.stone-white-heart::before, -.stone-white-heart::after { - content: ''; - position: absolute; - width: 12px; - height: 20px; - border-radius: 50% 50% 0 0; - border: 1px solid var(--color-neutral-900); - box-sizing: border-box; - transform: rotate(-45deg); - transform-origin: 0 100%; - top: 0; - left: 12px; +.stone-black-heart { + fill: var(--color-primary); + stroke: var(--color-neutral-900); } -.last-move.stone-white-heart::after, -.last-move.stone-black-heart::after, -.last-move.stone-white-heart::before, -.last-move.stone-black-heart::before { - border: 2px solid var(--color-info) !important; +.stone-white-heart { + stroke: var(--color-neutral-900); + fill: var(--color-on-primary); } -.stone-black-heart::after, -.stone-white-heart::after { - left: 0; - transform: rotate(45deg); - transform-origin: 100% 100%; -} - -.stone-black-heart::before, -.stone-black-heart::after { - background-color: var(--color-primary-light); -} - -.stone-white-heart::before, -.stone-white-heart::after { - background-color: var(--color-on-primary); +.last-move { + stroke: var(--color-info) !important; } .player-name { @@ -236,7 +213,8 @@ body { justify-content: center; align-items: center; transition: opacity 0.3s ease; - padding: 8px 15px; + padding: 15px 8px; + margin: 0px 6px; border-radius: 5px; white-space: nowrap; border: none; @@ -248,18 +226,22 @@ body { #button-box { display: flex; - flex-direction: row; - flex-wrap: nowrap; + flex-direction: column; justify-content: center; gap: 10px; margin-top: 20px; } +#button-box-buttons { + display: flex; + flex-direction: row; + flex-wrap: nowrap; +} + #resign-button { background-color: var(--color-primary); color: var(--color-on-primary); } - #resign-button:hover { background-color: var(--color-primary-light); } @@ -268,7 +250,9 @@ body { background-color: var(--color-primary); color: var(--color-on-primary); } - +#copy-link-button:hover { + background-color: var(--color-primary-light); +} #copy-link-button.copied-state { background-color: var(--color-success); } @@ -277,7 +261,6 @@ body { background-color: var(--color-info); color: var(--color-on-primary); } - #takeback-button:hover { background-color: var(--color-info-light); } @@ -286,16 +269,22 @@ body { background-color: var(--color-secondary); color: var(--color-on-secondary); } - #draw-button:hover { background-color: var(--color-secondary-light); } +#rematch-button { + background-color: var(--color-primary); + color: var(--color-on-primary); +} +#rematch-button:hover { + background-color: var(--color-primary-light); +} + .accept-button { background-color: var(--color-success); color: var(--color-on-primary); } - .accept-button:hover { background-color: var(--color-success-light); } @@ -304,7 +293,6 @@ body { background-color: var(--color-error); color: var(--color-on-primary); } - .decline-button:hover { background-color: var(--color-error-light); } diff --git a/src/.fuse_hidden0000421900000085 b/src/.fuse_hidden0000421900000085 new file mode 100755 index 0000000..787e214 --- /dev/null +++ b/src/.fuse_hidden0000421900000085 @@ -0,0 +1,114 @@ +import { Elysia, t } from 'elysia'; +import { html } from '@elysiajs/html'; +import { staticPlugin } from '@elysiajs/static'; +import { cookie } from '@elysiajs/cookie'; +import { WebSocketHandler } from './web-socket-handler'; +import { ElysiaWS } from 'elysia/dist/ws'; +import { createStoneSvg } from './view/board-renderer'; + +const wsHandler = new WebSocketHandler(); +export type WS = ElysiaWS<{ query: { playerId: string; gameId: string } }>; + +const app = new Elysia() + .use( + staticPlugin({ + assets: './public', + prefix: '/', + }), + ) + .use(cookie()) + .use(html()) + .ws('/ws', { + query: t.Object({ + gameId: t.String(), + playerId: t.String(), + }), + open(ws) { + wsHandler.handleConnection(ws); + }, + message(ws, message) { + wsHandler.handleMessage(ws, message); + }, + close(ws) { + wsHandler.handleDisconnect(ws); + }, + }) + .get('/', async ({ query, cookie, request: _request }) => { + let playerId: string; + const existingPlayerId = cookie.playerId?.value; + if (existingPlayerId) { + playerId = existingPlayerId; + console.log(`Using existing playerId from cookie: ${playerId}`); + } else { + playerId = `player-${Math.random().toString(36).substring(2, 9)}`; + cookie.playerId.set({ + value: playerId, + httpOnly: true, + path: '/', + maxAge: 30 * 24 * 60 * 60, + }); + console.log(`Generated new playerId and set cookie: ${playerId}`); + } + + let gameId = query.gameId as string | undefined; + let gameIdInitialized = false; + if (gameId) { + if (!wsHandler.hasGame(gameId)) { + wsHandler.createGame(gameId); + console.log(`Created new game with provided ID: ${gameId}`); + } + } else { + gameId = wsHandler.createGame(); + gameIdInitialized = true; + console.log(`Created new game without specific ID: ${gameId}`); + } + + if (gameIdInitialized) { + return new Response(null, { + status: 302, + headers: { Location: `/?gameId=${gameId}` }, + }); + } + + const displayName = wsHandler.getPlayerName(playerId); + + const htmlTemplate = await Bun.file('./public/index.html').text(); + let finalHtml = htmlTemplate + .replace( + '', + ``, + ) + .replace( + '', + ``, + ) + .replace( + '', + ``, + ); + + return new Response(finalHtml, { + headers: { 'Content-Type': 'text/html' }, + status: 200, + }); + }) + .get('/black-stone.svg', () => { + const stoneSvg = createStoneSvg('black', false); + return new Response(String(stoneSvg), { + headers: { 'Content-Type': 'image/svg+xml' }, + status: 200, + }); + }) + .get('/white-stone.svg', () => { + const stoneSvg = createStoneSvg('white', false); + return new Response(String(stoneSvg), { + headers: { 'Content-Type': 'image/svg+xml' }, + status: 200, + }); + }); + +const port = Number(process.env.PORT || 3000); + +app.listen(port, () => { + console.log(`🦊 Elysia is running at ${app.server?.hostname}:${port}`); +}); diff --git a/src/game-server.ts b/src/game-server.ts new file mode 100755 index 0000000..91271cf --- /dev/null +++ b/src/game-server.ts @@ -0,0 +1,140 @@ +import { WS } from '.'; +import { GomokuGame, PlayerColor } from './game/game-instance'; +import { handleDrawMessage, DrawMessage } from './messages/draw'; +import { handleMakeMove, MakeMoveMessage } from './messages/make-move'; +import { Message } from './messages/messages'; +import { handleRematchMessage, RematchMessage } from './messages/rematch'; +import { handleResignation, ResignationMessage } from './messages/resign'; +import { handleTakebackMessage, TakebackMessage } from './messages/takeback'; +import { + handleUpdateDisplayName, + UpdateDisplayNameMessage, +} from './messages/update-display-name'; +import { PlayerId, PlayerConnection } from './player-connection'; +import { broadcastBoard } from './view/board-renderer'; +import { broadcastButtons } from './view/button-renderer'; +import { broadcastTitle } from './view/title-renderer'; +import { WebSocketHandler } from './web-socket-handler'; + +export type GameId = string; + +export class GameServer { + id: GameId; + gomoku: GomokuGame; + connections: Map; + blackPlayerId?: PlayerId; + whitePlayerId?: PlayerId; + takebackRequesterId: PlayerId | null = null; + drawRequesterId: PlayerId | null = null; + rematchRequesterId: PlayerId | null = null; + + constructor( + id: GameId, + public webSocketHandler: WebSocketHandler, + ) { + this.id = id; + this.gomoku = new GomokuGame(); + this.connections = new Map(); + } + + public handleConnection(ws: WS) { + const { playerId } = ws.data.query; + if (this.connections.has(playerId)) { + const existingConn = this.connections.get(playerId)!; + existingConn.ws = ws; // Update with new WebSocket + console.log( + `Updated connection for player ${playerId} in game ${this.id}, replacing old WS.`, + ); + } else { + const playerName = this.webSocketHandler.getPlayerName(playerId); // Retrieve name or use ID + const conn = new PlayerConnection(playerId, playerName, ws); + this.connections.set(playerId, conn); + console.log( + `Created new connection with player ${conn.id} in game ${this.id}`, + ); + + if (!this.blackPlayerId) { + this.blackPlayerId = conn.id; + } else if (!this.whitePlayerId) { + this.whitePlayerId = conn.id; + } + } + if (this.whitePlayerId && this.blackPlayerId) { + this.gomoku.status = 'playing'; + } + + broadcastBoard(this); + broadcastButtons(this); + broadcastTitle(this); + } + + public handleDisconnect(ws: WS) { + const { playerId } = ws.data.query; + this.connections.delete(playerId); + broadcastTitle(this); + } + + public handleMessage(ws: WS, message: Message): void { + const conn = this.connections.get(ws.data.query.playerId); + if (!conn) { + console.error( + `Failed to handle message from player ${ws.data.query.playerId}, because they are not in the game ${this.id}, which it was routed to`, + ); + return; + } + + console.log( + `Handling ${message.type} message in game ${this.id} from player ${conn.id}: ${JSON.stringify(message)}`, + ); + switch (message.type) { + case 'make_move': { + handleMakeMove(this, conn, message as MakeMoveMessage); + break; + } + case 'resign': { + handleResignation(this, conn, message as ResignationMessage); + break; + } + case 'takeback': { + handleTakebackMessage(this, conn, message as TakebackMessage); + break; + } + case 'draw': { + handleDrawMessage(this, conn, message as DrawMessage); + break; + } + case 'rematch': { + handleRematchMessage(this, conn, message as RematchMessage); + break; + } + case 'update_display_name': { + handleUpdateDisplayName( + this, + conn, + message as UpdateDisplayNameMessage, + ); + break; + } + } + } + + public getPlayerColor(conn: PlayerConnection): PlayerColor | undefined { + if (this.blackPlayerId === conn.id) { + return 'black'; + } else if (this.whitePlayerId === conn.id) { + return 'white'; + } else { + return undefined; + } + } + + public getPlayerFromColor(color: PlayerColor) { + if (color === 'white' && this.whitePlayerId) { + return this.connections.get(this.whitePlayerId); + } else if (color === 'black' && this.blackPlayerId) { + return this.connections.get(this.blackPlayerId); + } else { + return null; + } + } +} diff --git a/src/game/game-instance.test.ts b/src/game/game-instance.test.ts old mode 100644 new mode 100755 index c290b67..42933dd --- a/src/game/game-instance.test.ts +++ b/src/game/game-instance.test.ts @@ -5,134 +5,48 @@ describe('GameInstance', () => { test('should initialize with correct default state', () => { const game = new GomokuGame(); - expect(game.id).toBeDefined(); expect(game.board.length).toBe(15); expect(game.board[0].length).toBe(15); - expect(game.currentPlayer).toBeNull(); + expect(game.currentPlayerColor).toBe('black'); expect(game.status).toBe('waiting'); - expect(game.winner).toBeNull(); - expect(game.players).toEqual({}); - }); - - test('should add players correctly', () => { - const game = new GomokuGame(); - - const player1 = 'player1-uuid'; - const player2 = 'player2-uuid'; - - // First player joins as black - const joined1 = game.addPlayer(player1); - expect(joined1).toBe(true); - - // Second player joins as white - const joined2 = game.addPlayer(player2); - expect(joined2).toBe(true); - - // Game should now be in playing state - expect(game.status).toBe('playing'); - expect(game.currentPlayer).toBe('black'); - expect(game.players.black).toBe(player1); - expect(game.players.white).toBe(player2); - }); - - test('should prevent more than two players from joining', () => { - const game = new GomokuGame(); - - const player1 = 'player1-uuid'; - const player2 = 'player2-uuid'; - const player3 = 'player3-uuid'; - - game.addPlayer(player1); - game.addPlayer(player2); - - // Third player tries to join (should fail) - const joined = game.addPlayer(player3); - expect(joined).toBe(false); - - // Players object should remain unchanged, only two players should be present - expect(game.players.black).toBe(player1); - expect(game.players.white).toBe(player2); - expect(Object.values(game.players).length).toBe(2); + expect(game.winnerColor).toBeNull(); }); test('should validate moves correctly', () => { const game = new GomokuGame(); - const player1 = 'player1-uuid'; - const player2 = 'player2-uuid'; - - game.addPlayer(player1); - game.addPlayer(player2); - // Player black makes first move - const move1 = game.makeMove(player1, 7, 7); + const move1 = game.makeMove('black', 7, 7); expect(move1.success).toBe(true); // Same player tries to move again (should fail) - const move2 = game.makeMove(player1, 7, 8); + const move2 = game.makeMove('black', 7, 8); expect(move2.success).toBe(false); // White player makes a move - const move3 = game.makeMove(player2, 7, 8); + const move3 = game.makeMove('white', 7, 8); expect(move3.success).toBe(true); // Try to place on occupied cell (should fail) - const move4 = game.makeMove(player2, 7, 7); + const move4 = game.makeMove('white', 7, 7); expect(move4.success).toBe(false); // Try to place out of bounds (should fail) - const move5 = game.makeMove(player2, 15, 15); + const move5 = game.makeMove('white', 15, 15); expect(move5.success).toBe(false); }); test('should detect win conditions', () => { const game = new GomokuGame(); - const player1 = 'player1-uuid'; - const player2 = 'player2-uuid'; - - game.addPlayer(player1); - game.addPlayer(player2); - // Create a horizontal win for black for (let col = 0; col < 5; col++) { - game.makeMove(player1, 7, col); + game.makeMove('black', 7, col); // Switch to other player for next move - if (col < 4) game.makeMove(player2, 8, col); + if (col < 4) game.makeMove('white', 8, col); } - expect(game.winner).toBe('black'); - expect(game.status).toBe('finished'); - }); - - test('should detect draw condition', () => { - const game = new GomokuGame(); - - const player1 = 'player1-uuid'; - const player2 = 'player2-uuid'; - - game.addPlayer(player1); - game.addPlayer(player2); - - // Create a pattern that doesn't result in a win but fills the board - // We'll use a simple alternating pattern - for (let row = 0; row < 15; row++) { - for (let col = 0; col < 15; col++) { - const currentPlayer = game.currentPlayer!; - const playerId = game.players[currentPlayer]!; - - // Make move - const result = game.makeMove(playerId, row, col); - - // If we can't make a move, it means someone won already - if (!result.success) { - expect(game.winner).not.toBeNull(); - return; - } - } - } - - expect(game.winner).toBe('draw'); + expect(game.winnerColor).toBe('black'); expect(game.status).toBe('finished'); }); }); diff --git a/src/game/game-instance.ts b/src/game/game-instance.ts old mode 100644 new mode 100755 index 0f11b86..8a7dd41 --- a/src/game/game-instance.ts +++ b/src/game/game-instance.ts @@ -10,7 +10,6 @@ export class GomokuGame { public history: { row: number; col: number }[]; private readonly boardSize = 15; - private moveCount = 0; constructor() { this.board = Array.from({ length: this.boardSize }, () => @@ -44,7 +43,6 @@ export class GomokuGame { // Make the move this.board[row][col] = playerColor; - this.moveCount++; // If this was the first move, declare the game to have begun if (this.status === 'waiting') { @@ -63,7 +61,7 @@ export class GomokuGame { } // Check for draw condition - if (this.moveCount === this.boardSize * this.boardSize) { + if (this.history.length >= this.boardSize * this.boardSize) { this.winnerColor = 'draw'; this.status = 'finished'; this.currentPlayerColor = null; @@ -90,7 +88,6 @@ export class GomokuGame { const lastMove = this.history.pop(); if (lastMove) { this.board[lastMove.row][lastMove.col] = null; - this.moveCount--; this.currentPlayerColor = this.currentPlayerColor === 'black' ? 'white' : 'black'; if (this.status === 'finished') { diff --git a/src/index.ts b/src/index.ts old mode 100644 new mode 100755 index b3239f7..314da4e --- a/src/index.ts +++ b/src/index.ts @@ -3,9 +3,11 @@ import { html } from '@elysiajs/html'; import { staticPlugin } from '@elysiajs/static'; import { cookie } from '@elysiajs/cookie'; import { WebSocketHandler } from './web-socket-handler'; -import { GomokuGame } from './game/game-instance'; +import { ElysiaWS } from 'elysia/dist/ws'; +import { createStoneSvg } from './view/board-renderer'; const wsHandler = new WebSocketHandler(); +export type WS = ElysiaWS<{ query: { playerId: string; gameId: string } }>; const app = new Elysia() .use( @@ -49,19 +51,18 @@ const app = new Elysia() } let gameId = query.gameId as string | undefined; - let gameIdInitialized = false; if (gameId) { if (!wsHandler.hasGame(gameId)) { wsHandler.createGame(gameId); console.log(`Created new game with provided ID: ${gameId}`); + } else { + console.log(`Player ${playerId} visited existing game ${gameId}`); } } else { gameId = wsHandler.createGame(); - gameIdInitialized = true; - console.log(`Created new game without specific ID: ${gameId}`); - } - - if (gameIdInitialized) { + console.log( + `Player ${playerId} came without a gameId. Redirecting them to new game ${gameId}.`, + ); return new Response(null, { status: 302, headers: { Location: `/?gameId=${gameId}` }, @@ -89,6 +90,20 @@ const app = new Elysia() headers: { 'Content-Type': 'text/html' }, status: 200, }); + }) + .get('/black-stone.svg', () => { + const stoneSvg = createStoneSvg('black', false); + return new Response(String(stoneSvg), { + headers: { 'Content-Type': 'image/svg+xml' }, + status: 200, + }); + }) + .get('/white-stone.svg', () => { + const stoneSvg = createStoneSvg('white', false); + return new Response(String(stoneSvg), { + headers: { 'Content-Type': 'image/svg+xml' }, + status: 200, + }); }); const port = Number(process.env.PORT || 3000); diff --git a/src/messages.ts b/src/messages.ts deleted file mode 100644 index 545a241..0000000 --- a/src/messages.ts +++ /dev/null @@ -1,39 +0,0 @@ -export type ActionType = 'request' | 'accept' | 'decline' | 'cancel'; - -export interface Message { - type: - | 'make_move' - | 'resign' - | 'takeback' - | 'draw' - | 'rematch' - | 'redirect_to_game' - | 'update_display_name'; -} - -export interface UpdateDisplayNameMessage extends Message { - displayName: string; -} - -export interface MakeMoveMessage extends Message { - row: number; - col: number; -} - -export interface ResignationMessage extends Message {} - -export interface TakebackMessage extends Message { - action: ActionType; -} - -export interface DrawMessage extends Message { - action: ActionType; -} - -export interface RematchMessage extends Message { - action: ActionType; -} - -export interface RedirectToGameMessage extends Message { - gameId: string; -} diff --git a/src/messages/draw.ts b/src/messages/draw.ts new file mode 100755 index 0000000..bb1e0b2 --- /dev/null +++ b/src/messages/draw.ts @@ -0,0 +1,82 @@ +import { PlayerConnection } from '../player-connection'; +import { broadcastBoard } from '../view/board-renderer'; +import { broadcastButtons } from '../view/button-renderer'; +import { broadcastSound } from '../view/sound-renderer'; +import { broadcastAnimation } from '../view/animation-renderer'; +import { broadcastTitle } from '../view/title-renderer'; +import { GameServer } from '../game-server'; +import { ActionType, Message } from './messages'; + +export interface DrawMessage extends Message { + action: ActionType; +} + +export function handleDrawMessage( + server: GameServer, + conn: PlayerConnection, + message: DrawMessage, +): void { + if (server.gomoku.status !== 'playing') { + conn.sendMessage( + 'error', + 'You can only perform this action in an active game.', + ); + return; + } + switch (message.action) { + case 'request': + handleRequestDraw(server, conn); + break; + case 'accept': + if (!server.drawRequesterId) { + conn.sendMessage('error', 'No draw has been requested.'); + return; + } + handleAcceptDraw(server); + break; + case 'decline': + if (!server.drawRequesterId) { + conn.sendMessage('error', 'No draw has been requested.'); + return; + } + handleDeclineDraw(server); + break; + case 'cancel': + if (server.drawRequesterId !== conn.id) { + conn.sendMessage('error', 'You are not the one who requested a draw.'); + return; + } + handleCancelDrawRequest(server); + break; + } +} + +function handleRequestDraw(server: GameServer, conn: PlayerConnection): void { + if (server.takebackRequesterId) { + conn.sendMessage('error', 'A takeback has already been requested.'); + return; + } + + server.drawRequesterId = conn.id; + broadcastButtons(server); +} + +function handleAcceptDraw(server: GameServer): void { + server.gomoku.declareDraw(); + server.drawRequesterId = null; + broadcastBoard(server); + broadcastButtons(server); + broadcastTitle(server); + broadcastSound(server, 'draw'); + broadcastAnimation(server, 'draw'); +} + +function handleDeclineDraw(server: GameServer): void { + server.drawRequesterId = null; + broadcastButtons(server); +} + +function handleCancelDrawRequest(server: GameServer): void { + server.drawRequesterId = null; + broadcastButtons(server); +} diff --git a/src/messages/make-move.ts b/src/messages/make-move.ts new file mode 100755 index 0000000..d1eda99 --- /dev/null +++ b/src/messages/make-move.ts @@ -0,0 +1,105 @@ +import { PlayerConnection } from '../player-connection'; +import { broadcastBoard } from '../view/board-renderer'; +import { broadcastButtons } from '../view/button-renderer'; +import { broadcastSound, broadcastSoundToPlayer } from '../view/sound-renderer'; +import { broadcastAnimation } from '../view/animation-renderer'; +import { broadcastTitle } from '../view/title-renderer'; +import { GameServer } from '../game-server'; +import { Message } from './messages'; + +export interface MakeMoveMessage extends Message { + row: number; + col: number; +} + +export function handleMakeMove( + server: GameServer, + conn: PlayerConnection, + message: MakeMoveMessage, +): void { + const { row, col } = message; + + if (server.gomoku.status !== 'playing') { + conn.sendMessage( + 'error', + 'You cannot play a move while the game is not ongoing!', + ); + return; + } + + var playerColor; + if (server.blackPlayerId === conn.id) { + playerColor = 'black'; + } else if (server.whitePlayerId == conn.id) { + playerColor = 'white'; + } else { + conn.sendMessage( + 'error', + 'You are not a player in this game, you cannot make a move!', + ); + return; + } + if (server.gomoku.currentPlayerColor !== playerColor) { + conn.sendMessage('error', "It's not your turn"); + return; + } + + if (server.takebackRequesterId || server.drawRequesterId) { + server.takebackRequesterId = null; + server.drawRequesterId = null; + broadcastButtons(server); + } + + const stateBeforeMove = server.gomoku.status; + const result = server.gomoku.makeMove(playerColor, row, col); + if (result.success) { + broadcastBoard(server); + broadcastTitle(server); + // We only need to re-send buttons when the game state changes + if (stateBeforeMove != server.gomoku.status) { + broadcastButtons(server); + } + + // Broadcast sounds + if (server.gomoku.status === 'playing') { + broadcastSound(server, 'move'); + } else { + const whiteConn = server.whitePlayerId + ? server.connections.get(server.whitePlayerId) + : null; + const blackConn = server.blackPlayerId + ? server.connections.get(server.blackPlayerId) + : null; + switch (server.gomoku.winnerColor) { + case 'draw': + broadcastSound(server, 'draw'); + broadcastAnimation(server, 'draw'); + break; + case 'white': + if (whiteConn) { + broadcastSoundToPlayer(whiteConn, 'victory'); + } + if (blackConn) { + broadcastSoundToPlayer(blackConn, 'defeat'); + } + broadcastAnimation(server, 'white-victory'); + break; + case 'black': + if (whiteConn) { + broadcastSoundToPlayer(whiteConn, 'defeat'); + } + if (blackConn) { + broadcastSoundToPlayer(blackConn, 'victory'); + } + broadcastAnimation(server, 'black-victory'); + break; + } + } + + console.log( + `Move made in game ${server.id} by ${conn.id}: (${row}, ${col})`, + ); + } else { + conn.sendMessage('error', result.error!); + } +} diff --git a/src/messages/messages.ts b/src/messages/messages.ts new file mode 100755 index 0000000..16474da --- /dev/null +++ b/src/messages/messages.ts @@ -0,0 +1,4 @@ +export interface Message { + type: string; +} +export type ActionType = 'request' | 'accept' | 'decline' | 'cancel'; diff --git a/src/messages/rematch.ts b/src/messages/rematch.ts new file mode 100755 index 0000000..c2b9352 --- /dev/null +++ b/src/messages/rematch.ts @@ -0,0 +1,84 @@ +import { PlayerConnection } from '../player-connection'; +import { broadcastButtons } from '../view/button-renderer'; +import { GameServer } from '../game-server'; +import { ActionType, Message } from './messages'; + +export interface RematchMessage extends Message { + action: ActionType; +} + +export function handleRematchMessage( + server: GameServer, + conn: PlayerConnection, + message: RematchMessage, +): void { + if (server.gomoku.status !== 'finished') { + conn.sendMessage( + 'error', + 'You can only perform this action in a finished game.', + ); + return; + } + switch (message.action) { + case 'request': + handleRequestRematch(server, conn); + break; + case 'accept': + if (!server.rematchRequesterId) { + conn.sendMessage('error', 'No rematch has been requested.'); + return; + } + handleAcceptRematch(server); + break; + case 'decline': + if (!server.rematchRequesterId) { + conn.sendMessage('error', 'No rematch has been requested.'); + return; + } + handleDeclineRematch(server); + break; + case 'cancel': + if (server.rematchRequesterId !== conn.id) { + conn.sendMessage( + 'error', + 'You are not the one who requested a rematch.', + ); + return; + } + handleCancelRematchRequest(server); + break; + } +} + +function handleRequestRematch( + server: GameServer, + conn: PlayerConnection, +): void { + server.rematchRequesterId = conn.id; + broadcastButtons(server); +} + +function handleAcceptRematch(server: GameServer): void { + const newGameId = server.webSocketHandler.createGame( + undefined, + server.whitePlayerId, + server.blackPlayerId, + ); + const redirectMessage = { + type: 'redirect_to_game', + gameId: newGameId, + }; + server.connections.forEach((c) => { + c.ws.send(redirectMessage); + }); +} + +function handleDeclineRematch(server: GameServer): void { + server.rematchRequesterId = null; + broadcastButtons(server); +} + +function handleCancelRematchRequest(server: GameServer): void { + server.rematchRequesterId = null; + broadcastButtons(server); +} diff --git a/src/messages/resign.ts b/src/messages/resign.ts new file mode 100755 index 0000000..8e4da0e --- /dev/null +++ b/src/messages/resign.ts @@ -0,0 +1,55 @@ +import { PlayerConnection } from '../player-connection'; +import { broadcastBoard } from '../view/board-renderer'; +import { broadcastButtons } from '../view/button-renderer'; +import { broadcastSoundToPlayer } from '../view/sound-renderer'; +import { broadcastAnimation } from '../view/animation-renderer'; +import { broadcastTitle } from '../view/title-renderer'; +import { GameServer } from '../game-server'; +import { Message } from './messages'; + +export interface ResignationMessage extends Message {} + +export function handleResignation( + server: GameServer, + conn: PlayerConnection, + message: ResignationMessage, +): void { + console.log( + `Handling resign message in game ${server.id} from player ${conn.id}: ${{ message }}`, + ); + + if (server.gomoku.status !== 'playing') { + conn.sendMessage('error', 'You can only resign from an active game.'); + return; + } + + const resigningPlayerColor = server.getPlayerColor(conn); + if (!resigningPlayerColor) { + conn.sendMessage('error', 'You are not a player in server game.'); + return; + } + + server.gomoku.resign(resigningPlayerColor); + broadcastBoard(server); + broadcastTitle(server); + broadcastButtons(server); + broadcastSoundToPlayer(conn, 'defeat'); + const otherPlayerId = + resigningPlayerColor === 'white' + ? server.blackPlayerId + : server.whitePlayerId; + if (otherPlayerId) { + const otherPlayer = server.connections.get(otherPlayerId); + if (otherPlayer) { + broadcastSoundToPlayer(otherPlayer, 'victory'); + // Broadcast animation for the winning player's color + if (resigningPlayerColor === 'white') { + broadcastAnimation(server, 'black-victory'); + } else { + broadcastAnimation(server, 'white-victory'); + } + } + } + + console.log(`Player ${conn.id} resigned from game ${server.id}`); +} diff --git a/src/messages/takeback.ts b/src/messages/takeback.ts new file mode 100755 index 0000000..06beb8d --- /dev/null +++ b/src/messages/takeback.ts @@ -0,0 +1,88 @@ +import { PlayerConnection } from '../player-connection'; +import { broadcastBoard } from '../view/board-renderer'; +import { broadcastButtons } from '../view/button-renderer'; +import { broadcastTitle } from '../view/title-renderer'; +import { GameServer } from '../game-server'; +import { ActionType, Message } from './messages'; + +export interface TakebackMessage extends Message { + action: ActionType; +} + +export function handleTakebackMessage( + server: GameServer, + conn: PlayerConnection, + message: TakebackMessage, +): void { + if (server.gomoku.status !== 'playing') { + conn.sendMessage( + 'error', + 'You can only perform this action in an active game.', + ); + return; + } + switch (message.action) { + case 'request': + handleRequestTakeback(server, conn); + break; + case 'accept': + if (!server.takebackRequesterId) { + conn.sendMessage('error', 'No takeback has been requested.'); + return; + } + handleAcceptTakeback(server); + break; + case 'decline': + if (!server.takebackRequesterId) { + conn.sendMessage('error', 'No takeback has been requested.'); + return; + } + handleDeclineTakeback(server); + break; + case 'cancel': + if (server.takebackRequesterId !== conn.id) { + conn.sendMessage( + 'error', + 'You are not the one who requested a takeback.', + ); + return; + } + handleCancelTakebackRequest(server); + break; + } +} + +function handleRequestTakeback( + server: GameServer, + conn: PlayerConnection, +): void { + if (server.gomoku.history.length === 0) { + conn.sendMessage('error', 'There are no moves to take back.'); + return; + } + + if (server.drawRequesterId) { + conn.sendMessage('error', 'A draw has already been requested.'); + return; + } + server.takebackRequesterId = conn.id; + broadcastButtons(server); +} + +function handleAcceptTakeback(server: GameServer): void { + server.gomoku.undoMove(); + server.takebackRequesterId = null; + broadcastBoard(server); + broadcastButtons(server); + broadcastTitle(server); +} + +function handleDeclineTakeback(server: GameServer): void { + server.takebackRequesterId = null; + broadcastButtons(server); +} + +function handleCancelTakebackRequest(server: GameServer): void { + server.takebackRequesterId = null; + broadcastButtons(server); +} diff --git a/src/messages/update-display-name.ts b/src/messages/update-display-name.ts new file mode 100755 index 0000000..005a557 --- /dev/null +++ b/src/messages/update-display-name.ts @@ -0,0 +1,37 @@ +import { PlayerConnection } from '../player-connection'; +import { broadcastTitle } from '../view/title-renderer'; +import { GameServer } from '../game-server'; +import { Message } from './messages'; + +export interface UpdateDisplayNameMessage extends Message { + displayName: string; +} + +export function handleUpdateDisplayName( + server: GameServer, + conn: PlayerConnection, + message: UpdateDisplayNameMessage, +): void { + const newDisplayName = message.displayName.trim(); + + if (!newDisplayName) { + conn.sendMessage('error', 'Display name cannot be empty.'); + return; + } + + if (newDisplayName.length > 20) { + conn.sendMessage( + 'error', + 'Display name cannot be longer than 20 characters.', + ); + return; + } + + if (newDisplayName === conn.name) { + return; // No change, do nothing + } + + conn.name = newDisplayName; + server.webSocketHandler.setPlayerName(conn.id, newDisplayName); + broadcastTitle(server); +} diff --git a/src/player-connection.ts b/src/player-connection.ts new file mode 100755 index 0000000..d95a512 --- /dev/null +++ b/src/player-connection.ts @@ -0,0 +1,25 @@ +import { WS } from '.'; + +export type PlayerId = string; + +export class PlayerConnection { + id: PlayerId; + name: string; + ws: WS; + + constructor(id: string, name: string, ws: WS) { + this.id = id; + this.name = name; + this.ws = ws; + } + + public sendMessage(severity: 'info' | 'error', message: string) { + console.log(`Sending message ${message} to player ${this.id}`); + this.ws.send( + JSON.stringify({ + type: 'client-info', + message: message, + }), + ); + } +} diff --git a/src/view/animation-renderer.tsx b/src/view/animation-renderer.tsx new file mode 100755 index 0000000..5a8387f --- /dev/null +++ b/src/view/animation-renderer.tsx @@ -0,0 +1,18 @@ +import { PlayerConnection } from '../player-connection'; +import { GameServer } from '../game-server'; + +export function broadcastAnimation(server: GameServer, animation: string) { + server.connections.forEach((conn: PlayerConnection) => + broadcastAnimationToPlayer(conn, animation), + ); +} + +export function broadcastAnimationToPlayer( + conn: PlayerConnection, + animation: string, +) { + conn.ws.send({ + type: 'animation', + animation: animation, + }); +} diff --git a/src/view/board-renderer.ts b/src/view/board-renderer.ts deleted file mode 100644 index 44d3cf2..0000000 --- a/src/view/board-renderer.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { GomokuGame } from '../game/game-instance'; - -export function renderGameBoardHtml( - game: GomokuGame, - isPlayerToPlay: boolean, -): string { - // Check the last move, so that the stone can be highlighted - const lastMove = game.history[game.history.length - 1]; - - let boardHtml = '
'; - for (let row = 0; row < game.board.length; row++) { - for (let col = 0; col < game.board[row].length; col++) { - const stone = game.board[row][col]; - const intersectionId = `intersection-${row}-${col}`; - let stoneHtml = ''; - if (stone) { - const colorClass = - stone === 'black' ? 'stone-black-heart' : 'stone-white-heart'; - const lastMoveClass = - col == lastMove.col && row == lastMove.row ? 'last-move' : ''; - stoneHtml = `
`; - } - - // Calculate top and left for absolute positioning, offset by half the intersection div size - const top = row * 7.142857142857142; - const left = col * 7.142857142857142; - - // HTMX attributes for making a move - const wsAttrs = isPlayerToPlay && !stone ? `ws-send="click"` : ''; - - boardHtml += ` -
- ${stoneHtml} -
`; - } - } - boardHtml += `
`; - return boardHtml; -} diff --git a/src/view/board-renderer.tsx b/src/view/board-renderer.tsx new file mode 100755 index 0000000..3c2d2b2 --- /dev/null +++ b/src/view/board-renderer.tsx @@ -0,0 +1,93 @@ +import { Html } from '@elysiajs/html'; +import { GomokuGame } from '../game/game-instance'; +import { PlayerConnection } from '../player-connection'; +import { GameServer } from '../game-server'; + +export function createStoneSvg( + color: 'black' | 'white', + isHighlighted: boolean = false, +): JSX.Element { + const colorClass = + color === 'black' ? 'stone-black-heart' : 'stone-white-heart'; + const lastMoveClass = isHighlighted ? 'last-move' : ''; + + return ( + + + + + ); +} + +export function broadcastBoard(server: GameServer) { + server.connections.forEach((conn: PlayerConnection) => + broadcastBoardToPlayer(server, conn), + ); +} + +export function broadcastBoardToPlayer( + server: GameServer, + conn: PlayerConnection, +) { + const isToPlay = + server.gomoku.currentPlayerColor == server.getPlayerColor(conn); + const updatedBoardHtml = renderGameBoardHtml(server.gomoku, isToPlay); + conn.ws.send(updatedBoardHtml); + console.log(`Sent board for game ${server.id} to player ${conn.id}`); +} + +function renderGameBoardHtml( + game: GomokuGame, + isPlayerToPlay: boolean, +): string { + // Check the last move, so that the stone can be highlighted + const lastMove = game.history[game.history.length - 1]; + + let boardHtml = '
'; + for (let row = 0; row < game.board.length; row++) { + for (let col = 0; col < game.board[row].length; col++) { + const stone = game.board[row][col]; + const intersectionId = `intersection-${row}-${col}`; + let stoneHtml: JSX.Element | null; + if (stone) { + const isLastMove = col == lastMove.col && row == lastMove.row; + stoneHtml = createStoneSvg(stone, isLastMove); + } else { + stoneHtml = null; + } + + // Calculate top and left for absolute positioning, offset by half the intersection div size + const top = row * 7.142857142857142; + const left = col * 7.142857142857142; + + // HTMX attributes for making a move + const wsAttrs = isPlayerToPlay && !stone ? `ws-send="click"` : ''; + + boardHtml += ` +
+ ${stoneHtml ? stoneHtml : ''} +
`; + } + } + boardHtml += `
`; + return boardHtml; +} diff --git a/src/view/button-renderer.tsx b/src/view/button-renderer.tsx new file mode 100755 index 0000000..1199ea5 --- /dev/null +++ b/src/view/button-renderer.tsx @@ -0,0 +1,183 @@ +import { Html } from '@elysiajs/html'; +import { PlayerConnection } from '../player-connection'; +import { GameServer } from '../game-server'; + +export function broadcastButtons(server: GameServer) { + server.connections.forEach((conn: PlayerConnection) => + broadcastButtonsToPlayer(server, conn), + ); +} + +export function broadcastButtonsToPlayer( + server: GameServer, + conn: PlayerConnection, +) { + const buttons: JSX.Element[] = []; + let title: string | undefined; + + if (server.gomoku.status == 'playing' && server.getPlayerColor(conn)) { + if (server.takebackRequesterId) { + if (server.takebackRequesterId === conn.id) { + title = 'You requested a takeback'; + buttons.push( + , + ); + } else { + title = 'Your opponent requests a takeback'; + buttons.push( + , + ); + buttons.push( + , + ); + } + } else if (server.drawRequesterId) { + if (server.drawRequesterId === conn.id) { + title = 'You requested a draw'; + buttons.push( + , + ); + } else { + title = 'Your opponent offers a draw'; + buttons.push( + , + ); + buttons.push( + , + ); + } + } else { + buttons.push( + , + ); + buttons.push( + , + ); + buttons.push( + , + ); + } + } else if (server.gomoku.status === 'finished') { + if (server.rematchRequesterId) { + if (server.rematchRequesterId === conn.id) { + title = 'You requested a rematch'; + buttons.push( + , + ); + } else { + title = 'Your opponent requests a rematch'; + buttons.push( + , + ); + buttons.push( + , + ); + } + } else { + buttons.push( + , + ); + } + } else if (server.gomoku.status === 'waiting') { + buttons.push( + , + ); + } + + conn.ws.send( +
+
{title}
+
{buttons}
+
, + ); + console.log(`Sent buttons for game ${server.id} to player ${conn.id}`); +} diff --git a/src/view/sound-renderer.tsx b/src/view/sound-renderer.tsx new file mode 100755 index 0000000..b52506d --- /dev/null +++ b/src/view/sound-renderer.tsx @@ -0,0 +1,15 @@ +import { PlayerConnection } from '../player-connection'; +import { GameServer } from '../game-server'; + +export function broadcastSound(server: GameServer, sound: string) { + server.connections.forEach((conn: PlayerConnection) => + broadcastSoundToPlayer(conn, sound), + ); +} + +export function broadcastSoundToPlayer(conn: PlayerConnection, sound: string) { + conn.ws.send({ + type: 'sound', + sound: sound, + }); +} diff --git a/src/view/title-renderer.tsx b/src/view/title-renderer.tsx new file mode 100755 index 0000000..1b4a5a4 --- /dev/null +++ b/src/view/title-renderer.tsx @@ -0,0 +1,65 @@ +import { Html } from '@elysiajs/html'; +import { PlayerConnection } from '../player-connection'; +import { GameServer } from '../game-server'; +import { PlayerColor } from '../game/game-instance'; + +export function broadcastTitle(server: GameServer) { + server.connections.forEach((conn: PlayerConnection) => + broadcastTitleToPlayer(server, conn), + ); +} + +export function broadcastTitleToPlayer( + server: GameServer, + conn: PlayerConnection, +) { + let message = ''; + switch (server.gomoku.status) { + case 'waiting': { + message = 'Waiting for players...'; + break; + } + case 'playing': { + const blackTag = playerTag(server, server.blackPlayerId!, 'black'); + const whiteTag = playerTag(server, server.whitePlayerId!, 'white'); + message = `${blackTag} vs ${whiteTag}`; + break; + } + case 'finished': { + switch (server.gomoku.winnerColor) { + case 'draw': { + message = 'Game ended in draw.'; + break; + } + case 'black': { + const name = server.connections.get(server.blackPlayerId!)?.name; + message = `${name} wins!`; + break; + } + case 'white': { + const name = server.connections.get(server.whitePlayerId!)?.name; + message = `${name} wins!`; + break; + } + } + break; + } + } + conn.ws.send(
{message}
); + console.log(`Sent title for game ${server.id} to player ${conn.id}`); +} + +function playerTag(server: GameServer, playerId: string, color: PlayerColor) { + const connectionIcon = server.connections.has(playerId) + ? '' + : `Disconnected`; + var turnClass = + server.gomoku.currentPlayerColor === color ? 'player-to-play' : ''; + const classes = `player-name ${'player-' + color} ${turnClass}`.trim(); + return ( + + {server.connections.get(playerId)?.name} + {connectionIcon} + + ); +} diff --git a/src/web-socket-handler.tsx b/src/web-socket-handler.tsx old mode 100644 new mode 100755 index cf8e705..72826ea --- a/src/web-socket-handler.tsx +++ b/src/web-socket-handler.tsx @@ -1,719 +1,11 @@ -import { ElysiaWS } from 'elysia/dist/ws'; -import { Html } from '@elysiajs/html'; -import { GomokuGame as GomokuGame, PlayerColor } from './game/game-instance'; -import { renderGameBoardHtml } from './view/board-renderer'; -import { - Message, - MakeMoveMessage, - ResignationMessage, - TakebackMessage, - DrawMessage, - RematchMessage, - RedirectToGameMessage, - UpdateDisplayNameMessage, -} from './messages'; import { v4 as uuidv4 } from 'uuid'; - -type WS = ElysiaWS<{ query: { playerId: string; gameId: string } }>; - -class PlayerConnection { - id: string; - name: string; - ws: WS; - - constructor(id: string, name: string, ws: WS) { - this.id = id; - this.name = name; - this.ws = ws; - } - - public sendMessage(severity: 'info' | 'error', message: string) { - console.log(`Sending message ${message} to player ${this.id}`); - // TODO - } -} - -class GameServer { - id: string; - gomoku: GomokuGame; - connections: Map; - blackPlayerId?: string; - whitePlayerId?: string; - takebackRequesterId: string | null = null; - drawRequesterId: string | null = null; - rematchRequesterId: string | null = null; - - constructor( - id: string, - private webSocketHandler: WebSocketHandler, - ) { - this.id = id; - this.gomoku = new GomokuGame(); - this.connections = new Map(); - } - - public handleConnection(ws: WS) { - const { playerId } = ws.data.query; - if (this.connections.has(playerId)) { - const existingConn = this.connections.get(playerId)!; - existingConn.ws = ws; // Update with new WebSocket - console.log( - `Updated connection for player ${playerId} in game ${this.id}, replacing old WS.`, - ); - } else { - const playerName = this.webSocketHandler.getPlayerName(playerId); // Retrieve name or use ID - const conn = new PlayerConnection(playerId, playerName, ws); - this.connections.set(playerId, conn); - console.log( - `Created new connection with player ${conn.id} in game ${this.id}`, - ); - - if (!this.blackPlayerId) { - this.blackPlayerId = conn.id; - } else if (!this.whitePlayerId) { - this.whitePlayerId = conn.id; - } - } - if (this.whitePlayerId && this.blackPlayerId) { - this.gomoku.status = 'playing'; - } - - this.broadcastBoard(); - this.broadcastButtons(); - this.broadcastTitle(); - } - - public handleDisconnect(ws: WS) { - const { playerId } = ws.data.query; - this.connections.delete(playerId); - this.broadcastTitle(); - } - - public broadcastBoard() { - this.connections.forEach((conn: PlayerConnection) => - this.broadcastBoardToPlayer(conn), - ); - } - - public broadcastTitle() { - this.connections.forEach((conn: PlayerConnection) => - this.broadcastTitleToPlayer(conn), - ); - } - - public broadcastButtons() { - this.connections.forEach((conn: PlayerConnection) => - this.broadcastButtonsToPlayer(conn), - ); - } - - public broadcastBoardToPlayer(conn: PlayerConnection) { - const isToPlay = - this.gomoku.currentPlayerColor == this.getPlayerColor(conn); - const updatedBoardHtml = renderGameBoardHtml(this.gomoku, isToPlay); - conn.ws.send(updatedBoardHtml); - console.log(`Sent board for game ${this.id} to player ${conn.id}`); - } - - public broadcastTitleToPlayer(conn: PlayerConnection) { - let message = ''; - switch (this.gomoku.status) { - case 'waiting': { - message = 'Waiting for players...'; - break; - } - case 'playing': { - const blackTag = this.playerTag(this.blackPlayerId!, 'black'); - const whiteTag = this.playerTag(this.whitePlayerId!, 'white'); - message = `${blackTag} vs ${whiteTag}`; - break; - } - case 'finished': { - switch (this.gomoku.winnerColor) { - case 'draw': { - message = 'Game ended in draw.'; - break; - } - case 'black': { - const name = this.connections.get(this.blackPlayerId!)?.name; - message = `${name} wins!`; - break; - } - case 'white': { - const name = this.connections.get(this.whitePlayerId!)?.name; - message = `${name} wins!`; - break; - } - } - break; - } - } - conn.ws.send(
{message}
); - console.log(`Sent title for game ${this.id} to player ${conn.id}`); - } - - public broadcastButtonsToPlayer(conn: PlayerConnection) { - const buttons: JSX.Element[] = []; - - if (this.gomoku.status == 'playing' && this.getPlayerColor(conn)) { - if (this.takebackRequesterId) { - if (this.takebackRequesterId === conn.id) { - buttons.push( - , - ); - } else { - buttons.push( - , - ); - buttons.push( - , - ); - } - } else if (this.drawRequesterId) { - if (this.drawRequesterId === conn.id) { - buttons.push( - , - ); - } else { - buttons.push( - , - ); - buttons.push( - , - ); - } - } else { - buttons.push( - , - ); - buttons.push( - , - ); - buttons.push( - , - ); - } - } else if (this.gomoku.status === 'finished') { - if (this.rematchRequesterId) { - if (this.rematchRequesterId === conn.id) { - buttons.push( - , - ); - } else { - buttons.push( - , - ); - buttons.push( - , - ); - } - } else { - buttons.push( - , - ); - } - } else if (this.gomoku.status === 'waiting') { - buttons.push( - , - ); - } - - conn.ws.send(
{buttons}
); - console.log(`Sent buttons for game ${this.id} to player ${conn.id}`); - } - - private playerTag(playerId: string, color: PlayerColor) { - const connectionIcon = this.connections.has(playerId) - ? '' - : `Disconnected`; - var turnClass = - this.gomoku.currentPlayerColor === color ? 'player-to-play' : ''; - const classes = `player-name ${'player-' + color} ${turnClass}`.trim(); - return ( - - {this.connections.get(playerId)?.name} - {connectionIcon} - - ); - } - - public handleMessage(ws: WS, message: Message): void { - const conn = this.connections.get(ws.data.query.playerId); - if (!conn) { - console.error( - `Failed to handle message from player ${ws.data.query.playerId}, because they are not in the game ${this.id}, which it was routed to`, - ); - return; - } - - console.log( - `Handling ${message.type} message in game ${this.id} from player ${conn.id}: ${JSON.stringify(message)}`, - ); - switch (message.type) { - case 'make_move': { - this.handleMakeMove(conn, message as MakeMoveMessage); - break; - } - case 'resign': { - this.handleResignation(conn, message as ResignationMessage); - break; - } - case 'takeback': { - this.handleTakebackMessage(conn, message as TakebackMessage); - break; - } - case 'draw': { - this.handleDrawMessage(conn, message as DrawMessage); - break; - } - case 'rematch': { - this.handleRematchMessage(conn, message as RematchMessage); - break; - } - case 'update_display_name': { - this.handleUpdateDisplayName(conn, message as UpdateDisplayNameMessage); - break; - } - } - } - - private getPlayerColor(conn: PlayerConnection): PlayerColor | undefined { - if (this.blackPlayerId === conn.id) { - return 'black'; - } else if (this.whitePlayerId === conn.id) { - return 'white'; - } else { - return undefined; - } - } - - private handleMakeMove( - conn: PlayerConnection, - message: MakeMoveMessage, - ): void { - const { row, col } = message; - - var playerColor; - if (this.blackPlayerId === conn.id) { - playerColor = 'black'; - } else if (this.whitePlayerId == conn.id) { - playerColor = 'white'; - } else { - conn.sendMessage( - 'error', - 'You are not a player in this game, you cannot make a move!', - ); - return; - } - if (this.gomoku.currentPlayerColor !== playerColor) { - conn.sendMessage('error', "It's not your turn"); - return; - } - - if (this.takebackRequesterId || this.drawRequesterId) { - this.takebackRequesterId = null; - this.drawRequesterId = null; - this.broadcastButtons(); - } - - const stateBeforeMove = this.gomoku.status; - const result = this.gomoku.makeMove(playerColor, row, col); - if (result.success) { - this.broadcastBoard(); - this.broadcastTitle(); - // We only need to re-send buttons when the game state changes - if (stateBeforeMove != this.gomoku.status) { - this.broadcastButtons(); - } - console.log( - `Move made in game ${this.id} by ${conn.id}: (${row}, ${col})`, - ); - } else { - conn.sendMessage('error', result.error!); - } - } - - private handleResignation( - conn: PlayerConnection, - message: ResignationMessage, - ): void { - console.log( - `Handling resign message in game ${this.id} from player ${conn.id}: ${{ message }}`, - ); - - if (this.gomoku.status !== 'playing') { - conn.sendMessage('error', 'You can only resign from an active game.'); - return; - } - - const resigningPlayerColor = this.getPlayerColor(conn); - if (!resigningPlayerColor) { - conn.sendMessage('error', 'You are not a player in this game.'); - return; - } - - this.gomoku.resign(resigningPlayerColor); - this.broadcastBoard(); - this.broadcastTitle(); - this.broadcastButtons(); - - console.log(`Player ${conn.id} resigned from game ${this.id}`); - } - - private handleTakebackMessage( - conn: PlayerConnection, - message: TakebackMessage, - ): void { - if (this.gomoku.status !== 'playing') { - conn.sendMessage( - 'error', - 'You can only perform this action in an active game.', - ); - return; - } - switch (message.action) { - case 'request': - this.handleRequestTakeback(conn); - break; - case 'accept': - if (!this.takebackRequesterId) { - conn.sendMessage('error', 'No takeback has been requested.'); - return; - } - this.handleAcceptTakeback(); - break; - case 'decline': - if (!this.takebackRequesterId) { - conn.sendMessage('error', 'No takeback has been requested.'); - return; - } - this.handleDeclineTakeback(); - break; - case 'cancel': - if (this.takebackRequesterId !== conn.id) { - conn.sendMessage( - 'error', - 'You are not the one who requested a takeback.', - ); - return; - } - this.handleCancelTakebackRequest(); - break; - } - } - - private handleDrawMessage( - conn: PlayerConnection, - message: DrawMessage, - ): void { - if (this.gomoku.status !== 'playing') { - conn.sendMessage( - 'error', - 'You can only perform this action in an active game.', - ); - return; - } - switch (message.action) { - case 'request': - this.handleRequestDraw(conn); - break; - case 'accept': - if (!this.drawRequesterId) { - conn.sendMessage('error', 'No draw has been requested.'); - return; - } - this.handleAcceptDraw(); - break; - case 'decline': - if (!this.drawRequesterId) { - conn.sendMessage('error', 'No draw has been requested.'); - return; - } - this.handleDeclineDraw(); - break; - case 'cancel': - if (this.drawRequesterId !== conn.id) { - conn.sendMessage( - 'error', - 'You are not the one who requested a draw.', - ); - return; - } - this.handleCancelDrawRequest(); - break; - } - } - - private handleRematchMessage( - conn: PlayerConnection, - message: RematchMessage, - ): void { - if (this.gomoku.status !== 'finished') { - conn.sendMessage( - 'error', - 'You can only perform this action in a finished game.', - ); - return; - } - switch (message.action) { - case 'request': - this.handleRequestRematch(conn); - break; - case 'accept': - if (!this.rematchRequesterId) { - conn.sendMessage('error', 'No rematch has been requested.'); - return; - } - this.handleAcceptRematch(); - break; - case 'decline': - if (!this.rematchRequesterId) { - conn.sendMessage('error', 'No rematch has been requested.'); - return; - } - this.handleDeclineRematch(); - break; - case 'cancel': - if (this.rematchRequesterId !== conn.id) { - conn.sendMessage( - 'error', - 'You are not the one who requested a rematch.', - ); - return; - } - this.handleCancelRematchRequest(); - break; - } - } - - private handleRequestTakeback(conn: PlayerConnection): void { - if (this.gomoku.history.length === 0) { - conn.sendMessage('error', 'There are no moves to take back.'); - return; - } - - if (this.drawRequesterId) { - conn.sendMessage('error', 'A draw has already been requested.'); - return; - } - this.takebackRequesterId = conn.id; - this.broadcastButtons(); - } - - private handleAcceptTakeback(): void { - this.gomoku.undoMove(); - this.takebackRequesterId = null; - this.broadcastBoard(); - this.broadcastButtons(); - this.broadcastTitle(); - } - - private handleDeclineTakeback(): void { - this.takebackRequesterId = null; - this.broadcastButtons(); - } - - private handleRequestDraw(conn: PlayerConnection): void { - if (this.takebackRequesterId) { - conn.sendMessage('error', 'A takeback has already been requested.'); - return; - } - - this.drawRequesterId = conn.id; - this.broadcastButtons(); - } - - private handleAcceptDraw(): void { - this.gomoku.declareDraw(); - this.drawRequesterId = null; - this.broadcastBoard(); - this.broadcastButtons(); - this.broadcastTitle(); - } - - private handleDeclineDraw(): void { - this.drawRequesterId = null; - this.broadcastButtons(); - } - - private handleRequestRematch(conn: PlayerConnection): void { - this.rematchRequesterId = conn.id; - this.broadcastButtons(); - } - - private handleAcceptRematch(): void { - const newGameId = this.webSocketHandler.createGame( - undefined, - this.whitePlayerId, - this.blackPlayerId, - ); - const redirectMessage: RedirectToGameMessage = { - type: 'redirect_to_game', - gameId: newGameId, - }; - this.connections.forEach((c) => { - c.ws.send(redirectMessage); - }); - } - - private handleDeclineRematch(): void { - this.rematchRequesterId = null; - this.broadcastButtons(); - } - - private handleCancelTakebackRequest(): void { - this.takebackRequesterId = null; - this.broadcastButtons(); - } - - private handleCancelDrawRequest(): void { - this.drawRequesterId = null; - this.broadcastButtons(); - } - - private handleCancelRematchRequest(): void { - this.rematchRequesterId = null; - this.broadcastButtons(); - } - - private handleUpdateDisplayName( - conn: PlayerConnection, - message: UpdateDisplayNameMessage, - ): void { - const newDisplayName = message.displayName.trim(); - - if (!newDisplayName) { - conn.sendMessage('error', 'Display name cannot be empty.'); - return; - } - - if (newDisplayName.length > 20) { - conn.sendMessage( - 'error', - 'Display name cannot be longer than 20 characters.', - ); - return; - } - - if (newDisplayName === conn.name) { - return; // No change, do nothing - } - - conn.name = newDisplayName; - this.webSocketHandler.setPlayerName(conn.id, newDisplayName); - this.broadcastTitle(); - } -} +import { WS } from '.'; +import { GameServer } from './game-server'; +import { Message } from './messages/messages'; export class WebSocketHandler { - private games: Map; - private playerNames: Map; + public games: Map; + public playerNames: Map; constructor() { this.games = new Map(); @@ -722,12 +14,13 @@ export class WebSocketHandler { public handleConnection(ws: WS): void { const { gameId, playerId } = ws.data.query; + console.log(`Player ${playerId} is opening a connection to game ${gameId}`); if (this.games.has(gameId)) { const game = this.games.get(gameId)!; game.handleConnection(ws); } else { - ws.send('Error: game not found'); - ws.close(); + this.createGame(gameId); + this.games.get(gameId)!.handleConnection(ws); } } diff --git a/tsconfig.json b/tsconfig.json old mode 100644 new mode 100755