diff --git a/app/src/lib/state.svelte.ts b/app/src/lib/state.svelte.ts index 6bf8b9c..0a4ed74 100644 --- a/app/src/lib/state.svelte.ts +++ b/app/src/lib/state.svelte.ts @@ -20,40 +20,29 @@ export class State { this.socket.on('upload', (_, callback) => { callback(this.playerBoard.board); }) - this.socket.on('turn', (id) => { - this.turn = id == this.socket.id; + this.socket.on('turnover', (id) => { + this.turn = id != this.socket.id; + }) + this.socket.on('attacked', ({ by, at, res }) => { + let [i, j] = at; + if (by == this.socket.id) { + this.opponentBoard.board[i][j] = res ? 'h' : 'm'; + this.turn = false; + } else { + this.playerBoard.board[i][j] = res ? 'h' : 'm'; + this.turn = true; + } }) } - async attack(i: number, j: number) { + attack(i: number, j: number) { if (!this.turn) return; this.turn = false; - const res = await this.socket.emitWithAck('attack', [i, j]); - if (res) { - this.opponentBoard.board[i][j] = 'h'; - } else { - this.opponentBoard.board[i][j] = 'm'; - } + this.socket.emit('attack', [i, j]); } - async createRoom() { + createRoom() { this.socket.emit('create'); - // this.socket.emit('upload', this.playerBoard.board); - // send the board to the server - // let api = 'http://127.0.0.1:3000/'; - // await fetch(api, { - // method: 'POST', - // headers: { - // 'Content-Type': 'application/json', - // 'Access-Control-Allow-Origin': '*', - // }, - // body: JSON.stringify(this.playerBoard.board), - // }).then((response) => { - // console.log(response); - // response.json().then((data) => { - // console.log(data); - // }); - // }); } joinRoom() { @@ -72,10 +61,6 @@ export class Board { if (!isOpponent) this.randomize(); } - // set(x: number, y: number, type: CellType) { - // this.board[x][y] = type; - // } - randomize() { this.board = Array.from({ length: 10 }, () => Array.from({ length: 10 }, () => 'e')); for (const shipLength of Board.shipTypes) { diff --git a/migrations/0001_battleship.sql b/migrations/0001_battleship.sql index 097bab8..c1339fe 100644 --- a/migrations/0001_battleship.sql +++ b/migrations/0001_battleship.sql @@ -3,15 +3,25 @@ CREATE TYPE STAT AS ENUM ('waiting', 'p1turn', 'p2turn'); CREATE TABLE IF NOT EXISTS players ( id CHAR(16) PRIMARY KEY, - board CHAR(10) [10], - room_code CHAR(4) + board CHAR [10] [10], + room_code CHAR(4) NOT NULL ); CREATE TABLE IF NOT EXISTS rooms ( code CHAR(4) PRIMARY KEY, player1_id CHAR(16), player2_id CHAR(16), - stat STAT DEFAULT 'waiting' + stat STAT DEFAULT 'waiting' NOT NULL, + CHECK ( + ( + player1_id IS DISTINCT + FROM player2_id + ) + OR ( + player1_id IS NULL + AND player2_id IS NULL + ) + ) ); ALTER TABLE players @@ -24,5 +34,22 @@ SET NULL, ADD CONSTRAINT fk_player2 FOREIGN KEY (player2_id) REFERENCES players (id) ON DELETE SET NULL; +-- delete room if both players are null +CREATE OR REPLACE FUNCTION delete_room() RETURNS TRIGGER AS $$ BEGIN IF ( + SELECT player1_id IS NULL + AND player2_id IS NULL + FROM rooms + WHERE code = OLD.room_code + ) THEN +DELETE FROM rooms +WHERE code = OLD.room_code; +END IF; +RETURN OLD; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER delete_room_trigger +AFTER DELETE ON players FOR EACH ROW EXECUTE FUNCTION delete_room(); + CREATE INDEX idx_player_room_code ON players (room_code); CREATE INDEX idx_room_status ON rooms (stat); \ No newline at end of file diff --git a/src/game.rs b/src/game.rs index ef615a8..ef4e838 100644 --- a/src/game.rs +++ b/src/game.rs @@ -1,34 +1,18 @@ -use std::{collections::HashMap, sync::Arc}; - use axum::Json; use rand::Rng; use serde::Deserialize; use socketioxide::socket::Sid; -use tokio::sync::RwLock; pub const ROOM_CODE_LENGTH: usize = 4; -// #[derive(Default, Clone)] -// pub struct Store { -// rooms: Arc>>, -// sockets: Arc>>, -// } - -// impl Store { -// pub async fn add_room(&self, code: String) { -// let mut store = self.rooms.write().await; -// store.insert( -// code.clone(), -// Room { -// code, -// ..Default::default() -// }, -// ); -// } -pub async fn add_room(code: String, pool: &sqlx::PgPool) -> Result<(), sqlx::Error> { - sqlx::query!("INSERT INTO rooms (code) VALUES ($1)", code) - .execute(pool) - .await?; +pub async fn add_room(sid: Sid, code: String, pool: &sqlx::PgPool) -> Result<(), sqlx::Error> { + 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(), + code + ) + .execute(pool) + .await?; Ok(()) } @@ -40,25 +24,39 @@ pub async fn join_room(sid: Sid, code: String, pool: &sqlx::PgPool) -> Result<() .fetch_one(pool) .await?; + let sid = sid.as_str(); + if room.player1_id.is_some() && room.player2_id.is_some() { return Err(sqlx::Error::RowNotFound); // room full } + if let Some(id) = room.player1_id.as_ref() { + if id == sid { + return Err(sqlx::Error::RowNotFound); // already in room + } + } + if let Some(id) = room.player2_id.as_ref() { + if id == sid { + return Err(sqlx::Error::RowNotFound); // already in room + } + } let mut txn = pool.begin().await?; + // create/update player sqlx::query!( r#"INSERT INTO players (id, room_code) VALUES ($1, $2) ON CONFLICT (id) DO UPDATE SET room_code = $2"#, - sid.as_str(), + sid, code ) .execute(&mut *txn) .await?; + // add to room sqlx::query(&format!( "UPDATE rooms SET player{}_id = $1 WHERE code = $2", if room.player1_id.is_none() { "1" } else { "2" } )) - .bind(sid.as_str()) + .bind(sid) .bind(code) .execute(&mut *txn) .await?; @@ -66,102 +64,133 @@ pub async fn join_room(sid: Sid, code: String, pool: &sqlx::PgPool) -> Result<() txn.commit().await?; Ok(()) } -// pub async fn join_room(&self, sid: Sid, code: String) -> Result<(), ()> { -// if self.rooms.read().await.get(&code).is_none() { -// return Err(()); -// }; -// let mut sockets = self.sockets.write().await; -// let player = sockets -// .entry(sid) -// .and_modify(|p| p.room = Some(code.clone())) -// .or_insert(Arc::new(Player { -// sid, -// room: Some(code.clone()), -// board: None, -// })); -// let mut rooms = self.rooms.write().await; -// let Some(room) = rooms.get_mut(&code) else { -// return Err(()); -// }; -// if room.player1.is_none() { -// room.player1 = Some(Arc::clone(player)); -// } -// Ok(()) -// } +pub async fn add_board(sid: Sid, board: Board, pool: &sqlx::PgPool) -> Result<(), sqlx::Error> { + let query = format!( + "UPDATE players SET board = ARRAY[{}] WHERE id = '{}'", + board + .0 + .map(|row| { + format!( + "ARRAY[{}]", + row.map(|x| format!("'{x}'")) + .into_iter() + .collect::>() + .join(",") + ) + }) + .into_iter() + .collect::>() + .join(","), + sid.as_str() + ); + sqlx::query(&query).execute(pool).await.unwrap(); + Ok(()) +} -// pub async fn add_board(&self, sid: Sid, board: Board) -> Result<(), ()> { -// let mut store = self.sockets.write().await; -// if let Some(player) = store.get_mut(&sid) { -// player.board = Some(board); -// } else { -// return Err(()); -// } -// Ok(()) -// } +pub async fn start(sid: Sid, code: String, pool: &sqlx::PgPool) -> Result<(), sqlx::Error> { + let room = sqlx::query!( + r"SELECT player1_id, player2_id FROM rooms WHERE code = $1", + code + ) + .fetch_one(pool) + .await?; -// pub async fn start(&self, code: String, sid: Sid) -> Result<(), ()> { -// let mut store = self.rooms.write().await; -// let Some(room) = store.get_mut(&code) else { -// return Err(()); -// }; -// dbg!(&room); -// let (Some(player1), Some(player2)) = (room.player1, room.player2) else { -// return Err(()); -// }; + let (Some(player1), Some(player2)) = (room.player1_id, room.player2_id) else { + return Err(sqlx::Error::RowNotFound); // room not full + }; -// if player1.sid == sid { -// room.status = Status::Player1Turn; -// } else if player2.sid == sid { -// room.status = Status::Player2Turn; -// } else { -// return Err(()); -// } -// Ok(()) -// } + let status = if sid.as_str() == player1 { + Status::P2Turn + } else if sid.as_str() == player2 { + Status::P1Turn + } else { + return Err(sqlx::Error::RowNotFound); // not in room + }; -// pub async fn attack(&self, sid: Sid, (i, j): (usize, usize)) -> Result { -// let sockets = self.sockets.read().await; -// let Some(player) = sockets.get(&sid) else { -// return Err(()); -// }; -// let mut rooms = self.rooms.write().await; -// let Some(room) = rooms.get_mut(player.room.as_ref().unwrap()) else { -// return Err(()); -// }; + sqlx::query!( + r"UPDATE rooms SET stat = $1 WHERE code = $2", + status as Status, + code + ) + .execute(pool) + .await?; + Ok(()) +} -// match room.status { -// Status::Player1Turn if player.sid == room.player1.as_ref().unwrap().sid => { -// room.status = Status::Player2Turn; -// return Ok(room.player2.as_ref().unwrap().board.as_ref().unwrap().0[i][j] == 's'); -// } -// Status::Player2Turn if player.sid == room.player2.as_ref().unwrap().sid => { -// room.status = Status::Player1Turn; -// return Ok(room.player1.as_ref().unwrap().board.as_ref().unwrap().0[i][j] == 's'); -// } -// _ => return Err(()), -// } +pub async fn attack( + sid: Sid, + (i, j): (usize, usize), + pool: &sqlx::PgPool, +) -> Result { + let player = sqlx::query!(r"SELECT room_code FROM players WHERE id = $1", sid.as_str()) + .fetch_one(pool) + .await?; -// Err(()) -// } -// } + let room = sqlx::query!( + r#"SELECT stat AS "stat: Status", player1_id, player2_id FROM rooms WHERE code = $1"#, + player.room_code + ) + .fetch_one(pool) + .await?; -// #[derive(Default, Debug)] -// struct Room { -// code: String, -// player1: Option>, -// player2: Option>, -// status: Status, -// } + let (_, other, to_status) = match (room.player1_id, room.player2_id) { + (Some(p1), Some(p2)) if p1 == sid.as_str() && room.stat == Status::P1Turn => { + (p1, p2, Status::P2Turn) + } + (Some(p1), Some(p2)) if p2 == sid.as_str() && room.stat == Status::P2Turn => { + (p2, p1, Status::P1Turn) + } + _ => return Err(sqlx::Error::RowNotFound), // room not full + }; -// #[derive(Debug)] -// struct Player { -// sid: Sid, -// board: Option, -// room: Option, -// } + let mut txn = pool.begin().await?; -#[derive(Debug, sqlx::Type)] + let turn = sqlx::query!( + r"SELECT board[$1][$2] as HIT FROM players WHERE id = $3", + i as i32 + 1, + j as i32 + 1, + other + ) + .fetch_one(&mut *txn) + .await?; + + sqlx::query!( + r#"UPDATE players + SET board[$1][$2] = CASE + WHEN board[$1][$2] = 's' THEN 'h' + WHEN board[$1][$2] = 'e' THEN 'm' + ELSE board[$1][$2] + END + WHERE id = $3"#, + i as i32 + 1, + j as i32 + 1, + other + ) + .execute(&mut *txn) + .await?; + + sqlx::query!( + r#"UPDATE rooms SET stat = $1 WHERE code = $2"#, + to_status as Status, + player.room_code + ) + .execute(&mut *txn) + .await?; + + txn.commit().await?; + Ok(turn.hit.unwrap() == "s") +} + +pub async fn disconnect(sid: Sid, pool: &sqlx::PgPool) -> Result<(), sqlx::Error> { + sqlx::query!(r"DELETE FROM players WHERE id = $1", sid.as_str()) + .execute(pool) + .await + .unwrap(); + Ok(()) +} + +#[derive(Debug, sqlx::Type, PartialEq)] #[sqlx(type_name = "STAT", rename_all = "lowercase")] enum Status { Waiting, @@ -169,12 +198,6 @@ enum Status { P2Turn, } -// impl Default for Status { -// fn default() -> Self { -// Status::Waiting -// } -// } - #[derive(Debug, Deserialize)] pub struct Board(pub [[char; 10]; 10]); diff --git a/src/main.rs b/src/main.rs index 04ef94f..f1ff68f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,7 +4,7 @@ use std::{str::FromStr, sync::Arc}; use axum::Router; use dotenv::dotenv; use futures_util::stream::StreamExt; -use game::{join_room, Board, ROOM_CODE_LENGTH}; +use game::{add_board, add_room, attack, disconnect, join_room, start, Board, ROOM_CODE_LENGTH}; use rand::Rng; use serde_json::Value; use socketioxide::{ @@ -13,6 +13,7 @@ use socketioxide::{ socket::Sid, SocketIo, }; +use sqlx::PgPool; use tokio::net::TcpListener; use tower_http::cors::CorsLayer; use tracing_subscriber::FmtSubscriber; @@ -28,14 +29,9 @@ 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?; - join_room( - Sid::from_str("aaaaaaaaaaaaaaaa").unwrap(), - "AAAB".to_string(), - &pool, - ) - .await?; let (layer, io) = SocketIo::builder().with_state(pool).build_layer(); - // io.ns("/", on_connect); + + io.ns("/", on_connect); let app = Router::new() // .route("/", post(game::create_board_route)) .layer(layer) @@ -47,89 +43,98 @@ async fn main() -> Result<(), Box> { Ok(()) } -// fn on_connect(socket: SocketRef, io: SocketIo) { -// tracing::info!("Connected: {:?}", socket.id); -// // tracing::info!( -// // "All rooms and sockets: {:?}", -// // io.rooms() -// // .unwrap() -// // .iter() -// // .map(|room| { (room, io.within(room.clone()).sockets().unwrap()) }) -// // ); +fn on_connect(socket: SocketRef, io: SocketIo) { + tracing::info!("Connected: {:?}", socket.id); + // tracing::info!( + // "All rooms and sockets: {:?}", + // io.rooms() + // .unwrap() + // .iter() + // .map(|room| { (room, io.within(room.clone()).sockets().unwrap()) }) + // ); -// socket.on( -// "create", -// |socket: SocketRef, store: State| async move { -// if !socket.rooms().unwrap().is_empty() { -// socket -// .emit("created-room", socket.rooms().unwrap().first()) -// .unwrap(); -// println!("{} Already in a room", socket.id); -// return; -// } + socket.on( + "create", + |socket: SocketRef, pool: State| async move { + if !socket.rooms().unwrap().is_empty() { + socket + .emit("created-room", socket.rooms().unwrap().first()) + .unwrap(); + println!("{} Already in a room", socket.id); + return; + } -// let room: String = rand::thread_rng() -// .sample_iter(&rand::distributions::Alphanumeric) -// .take(ROOM_CODE_LENGTH) -// .map(|x| char::to_ascii_uppercase(&(x as char))) -// .collect(); -// tracing::info!("Creating room: {:?}", room); -// store.add_room(room.clone()).await; -// store.join_room(socket.id, room.clone()).await.unwrap(); -// socket.leave_all().unwrap(); -// socket.join(room.clone()).unwrap(); -// socket.emit("created-room", &room).unwrap(); -// }, -// ); + let room: String = rand::thread_rng() + .sample_iter(&rand::distributions::Alphanumeric) + .take(ROOM_CODE_LENGTH) + .map(|x| char::to_ascii_uppercase(&(x as char))) + .collect(); + tracing::info!("Creating room: {:?}", room); + add_room(socket.id, room.clone(), &pool).await.unwrap(); + socket.leave_all().unwrap(); + socket.join(room.clone()).unwrap(); + socket.emit("created-room", &room).unwrap(); + }, + ); -// socket.on( -// "join", -// |socket: SocketRef, Data::(room), store: State| async move { -// if room.len() != ROOM_CODE_LENGTH { -// return; -// } -// tracing::info!("Joining room: {:?}", room); -// store.join_room(socket.id, room.clone()).await.unwrap(); -// socket.leave_all().unwrap(); -// socket.join(room.clone()).unwrap(); -// if socket.within(room.clone()).sockets().unwrap().len() != 2 { -// return; -// } -// let ack_stream = socket -// .within(room.clone()) -// .emit_with_ack::>("upload", ()) -// .unwrap(); -// ack_stream -// .for_each(|(id, ack)| { -// let store = store.clone(); -// async move { -// match ack { -// Ok(mut ack) => { -// store.add_board(id, ack.data.pop().unwrap()).await.unwrap(); -// } -// Err(err) => tracing::error!("Ack error, {}", err), -// } -// } -// }) -// .await; -// store.start(room.clone(), socket.id).await.unwrap(); -// tracing::info!("Game started"); -// socket.within(room.clone()).emit("turn", socket.id).unwrap(); -// }, -// ); + socket.on( + "join", + |socket: SocketRef, Data::(room), pool: State| async move { + if room.len() != ROOM_CODE_LENGTH { + return; + } + tracing::info!("Joining room: {:?}", room); + join_room(socket.id, room.clone(), &pool).await.unwrap(); + socket.leave_all().unwrap(); + socket.join(room.clone()).unwrap(); + if socket.within(room.clone()).sockets().unwrap().len() != 2 { + return; + } + let ack_stream = socket + .within(room.clone()) + .emit_with_ack::>("upload", ()) + .unwrap(); + ack_stream + .for_each(|(id, ack)| { + let pool = pool.clone(); + async move { + match ack { + Ok(mut ack) => { + add_board(id, ack.data.pop().unwrap(), &pool).await.unwrap(); + } + Err(err) => tracing::error!("Ack error, {}", err), + } + } + }) + .await; + start(socket.id, room.clone(), &pool).await.unwrap(); + tracing::info!("Game started"); + socket + .within(room.clone()) + .emit("turnover", socket.id) + .unwrap(); + }, + ); -// socket.on( -// "attack", -// |socket: SocketRef, Data::<[usize; 2]>([i, j]), ack: AckSender, store: State| async move { -// let res = store.attack(socket.id, (i, j)).await.unwrap(); -// tracing::info!("Attacking at: ({}, {}), result: {}", i, j, res); -// ack.send(res).unwrap(); -// }, -// ); + socket.on( + "attack", + |socket: SocketRef, Data::<[usize; 2]>([i, j]), pool: State| async move { + let res = attack(socket.id, (i, j), &pool).await.unwrap(); + tracing::info!("Attacking at: ({}, {}), result: {}", i, j, res); + socket + .within(socket.rooms().unwrap().first().unwrap().clone()) + .emit( + "attacked", + serde_json::json!({"by": socket.id.as_str(), "at": [i, j], "res": res}), + ) + .unwrap(); + }, + ); -// socket.on_disconnect(|socket: SocketRef, store: State| { -// tracing::info!("Disconnecting: {:?}", socket.id); -// socket.leave_all().unwrap(); -// // TODO: Delete room -// }); -// } + socket.on_disconnect(|socket: SocketRef, pool: State| async move { + tracing::info!("Disconnecting: {:?}", socket.id); + socket.leave_all().unwrap(); + disconnect(socket.id, &pool).await.unwrap(); + // TODO: Delete room + }); +}