diff --git a/.gitignore b/.gitignore old mode 100755 new mode 100644 index 0c12830..f79d6ed --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + # dependencies /node_modules /.pnp @@ -6,6 +8,17 @@ # testing /coverage +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + # debug npm-debug.log* yarn-debug.log* @@ -17,15 +30,16 @@ yarn-error.log* .env.test.local .env.production.local -# build outputs -package-lock.json -dist/ -target/ +# vercel +.vercel -# common artifact extensions **/*.trace **/*.zip **/*.tar.gz **/*.tgz **/*.log +package-lock.json **/*.bun + +dist/ +target/ diff --git a/.goosehints b/.goosehints old mode 100755 new mode 100644 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml old mode 100755 new mode 100644 diff --git a/.prettierrc.json b/.prettierrc.json old mode 100755 new mode 100644 diff --git a/README.md b/README.md old mode 100755 new mode 100644 diff --git a/ansible/deploy.yml b/ansible/deploy.yml deleted file mode 100755 index c7b5734..0000000 --- a/ansible/deploy.yml +++ /dev/null @@ -1,84 +0,0 @@ ---- -- 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 deleted file mode 100755 index 794590f..0000000 --- a/ansible/gomoku.caddy +++ /dev/null @@ -1,3 +0,0 @@ -gomoku.sepiatones.xyz { - reverse_proxy localhost:3002 -} diff --git a/ansible/gomoku.supervisor b/ansible/gomoku.supervisor deleted file mode 100755 index f5e5b3a..0000000 --- a/ansible/gomoku.supervisor +++ /dev/null @@ -1,10 +0,0 @@ -[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 deleted file mode 100755 index 2a3e189..0000000 --- a/ansible/hosts.ini +++ /dev/null @@ -1,3 +0,0 @@ -# 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 100755 new mode 100644 index 0e5ff4b..601b4af --- a/justfile +++ b/justfile @@ -8,7 +8,7 @@ build: bun build --compile --minify --target bun --outfile ./target/gomoku ./src/index.ts deploy: build - ansible-playbook -i ansible/hosts.ini ansible/deploy.yml + rsync -avz target/gomoku sepiatonesxyz:~/gomoku test: bun test diff --git a/package.json b/package.json old mode 100755 new mode 100644 diff --git a/public/icons/accept.svg b/public/icons/accept.svg old mode 100755 new mode 100644 diff --git a/public/icons/decline.svg b/public/icons/decline.svg old mode 100755 new mode 100644 diff --git a/public/icons/draw.svg b/public/icons/draw.svg old mode 100755 new mode 100644 diff --git a/public/icons/heart.svg b/public/icons/heart.svg new file mode 100755 index 0000000..611c11e --- /dev/null +++ b/public/icons/heart.svg @@ -0,0 +1,3 @@ + \ No newline at end of file diff --git a/public/icons/resign.svg b/public/icons/resign.svg old mode 100755 new mode 100644 diff --git a/public/icons/rotate-right.svg b/public/icons/rotate-right.svg old mode 100755 new mode 100644 diff --git a/public/icons/undo.svg b/public/icons/undo.svg old mode 100755 new mode 100644 diff --git a/public/index.html b/public/index.html old mode 100755 new mode 100644 index 72ad5d7..0b99d68 --- a/public/index.html +++ b/public/index.html @@ -47,14 +47,11 @@
- + - - - diff --git a/public/scripts/client-info.js b/public/scripts/client-info.js deleted file mode 100755 index b31a0bb..0000000 --- a/public/scripts/client-info.js +++ /dev/null @@ -1,12 +0,0 @@ -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 100755 new mode 100644 diff --git a/public/scripts/make-ws-connection.js b/public/scripts/display-ws-connection.js old mode 100755 new mode 100644 similarity index 76% rename from public/scripts/make-ws-connection.js rename to public/scripts/display-ws-connection.js index b532407..803ad28 --- a/public/scripts/make-ws-connection.js +++ b/public/scripts/display-ws-connection.js @@ -8,13 +8,7 @@ const gameId = gameIdMeta.content; const playerId = playerIdMeta.content; // Dynamically construct WebSocket URL -let wsProtocol; -if (window.location.protocol === 'https:') { - wsProtocol = 'wss'; -} else { - wsProtocol = 'ws'; -} -const wsUrl = `${wsProtocol}://${window.location.host}/ws?gameId=${gameId}&playerId=${playerId}`; +const wsUrl = `ws://${window.location.host}/ws?gameId=${gameId}&playerId=${playerId}`; // Get the game container element const gameContainer = document.getElementById('ws-container'); diff --git a/public/scripts/handle-redirects.js b/public/scripts/handle-redirects.js old mode 100755 new mode 100644 diff --git a/public/scripts/make-animations.js b/public/scripts/make-animations.js deleted file mode 100755 index 0aea83b..0000000 --- a/public/scripts/make-animations.js +++ /dev/null @@ -1,217 +0,0 @@ -// 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 deleted file mode 100755 index 31a3b83..0000000 --- a/public/scripts/make-sounds.js +++ /dev/null @@ -1,22 +0,0 @@ -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/profile-editor.js b/public/scripts/profile-editor.js old mode 100755 new mode 100644 diff --git a/public/scripts/send-ws-messages.js b/public/scripts/send-ws-messages.js old mode 100755 new mode 100644 diff --git a/public/sounds/defeat.ogg b/public/sounds/defeat.ogg deleted file mode 100755 index 5db6d6b..0000000 Binary files a/public/sounds/defeat.ogg and /dev/null differ diff --git a/public/sounds/draw.ogg b/public/sounds/draw.ogg deleted file mode 100755 index 101735f..0000000 Binary files a/public/sounds/draw.ogg and /dev/null differ diff --git a/public/sounds/move.ogg b/public/sounds/move.ogg deleted file mode 100755 index de81b9e..0000000 Binary files a/public/sounds/move.ogg and /dev/null differ diff --git a/public/sounds/victory.ogg b/public/sounds/victory.ogg deleted file mode 100755 index 537115f..0000000 Binary files a/public/sounds/victory.ogg and /dev/null differ diff --git a/public/style.css b/public/style.css old mode 100755 new mode 100644 index 15767fc..7bae680 --- a/public/style.css +++ b/public/style.css @@ -1,5 +1,3 @@ -@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 */ @@ -78,11 +76,8 @@ --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; @@ -154,20 +149,48 @@ body { width: 24px; height: 24px; margin: auto; + overflow: hidden; } -.stone-black-heart { - fill: var(--color-primary); - stroke: var(--color-neutral-900); +.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-white-heart { - stroke: var(--color-neutral-900); - fill: var(--color-on-primary); +.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; } -.last-move { - stroke: var(--color-info) !important; +.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); } .player-name { @@ -213,8 +236,7 @@ body { justify-content: center; align-items: center; transition: opacity 0.3s ease; - padding: 15px 8px; - margin: 0px 6px; + padding: 8px 15px; border-radius: 5px; white-space: nowrap; border: none; @@ -226,22 +248,18 @@ body { #button-box { display: flex; - flex-direction: column; + flex-direction: row; + flex-wrap: nowrap; 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); } @@ -250,9 +268,7 @@ 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); } @@ -261,6 +277,7 @@ body { background-color: var(--color-info); color: var(--color-on-primary); } + #takeback-button:hover { background-color: var(--color-info-light); } @@ -269,22 +286,16 @@ 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); } @@ -293,6 +304,7 @@ 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 deleted file mode 100755 index 787e214..0000000 --- a/src/.fuse_hidden0000421900000085 +++ /dev/null @@ -1,114 +0,0 @@ -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 deleted file mode 100755 index 91271cf..0000000 --- a/src/game-server.ts +++ /dev/null @@ -1,140 +0,0 @@ -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 100755 new mode 100644 index 42933dd..c290b67 --- a/src/game/game-instance.test.ts +++ b/src/game/game-instance.test.ts @@ -5,48 +5,134 @@ 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.currentPlayerColor).toBe('black'); + expect(game.currentPlayer).toBeNull(); expect(game.status).toBe('waiting'); - expect(game.winnerColor).toBeNull(); + 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); }); 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('black', 7, 7); + const move1 = game.makeMove(player1, 7, 7); expect(move1.success).toBe(true); // Same player tries to move again (should fail) - const move2 = game.makeMove('black', 7, 8); + const move2 = game.makeMove(player1, 7, 8); expect(move2.success).toBe(false); // White player makes a move - const move3 = game.makeMove('white', 7, 8); + const move3 = game.makeMove(player2, 7, 8); expect(move3.success).toBe(true); // Try to place on occupied cell (should fail) - const move4 = game.makeMove('white', 7, 7); + const move4 = game.makeMove(player2, 7, 7); expect(move4.success).toBe(false); // Try to place out of bounds (should fail) - const move5 = game.makeMove('white', 15, 15); + const move5 = game.makeMove(player2, 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('black', 7, col); + game.makeMove(player1, 7, col); // Switch to other player for next move - if (col < 4) game.makeMove('white', 8, col); + if (col < 4) game.makeMove(player2, 8, col); } - expect(game.winnerColor).toBe('black'); + 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.status).toBe('finished'); }); }); diff --git a/src/game/game-instance.ts b/src/game/game-instance.ts old mode 100755 new mode 100644 index 8a7dd41..0f11b86 --- a/src/game/game-instance.ts +++ b/src/game/game-instance.ts @@ -10,6 +10,7 @@ export class GomokuGame { public history: { row: number; col: number }[]; private readonly boardSize = 15; + private moveCount = 0; constructor() { this.board = Array.from({ length: this.boardSize }, () => @@ -43,6 +44,7 @@ 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') { @@ -61,7 +63,7 @@ export class GomokuGame { } // Check for draw condition - if (this.history.length >= this.boardSize * this.boardSize) { + if (this.moveCount === this.boardSize * this.boardSize) { this.winnerColor = 'draw'; this.status = 'finished'; this.currentPlayerColor = null; @@ -88,6 +90,7 @@ 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 100755 new mode 100644 index 314da4e..b3239f7 --- a/src/index.ts +++ b/src/index.ts @@ -3,11 +3,9 @@ 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'; +import { GomokuGame } from './game/game-instance'; const wsHandler = new WebSocketHandler(); -export type WS = ElysiaWS<{ query: { playerId: string; gameId: string } }>; const app = new Elysia() .use( @@ -51,18 +49,19 @@ 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(); - console.log( - `Player ${playerId} came without a gameId. Redirecting them to new game ${gameId}.`, - ); + gameIdInitialized = true; + console.log(`Created new game without specific ID: ${gameId}`); + } + + if (gameIdInitialized) { return new Response(null, { status: 302, headers: { Location: `/?gameId=${gameId}` }, @@ -90,20 +89,6 @@ 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 new file mode 100644 index 0000000..545a241 --- /dev/null +++ b/src/messages.ts @@ -0,0 +1,39 @@ +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 deleted file mode 100755 index bb1e0b2..0000000 --- a/src/messages/draw.ts +++ /dev/null @@ -1,82 +0,0 @@ -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 deleted file mode 100755 index d1eda99..0000000 --- a/src/messages/make-move.ts +++ /dev/null @@ -1,105 +0,0 @@ -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 deleted file mode 100755 index 16474da..0000000 --- a/src/messages/messages.ts +++ /dev/null @@ -1,4 +0,0 @@ -export interface Message { - type: string; -} -export type ActionType = 'request' | 'accept' | 'decline' | 'cancel'; diff --git a/src/messages/rematch.ts b/src/messages/rematch.ts deleted file mode 100755 index c2b9352..0000000 --- a/src/messages/rematch.ts +++ /dev/null @@ -1,84 +0,0 @@ -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 deleted file mode 100755 index 8e4da0e..0000000 --- a/src/messages/resign.ts +++ /dev/null @@ -1,55 +0,0 @@ -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 deleted file mode 100755 index 06beb8d..0000000 --- a/src/messages/takeback.ts +++ /dev/null @@ -1,88 +0,0 @@ -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 deleted file mode 100755 index 005a557..0000000 --- a/src/messages/update-display-name.ts +++ /dev/null @@ -1,37 +0,0 @@ -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 deleted file mode 100755 index d95a512..0000000 --- a/src/player-connection.ts +++ /dev/null @@ -1,25 +0,0 @@ -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 deleted file mode 100755 index 5a8387f..0000000 --- a/src/view/animation-renderer.tsx +++ /dev/null @@ -1,18 +0,0 @@ -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 new file mode 100644 index 0000000..44d3cf2 --- /dev/null +++ b/src/view/board-renderer.ts @@ -0,0 +1,46 @@ +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 deleted file mode 100755 index 3c2d2b2..0000000 --- a/src/view/board-renderer.tsx +++ /dev/null @@ -1,93 +0,0 @@ -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 deleted file mode 100755 index 1199ea5..0000000 --- a/src/view/button-renderer.tsx +++ /dev/null @@ -1,183 +0,0 @@ -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 deleted file mode 100755 index b52506d..0000000 --- a/src/view/sound-renderer.tsx +++ /dev/null @@ -1,15 +0,0 @@ -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 deleted file mode 100755 index 1b4a5a4..0000000 --- a/src/view/title-renderer.tsx +++ /dev/null @@ -1,65 +0,0 @@ -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 100755 new mode 100644 index 72826ea..cf8e705 --- a/src/web-socket-handler.tsx +++ b/src/web-socket-handler.tsx @@ -1,11 +1,719 @@ +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'; -import { WS } from '.'; -import { GameServer } from './game-server'; -import { Message } from './messages/messages'; + +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(); + } +} export class WebSocketHandler { - public games: Map; - public playerNames: Map; + private games: Map; + private playerNames: Map; constructor() { this.games = new Map(); @@ -14,13 +722,12 @@ 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 { - this.createGame(gameId); - this.games.get(gameId)!.handleConnection(ws); + ws.send('Error: game not found'); + ws.close(); } } diff --git a/tsconfig.json b/tsconfig.json old mode 100755 new mode 100644