From d7c9cfd9d579439ab4b9fe620098ba938aa8a6e8 Mon Sep 17 00:00:00 2001 From: pommicket Date: Fri, 30 Aug 2024 11:45:04 -0400 Subject: handle server errors, rejoining --- TODO.txt | 2 - game.html | 1 + game.js | 117 ++++++++++++++++++++++++++++++++++++++++------------- server/src/main.rs | 62 +++++++++++++++++++--------- style.css | 13 ++++++ 5 files changed, 146 insertions(+), 49 deletions(-) diff --git a/TODO.txt b/TODO.txt index 7610b29..e69de29 100644 --- a/TODO.txt +++ b/TODO.txt @@ -1,2 +0,0 @@ -- single player mode with no server -- handle server errors on the client side diff --git a/game.html b/game.html index 51a34bc..4bf8997 100644 --- a/game.html +++ b/game.html @@ -23,5 +23,6 @@ +
diff --git a/game.js b/game.js index 0fad2e5..5853e21 100644 --- a/game.js +++ b/game.js @@ -2,9 +2,8 @@ window.addEventListener('load', function () { const ACTION_MOVE = 3; const ACTION_CONNECT = 4; - const socket = new WebSocket(location.protocol === "file:" || location.hostname === "localhost" ? "ws://localhost:54472" : "wss://jigsaw.pommicket.com"); + let socket; const searchParams = new URL(location.href).searchParams; - socket.binaryType = "arraybuffer"; let puzzleSeed = Math.floor(Math.random() * 0x7fffffff); // direct URL to image file let imageUrl = searchParams.has('image') ? encodeURI(searchParams.get('image')) : undefined; @@ -19,7 +18,11 @@ window.addEventListener('load', function () { const imageLinkElement = getById('image-link'); const joinPuzzle = searchParams.get('join'); const joinLink = getById('join-link'); - function setJoinLink(puzzleID) { + const errorBox = getById('error'); + const hostMultiplayerButton = getById('host-multiplayer'); + let puzzleID = joinPuzzle ? joinPuzzle : null; + let rejoining = false; + function setJoinLink() { const url = new URL(location.href); url.hash = ''; url.search = '?' + new URLSearchParams({ @@ -37,7 +40,7 @@ window.addEventListener('load', function () { joinLink.innerText = prev; }, 3000); }); - if (joinPuzzle) setJoinLink(joinPuzzle); + if (joinPuzzle) setJoinLink(); let solved = false; const connectRadius = 10; let pieceZIndexCounter = 1; @@ -132,6 +135,18 @@ window.addEventListener('load', function () { l[j] = temp; } } + let errorTimeout; + function showError(e) { + console.log(`Error: ${e}`); + errorBox.classList.add('no-animation'); + errorBox.style.opacity = 1; + errorBox.innerText = `Error: ${e}`; + if (errorTimeout) clearTimeout(errorTimeout); + errorTimeout = setTimeout(() => { + errorBox.classList.remove('no-animation'); + errorBox.style.opacity = 0; + }, 5000); + } const TOP_IN = 0; const TOP_OUT = 1; const RIGHT_IN = 2; @@ -427,8 +442,6 @@ window.addEventListener('load', function () { if (draggingPiece) { let dx = (e.clientX - draggingPieceLastPos.x) / playArea.clientWidth; let dy = (e.clientY - draggingPieceLastPos.y) / playArea.clientHeight; - let originalDx = dx; - let originalDy = dy; for (const piece of draggingPiece.connectedComponent) { // ensure pieces don't go past left edge dx = Math.max(dx, 0.0001 - piece.x); @@ -444,12 +457,8 @@ window.addEventListener('load', function () { piece.y += dy; piece.updatePosition(); } - draggingPieceLastPos.x = e.clientX; - draggingPieceLastPos.y = e.clientY; - if (dx !== originalDx || dy !== originalDy) { - // stop dragging piece if it was dragged past edge - stopDraggingPiece(); - } + draggingPieceLastPos.x += dx * playArea.clientWidth; + draggingPieceLastPos.y += dy * playArea.clientHeight; } }); function loadImage() { @@ -516,8 +525,7 @@ window.addEventListener('load', function () { async function createPuzzle() { await loadImage(); if (isNaN(roughPieceCount) || roughPieceCount < 10 || roughPieceCount > 1000) { - // TODO : better error reporting - console.error('bad piece count'); + showError('bad piece count'); return; } let bestWidth = 1; @@ -592,6 +600,10 @@ window.addEventListener('load', function () { }, 100); } async function hostPuzzle() { + if (!socket) { + openSocket(); + await new Promise((resolve) => socket.addEventListener('open', () => resolve())); + } socket.send(`new ${puzzleWidth} ${puzzleHeight} ${imageUrl};${imageLink} ${puzzleSeed}`); multiplayer = true; } @@ -645,13 +657,26 @@ window.addEventListener('load', function () { socket.send(new Uint32Array(actions.buffer, 0, i)); } } + function rejoinPuzzle() { + if (rejoining) return; + multiplayer = false; + rejoining = true; + if (socket) { + socket.closedByClient = true; + socket.close(); + socket = null; + } + setTimeout(openSocket, 1000); + } let waitingForServerToGiveUsImageUrl = false; let puzzleCreated = null; if (!joinPuzzle && imageUrl.startsWith('http')) { puzzleCreated = createPuzzle(); } - socket.addEventListener('open', async () => { - if (joinPuzzle) { + async function onSocketOpen() { + if (rejoining) { + socket.send(`rejoin ${puzzleID}`); + } else if (joinPuzzle) { socket.send(`join ${joinPuzzle}`); } else { if (imageUrl.startsWith('http')) { @@ -664,15 +689,25 @@ window.addEventListener('load', function () { socket.send('wikimediaPotd'); waitingForServerToGiveUsImageUrl = true; } else { - // TODO : better error reporting - throw new Error("bad image URL"); + showError("bad image URL"); } } - }); - socket.addEventListener('message', async (e) => { + } + async function onSocketClose(e) { + if (e.target.closedByClient) + return; + if (!multiplayer && !waitingForServerToGiveUsImageUrl) { + socket = null; + return; + } + if (rejoining) return; + showError('Lost connection to server. Trying to reconnect…'); + rejoinPuzzle(); + } + async function onSocketMessage(e) { if (typeof e.data === 'string') { if (e.data.startsWith('id: ')) { - let puzzleID = e.data.split(' ')[1]; + puzzleID = e.data.split(' ')[1]; sendServerUpdate(); // send piece positions const connectivityUpdate = [0 /* message ID */]; for (const piece of pieces) { @@ -682,7 +717,7 @@ window.addEventListener('load', function () { } socket.send(new Uint32Array(connectivityUpdate)); history.pushState({}, null, `?join=${puzzleID}`); - setJoinLink(puzzleID); + setJoinLink(); } else if (e.data.startsWith('ack')) { const messageID = parseInt(e.data.split(' ')[1]); if (messageID === waitingForAck) { @@ -700,7 +735,11 @@ window.addEventListener('load', function () { getById('host-multiplayer').style.display = 'inline-block'; } else if (e.data.startsWith('error ')) { const error = e.data.substring('error '.length); - console.error(error); // TODO : better error handling + showError(error); + rejoinPuzzle(); + } else if (e.data === 'rejoined') { + multiplayer = true; + rejoining = false; } } else { const opcode = new Uint8Array(e.data, 0, 1)[0]; @@ -710,7 +749,18 @@ window.addEventListener('load', function () { applyUpdate(e.data); } } - }); + } + async function onSocketError() { + showError("Couldn't connect to server. You can still play single player with a custom image URL."); + multiplayer = false; + rejoining = false; + hostMultiplayerButton.style.display = imageUrl ? 'inline' : 'none'; + if (puzzleID) { + hostMultiplayerButton.innerText = '👥 Reconnect to multiplayer game'; + } + joinLink.style.display = 'none'; + socket = null; + } const prevPlayAreaSize = Object.preventExtensions({width: playArea.clientWidth, height: playArea.clientHeight}); function everyFrame() { if (prevPlayAreaSize.width !== playArea.clientWidth || prevPlayAreaSize.height !== playArea.clientHeight) { @@ -736,9 +786,13 @@ window.addEventListener('load', function () { getById('piece-size-minus').addEventListener('click', () => { setPieceSize(pieceWidth / 1.2, pieceHeight / 1.2); }); - getById('host-multiplayer').addEventListener('click', () => { - getById('host-multiplayer').style.display = 'none'; - hostPuzzle(); + hostMultiplayerButton.addEventListener('click', () => { + hostMultiplayerButton.style.display = 'none'; + if (puzzleID) { + rejoinPuzzle(); + } else { + hostPuzzle(); + } }); setInterval(sendServerUpdate, 1000); setInterval(() => { @@ -747,4 +801,13 @@ window.addEventListener('load', function () { } }, 1000); requestAnimationFrame(everyFrame); + function openSocket() { + socket = new WebSocket(location.protocol === "file:" || location.hostname === "localhost" ? "ws://localhost:54472" : "wss://jigsaw.pommicket.com"); + socket.binaryType = "arraybuffer"; + socket.addEventListener('open', onSocketOpen); + socket.addEventListener('close', onSocketClose); + socket.addEventListener('error', onSocketError); + socket.addEventListener('message', onSocketMessage); + } + openSocket(); }); diff --git a/server/src/main.rs b/server/src/main.rs index 5e62213..c019a2a 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -15,7 +15,7 @@ use tungstenite::protocol::Message; const PUZZLE_ID_CHARSET: &[u8] = b"23456789abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ"; const PUZZLE_ID_LEN: usize = 7; -const MAX_PLAYERS: u16 = 20; +const MAX_PLAYERS: u32 = 20; const MAX_PIECES: usize = 1000; const ACTION_MOVE: u32 = 3; const ACTION_CONNECT: u32 = 4; @@ -28,7 +28,7 @@ fn generate_puzzle_id() -> [u8; PUZZLE_ID_LEN] { #[derive(Debug)] struct Server { // keep this in memory, since we want to reset it to 0 when the server restarts - player_counts: Mutex>, + player_counts: Mutex>, wikimedia_featured: Vec, wikimedia_potd: RwLock, database: tokio_postgres::Client, @@ -62,6 +62,28 @@ impl Server { .await .is_ok()) } + async fn increase_player_count(&self, id: [u8; PUZZLE_ID_LEN]) -> Result<()> { + let mut player_counts = self.player_counts.lock().await; + let entry = player_counts.entry(id).or_insert(0); + if *entry >= MAX_PLAYERS { + Err(Error::TooManyPlayers) + } else { + *entry += 1; + Ok(()) + } + } + async fn decrease_player_count(&self, id: [u8; PUZZLE_ID_LEN]) -> Result<()> { + let mut player_counts = self.player_counts.lock().await; + let std::collections::hash_map::Entry::Occupied(mut o) = player_counts.entry(id) else { + return Err(Error::BadPuzzleID); + }; + if *o.get() <= 1 { + o.remove(); + } else { + *o.get_mut() -= 1; + } + Ok(()) + } async fn set_puzzle_data( &self, id: [u8; PUZZLE_ID_LEN], @@ -186,7 +208,7 @@ impl std::fmt::Display for Error { match self { Error::BadPieceID => write!(f, "bad piece ID"), Error::BadPuzzleID => write!(f, "bad puzzle ID"), - Error::BadSyntax(s) => write!(f, "bad syntax: {s}"), + Error::BadSyntax(s) => write!(f, "{s}"), Error::ImageURLTooLong => write!(f, "image URL too long"), Error::TooManyPieces => write!(f, "too many pieces"), Error::NotJoined => write!(f, "haven't joined a puzzle"), @@ -326,16 +348,21 @@ async fn handle_websocket( .as_bytes() .try_into() .map_err(|_| Error::BadSyntax("bad join ID"))?; - let mut player_counts = server.player_counts.lock().await; - let entry = player_counts.entry(id).or_default(); - if *entry >= MAX_PLAYERS { - return Err(Error::TooManyPlayers); - } - *entry += 1; - drop(player_counts); // release lock + server.increase_player_count(id).await?; *puzzle_id = Some(id); let info = get_puzzle_info(server, &id).await?; ws.send(Message::Binary(info)).await?; + } else if let Some(id) = text.strip_prefix("rejoin ") { + let id = id + .as_bytes() + .try_into() + .map_err(|_| Error::BadSyntax("bad join ID"))?; + if puzzle_id.is_some() { + return Err(Error::BadSyntax("unexpected rejoin")); + } + server.increase_player_count(id).await?; + *puzzle_id = Some(id); + ws.send(Message::Text("rejoined".to_string())).await?; } else if text == "poll" { let puzzle_id = puzzle_id.ok_or(Error::NotJoined)?; let PieceInfo { @@ -434,16 +461,11 @@ async fn handle_connection(server: &Server, conn: &mut tokio::net::TcpStream) -> ws.send(Message::Text(format!("error {e}"))).await?; }; if let Some(puzzle_id) = puzzle_id { - *server - .player_counts - .lock() - .await - .entry(puzzle_id) - .or_insert_with(|| { - eprintln!("negative player count??"); - // prevent underflow - 1 - }) -= 1; + if let Err(e) = server.decrease_player_count(puzzle_id).await { + eprintln!( + "unexpected error while decreasing player count for puzzle {puzzle_id:?}: {e}" + ); + } } status } diff --git a/style.css b/style.css index b074ea1..122cd0a 100644 --- a/style.css +++ b/style.css @@ -79,3 +79,16 @@ a, a:visited { width: 100%; height: 100%; } + +#error { + position: absolute; + top: 1em; + right: 1em; + width: 30%; + padding: 0.5em; + border: 2px solid #800; + border-radius: 5px; + background-color: #a00; + color: white; + transition: opacity 3s; +} -- cgit v1.2.3