diff --git a/app/src/lib/join.svelte b/app/src/lib/join.svelte index 442acf3..8c281a5 100644 --- a/app/src/lib/join.svelte +++ b/app/src/lib/join.svelte @@ -23,13 +23,13 @@ {#if roomCode}
{roomCode}
diff --git a/app/src/lib/state.svelte.ts b/app/src/lib/state.svelte.ts index ade04f3..d3f0f7c 100644 --- a/app/src/lib/state.svelte.ts +++ b/app/src/lib/state.svelte.ts @@ -7,34 +7,43 @@ export class State { phase: Phase = $state('placement'); playerBoard = $state(new Board(false)); opponentBoard = $state(new Board(true)); + users = $state(0); room = $state(''); - turn = $state(false); + turn = $state(-1); // -1 not my turn, 0 might be, 1 is socket: Socket; constructor(hostname: string) { + let session = sessionStorage.getItem('session'); + this.socket = io(`ws://${hostname}:3000/`, { - transports: ['websocket'] + transports: ['websocket'], + auth: { session } }); - this.socket.on('joined-room', (room: string) => { - this.phase = 'waiting'; + this.socket.on('connect', () => { + sessionStorage.setItem('session', this.socket.id!); + }); + + this.socket.on('update-room', ({ room, users }) => { + if (this.phase == 'placement') this.phase = 'waiting'; this.room = room; + this.users = users; }); this.socket.on('upload', (_, callback) => { callback(this.playerBoard.board); }); this.socket.on('turnover', (id) => { - this.turn = id == this.socket.id; + this.turn = (id == this.socket.id) ? 1 : -1; this.phase = this.turn ? 'selfturn' : 'otherturn'; }); this.socket.on('attacked', ({ by, at, hit, sunk }) => { const [i, j]: [number, number] = at; let board = by == this.socket.id ? this.opponentBoard : this.playerBoard; if (by == this.socket.id) { - this.turn = hit; + this.turn = (hit) ? 1 : -1; } else { - this.turn = !hit; + this.turn = (!hit) ? 1 : -1; } if (hit) { board.board[i][j] = 'h'; @@ -62,12 +71,19 @@ export class State { } } }); + + this.socket.on('restore', ({ turn, player, opponent }: { turn: boolean, player: string[], opponent: string[] }) => { + this.turn = turn ? 1 : -1; + this.phase = this.turn ? 'selfturn' : 'otherturn'; + this.playerBoard.board = player.map((s) => s.split('').map(c => c as CellType)); + this.opponentBoard.board = opponent.map((s) => s.split('').map(c => c as CellType)); + }) } attack(i: number, j: number) { - if (!this.turn) return; + if (this.turn != 1) return; if (this.opponentBoard.board[i][j] != 'e') return; - this.turn = false; + this.turn = 0; this.socket.emit('attack', [i, j]); } @@ -87,6 +103,7 @@ export class State { } } + export class Board { static shipTypes = [5, 4, 3, 3, 2]; board: Array> = $state(Array.from({ length: 10 }, () => Array.from({ length: 10 }, () => 'e'))); diff --git a/app/src/routes/+page.svelte b/app/src/routes/+page.svelte index 8934f81..e8ab788 100644 --- a/app/src/routes/+page.svelte +++ b/app/src/routes/+page.svelte @@ -3,6 +3,7 @@ import Header from '$lib/header.svelte'; import Join from '$lib/join.svelte'; import { State } from '$lib/state.svelte'; + import { Users } from 'lucide-svelte'; const hostname = window.location.hostname; let gameState = new State(hostname); @@ -18,14 +19,35 @@

{gameState.hasNotStarted() ? 'Place your ships' - : gameState.turn + : gameState.turn >= 0 ? 'Make a guess' : 'Waiting for opponent'}

-
-
Your Ships: {5}
-
Enemy Ships: {5}
-
+ {#if gameState.room} +
+ +
+
+
{gameState.users}
+ +
+ +
+ {/if}
@@ -33,7 +55,7 @@

Your Board

{}} /> @@ -42,7 +64,7 @@

Opponent's Board

= 0 ? 'scale-[1.01]' : 'opacity-60'} board={gameState.opponentBoard} callback={(i, j) => gameState.attack(i, j)} /> diff --git a/migrations/0001_battleship.sql b/migrations/0001_battleship.sql index f42da13..fcfa698 100644 --- a/migrations/0001_battleship.sql +++ b/migrations/0001_battleship.sql @@ -24,15 +24,21 @@ CREATE TABLE IF NOT EXISTS rooms ( ) ); +CREATE TABLE IF NOT EXISTS abandoned_players ( + time TIMESTAMP PRIMARY KEY, + id CHAR(16) NOT NULL, + CONSTRAINT fk_player_id FOREIGN KEY (id) REFERENCES players (id) ON DELETE CASCADE ON UPDATE CASCADE +); + ALTER TABLE players ADD CONSTRAINT fk_room_code FOREIGN KEY (room_code) REFERENCES rooms (code) ON DELETE SET NULL; ALTER TABLE rooms ADD CONSTRAINT fk_player1 FOREIGN KEY (player1_id) REFERENCES players (id) ON DELETE -SET NULL, +SET NULL ON UPDATE CASCADE, ADD CONSTRAINT fk_player2 FOREIGN KEY (player2_id) REFERENCES players (id) ON DELETE -SET NULL; +SET NULL ON UPDATE CASCADE; -- delete room if both players are null CREATE OR REPLACE FUNCTION delete_room() RETURNS TRIGGER AS $$ BEGIN IF ( diff --git a/src/board.rs b/src/board.rs index e47d57f..0cb83e8 100644 --- a/src/board.rs +++ b/src/board.rs @@ -2,9 +2,9 @@ use std::ops::{Deref, DerefMut}; use axum::Json; use rand::Rng; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, Serialize)] pub struct Board(pub [[char; 10]; 10]); impl From for Vec { @@ -109,6 +109,31 @@ impl Board { Some(bounds) } + pub fn mark_redundant(mut self) -> Self { + for i in 0..10 { + for j in 0..10 { + if self[i][j] == 'h' { + for (dx, dy) in [(-1, -1), (1, 1), (1, -1), (-1, 1)].iter() { + let (tx, ty) = ((i as i32 + dx) as usize, (j as i32 + dy) as usize); + if (0..10).contains(&tx) && (0..10).contains(&ty) { + self[tx][ty] = 'm'; + } + } + if self.has_sunk((i, j)).is_some() { + for (dx, dy) in [(-1, 0), (1, 0), (0, -1), (0, 1)].iter() { + let (tx, ty) = ((i as i32 + dx) as usize, (j as i32 + dy) as usize); + if (0..10).contains(&tx) && (0..10).contains(&ty) && self[tx][ty] == 'e' + { + self[tx][ty] = 'm'; + } + } + } + } + } + } + self + } + // fn validate_syntax(&self) -> bool { // self // .iter() diff --git a/src/game.rs b/src/game.rs index 087c3dc..2048c96 100644 --- a/src/game.rs +++ b/src/game.rs @@ -1,3 +1,4 @@ +use serde::Serialize; use socketioxide::socket::Sid; use thiserror::Error; @@ -9,8 +10,8 @@ pub type Result = std::result::Result; #[derive(Debug, Error)] pub enum Error { - #[error("Room full")] - RoomFull, + #[error("Room full, potential replacement {0:?}")] + RoomFull(Option), #[error("Room not full")] RoomNotFull, #[error("Already in room")] @@ -23,7 +24,25 @@ pub enum Error { Sqlx(#[from] sqlx::Error), } +#[derive(Debug, sqlx::Type, PartialEq, Serialize)] +#[sqlx(type_name = "STAT", rename_all = "lowercase")] +pub enum Status { + Waiting, + P1Turn, + P2Turn, +} + +pub async fn room_if_player_exists(sid: &str, pool: &sqlx::PgPool) -> Result> { + Ok( + sqlx::query!("SELECT room_code FROM players WHERE id = $1", sid) + .fetch_optional(pool) + .await? + .map(|player| player.room_code), + ) +} + pub async fn add_room(sid: Sid, code: String, pool: &sqlx::PgPool) -> Result<()> { + delete_sid(sid.as_str(), pool).await?; sqlx::query!( r"WITH new_user AS (INSERT INTO players (id, room_code) VALUES ($1, $2) RETURNING id) INSERT INTO rooms (player1_id, code) SELECT $1, $2 FROM new_user", sid.as_str(), @@ -45,8 +64,15 @@ pub async fn join_room(sid: Sid, code: String, pool: &sqlx::PgPool) -> Result<() let sid = sid.as_str(); - if room.player1_id.is_some() && room.player2_id.is_some() { - return Err(Error::RoomFull); + if let (Some(p1), Some(p2)) = (room.player1_id.as_ref(), room.player2_id.as_ref()) { + if in_delete_sid(&p1, &pool).await? { + update_sid(&p1, sid, pool).await?; + return Err(Error::RoomFull(Some(p1.to_string()))); + } else if in_delete_sid(&p2, &pool).await? { + update_sid(&p2, sid, pool).await?; + return Err(Error::RoomFull(Some(p2.to_string()))); + } + return Err(Error::RoomFull(None)); } if let Some(id) = room.player1_id.as_ref() { if id == sid { @@ -58,7 +84,7 @@ pub async fn join_room(sid: Sid, code: String, pool: &sqlx::PgPool) -> Result<() return Err(Error::AlreadyInRoom); } } - + delete_sid(sid, pool).await?; let mut txn = pool.begin().await?; // create/update player @@ -84,6 +110,15 @@ pub async fn join_room(sid: Sid, code: String, pool: &sqlx::PgPool) -> Result<() Ok(()) } +pub async fn get_room(sid: Sid, pool: &sqlx::PgPool) -> Result> { + Ok( + sqlx::query!("SELECT room_code FROM players WHERE id = $1", sid.as_str()) + .fetch_optional(pool) + .await? + .map(|r| r.room_code), + ) +} + pub async fn add_board(sid: Sid, board: Board, pool: &sqlx::PgPool) -> Result<()> { let board: Vec = board.into(); sqlx::query!( @@ -96,6 +131,66 @@ pub async fn add_board(sid: Sid, board: Board, pool: &sqlx::PgPool) -> Result<() Ok(()) } +pub async fn get_game_state( + sid: &str, + room: &str, + pool: &sqlx::PgPool, +) -> Result<(bool, Vec, Vec)> { + let room_details = sqlx::query!( + r#"SELECT player1_id, player2_id, stat AS "stat: Status" FROM rooms WHERE code = $1"#, + room + ) + .fetch_one(pool) + .await?; + + let turn = match room_details.stat { + Status::P1Turn if room_details.player1_id == Some(sid.to_string()) => true, + Status::P2Turn if room_details.player2_id == Some(sid.to_string()) => true, + _ => false, + }; + + let oid = match (room_details.player1_id, room_details.player2_id) { + (Some(p1), Some(p2)) if p1 == sid => p2, + (Some(p1), Some(p2)) if p2 == sid => p1, + _ => return Err(Error::NotInRoom), + }; + + let player_board: Board = sqlx::query!( + r#"SELECT board FROM players WHERE id = $1 AND room_code = $2"#, + sid, + room + ) + .fetch_one(pool) + .await? + .board + .unwrap() + .into(); + let player_board: Vec = player_board.mark_redundant().into(); + + let opponent_board: Board = sqlx::query!( + r#"SELECT board FROM players WHERE id = $1 AND room_code = $2"#, + oid, + room + ) + .fetch_one(pool) + .await? + .board + .unwrap() + .into(); + let opponent_board: Vec = opponent_board.mark_redundant().into(); + let opponent_board: Vec = opponent_board + .into_iter() + .map(|row| { + row.chars() + .into_iter() + .map(|x| if x == 's' { 'e' } else { x }) + .collect() + }) + .collect::>(); + + Ok((turn, player_board, opponent_board)) +} + pub async fn start(sid: Sid, code: String, pool: &sqlx::PgPool) -> Result<()> { let room = sqlx::query!( r"SELECT player1_id, player2_id FROM rooms WHERE code = $1", @@ -190,17 +285,47 @@ pub async fn attack( Ok((hit, if hit { board.has_sunk((i, j)) } else { None })) } -pub async fn disconnect(sid: Sid, pool: &sqlx::PgPool) -> Result<()> { - sqlx::query!(r"DELETE FROM players WHERE id = $1", sid.as_str()) +pub async fn update_sid(oldsid: &str, newsid: &str, pool: &sqlx::PgPool) -> Result<()> { + sqlx::query!(r"UPDATE players SET id = $1 WHERE id = $2", newsid, oldsid) .execute(pool) .await?; Ok(()) } -#[derive(Debug, sqlx::Type, PartialEq)] -#[sqlx(type_name = "STAT", rename_all = "lowercase")] -enum Status { - Waiting, - P1Turn, - P2Turn, +pub async fn delete_sid(sid: &str, pool: &sqlx::PgPool) -> Result<()> { + sqlx::query!(r"DELETE FROM players WHERE id = $1", sid) + .execute(pool) + .await?; + Ok(()) +} + +pub async fn to_delete_sid(sid: &str, pool: &sqlx::PgPool) -> Result<()> { + sqlx::query!( + r"INSERT INTO abandoned_players (time, id) VALUES (NOW(), $1)", + sid + ) + .execute(pool) + .await?; + Ok(()) +} + +pub async fn in_delete_sid(sid: &str, pool: &sqlx::PgPool) -> Result { + Ok( + sqlx::query!(r"SELECT id FROM abandoned_players WHERE id = $1", sid) + .fetch_optional(pool) + .await? + .is_some(), + ) +} + +pub async fn delete_abandoned(pool: &sqlx::PgPool) -> Result<()> { + sqlx::query!( + r"DELETE FROM players + WHERE id IN (SELECT id FROM abandoned_players + ORDER BY time DESC + OFFSET 1000)" + ) + .execute(pool) + .await?; + Ok(()) // TODO: REMOVE duliplcates id from abandoned } diff --git a/src/main.rs b/src/main.rs index 16b40b1..5b4e45f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,8 +4,13 @@ use axum::Router; use board::Board; use dotenv::dotenv; use futures_util::stream::StreamExt; -use game::{add_board, add_room, attack, disconnect, join_room, start, ROOM_CODE_LENGTH}; +use game::{ + add_board, add_room, attack, delete_sid, get_game_state, get_room, join_room, + room_if_player_exists, start, to_delete_sid, update_sid, Error, ROOM_CODE_LENGTH, +}; use rand::Rng; + +use serde::Deserialize; use socketioxide::{ extract::{Data, SocketRef, State}, SocketIo, @@ -26,6 +31,11 @@ async fn main() -> Result<(), Box> { let url = std::env::var("DATABASE_URL")?; let pool = sqlx::postgres::PgPool::connect(&url).await?; sqlx::migrate!("./migrations").run(&pool).await?; + sqlx::query("DELETE FROM players").execute(&pool).await?; + sqlx::query("DELETE FROM abandoned_players") + .execute(&pool) + .await?; + sqlx::query("DELETE FROM rooms").execute(&pool).await?; let (layer, io) = SocketIo::builder().with_state(pool).build_layer(); io.ns("/", on_connect); @@ -40,8 +50,35 @@ async fn main() -> Result<(), Box> { Ok(()) } -fn on_connect(socket: SocketRef) { +#[derive(Debug, Deserialize)] +struct AuthPayload { + pub session: Option, +} + +async fn on_connect(socket: SocketRef, Data(auth): Data, pool: State) { tracing::info!("Connected: {:?}", socket.id); + tracing::info!("Connected: {:?}", auth.session); + + if let Some(sid) = auth.session { + update_sid(&sid, socket.id.as_str(), &pool).await.unwrap(); + let sid = socket.id.as_str(); + if let Some(room) = room_if_player_exists(&sid, &pool).await.unwrap() { + let data = get_game_state(&sid, &room, &pool).await.unwrap(); + socket + .emit( + "restore", + serde_json::json!({"turn": data.0, "player": data.1, "opponent": data.2}), + ) + .unwrap(); + socket.join(room.clone()).unwrap(); + emit_update_room( + &socket, + &room, + socket.within(room.clone()).sockets().unwrap().len(), + ); + } + } + socket.on( "create", |socket: SocketRef, pool: State| async move { @@ -59,6 +96,7 @@ fn on_connect(socket: SocketRef) { .map(|x| char::to_ascii_uppercase(&(x as char))) .collect(); tracing::info!("Creating room: {:?}", room); + // TODO: Handle duplicates if let Err(e) = add_room(socket.id, room.clone(), &pool).await { tracing::error!("{:?}", e); @@ -66,7 +104,11 @@ fn on_connect(socket: SocketRef) { } socket.leave_all().unwrap(); socket.join(room.clone()).unwrap(); - socket.emit("joined-room", &room).unwrap(); + emit_update_room( + &socket, + &room, + socket.within(room.clone()).sockets().unwrap().len(), + ); }, ); @@ -77,15 +119,30 @@ fn on_connect(socket: SocketRef) { return; } tracing::info!("Joining room: {:?}", room); - if let Err(e) = join_room(socket.id, room.clone(), &pool).await { - tracing::error!("{:?}", e); - return; + let room_error = join_room(socket.id, room.clone(), &pool).await; + if let Err(e) = &room_error { + if let Error::RoomFull(Some(player)) = &e { + tracing::warn!("{:?}", e); + update_sid(&player, socket.id.as_str(), &pool).await.unwrap(); + let data = get_game_state(socket.id.as_str(), &room, &pool).await.unwrap(); + socket + .emit( + "restore", + serde_json::json!({"turn": data.0, "player": data.1, "opponent": data.2}), + ) + .unwrap(); + } else { + tracing::error!("{:?}", e); + return; + } } socket.leave_all().unwrap(); socket.join(room.clone()).unwrap(); - socket.emit("joined-room", &room).unwrap(); - if socket.within(room.clone()).sockets().unwrap().len() != 2 { + let users = socket.within(room.clone()).sockets().unwrap().len(); + emit_update_room(&socket, &room, users); + + if users != 2 || room_error.is_err() { return; } let ack_stream = socket @@ -141,11 +198,44 @@ fn on_connect(socket: SocketRef) { }, ); + socket.on( + "leave", + |socket: SocketRef, pool: State| async move { + tracing::info!("Leaving Rooms: {:?}", socket.id); + leave_and_inform(&socket, &pool).await; + }, + ); + socket.on_disconnect(|socket: SocketRef, pool: State| async move { tracing::info!("Disconnecting: {:?}", socket.id); - socket.leave_all().unwrap(); - if let Err(e) = disconnect(socket.id, &pool).await { - tracing::error!("{:?}", e); - } + leave_and_inform(&socket, &pool).await; }); } + +async fn leave_and_inform(socket: &SocketRef, pool: &PgPool) { + let room = socket + .rooms() + .unwrap() + .first() + .map(|s| s.to_string()) + .or(get_room(socket.id, pool).await.unwrap()); + let Some(room) = room else { + return; + }; + let ops = socket.within(room.clone()); + socket.leave_all().unwrap(); + emit_update_room(socket, &room.to_string(), ops.sockets().unwrap().len()); + if let Err(e) = to_delete_sid(socket.id.as_str(), pool).await { + tracing::error!("{:?}", e); + } +} + +fn emit_update_room(socket: &SocketRef, room: &String, users: usize) { + socket + .within(room.clone()) + .emit( + "update-room", + serde_json::json!({"room": &room, "users": users}), + ) + .unwrap(); +}