diff --git a/.gitignore b/.gitignore index b5e703e..ca10f2c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ /target .vscode -.env \ No newline at end of file +.env +test.sql \ No newline at end of file diff --git a/app/src/lib/board.svelte b/app/src/lib/board.svelte index 7273966..4efdcf6 100644 --- a/app/src/lib/board.svelte +++ b/app/src/lib/board.svelte @@ -5,23 +5,40 @@ let { board, callback }: { board: Board; callback: (i: number, j: number) => void } = $props(); -
- {#each board.board as row, i} - {#each row as cell, j} - - {/each} +
+ {#each [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] as i} +
{i}
{/each}
+ +
+
+ {#each ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J'] as i} +
{i}
+ {/each} +
+
+ {#each board.board as row, i} + {#each row as cell, j} + + {/each} + {/each} +
+
diff --git a/app/src/lib/state.svelte.ts b/app/src/lib/state.svelte.ts index 0a4ed74..f7f7981 100644 --- a/app/src/lib/state.svelte.ts +++ b/app/src/lib/state.svelte.ts @@ -9,35 +9,53 @@ export class State { opponentBoard = $state(new Board(true)); room = $state(''); turn = $state(false); - socket = io('ws://127.0.0.1:3000/', { - transports: ['websocket'] - }); + socket: Socket; + + constructor(hostname: string) { + this.socket = io(`ws://${hostname}:3000/`, { + transports: ['websocket'] + }); - constructor() { this.socket.on('created-room', (room: string) => { this.room = room; }); this.socket.on('upload', (_, callback) => { callback(this.playerBoard.board); - }) + }); this.socket.on('turnover', (id) => { this.turn = id != this.socket.id; - }) - this.socket.on('attacked', ({ by, at, res }) => { - let [i, j] = at; + }); + 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.opponentBoard.board[i][j] = res ? 'h' : 'm'; - this.turn = false; + this.turn = hit; } else { - this.playerBoard.board[i][j] = res ? 'h' : 'm'; - this.turn = true; + this.turn = !hit; } - }) + board.board[i][j] = hit ? 'h' : 'm'; + if (sunk) { + const [[minx, miny], [maxx, maxy]] = sunk; + const x1 = Math.max(0, minx - 1); + const y1 = Math.max(0, miny - 1); + const x2 = Math.min(9, maxx + 1); + const y2 = Math.min(9, maxy + 1); + for (let x = x1; x <= x2; x++) { + for (let y = y1; y <= y2; y++) { + if (board.board[x][y] == 'e') { + board.board[x][y] = 'm'; + } + } + } + } + }); } attack(i: number, j: number) { if (!this.turn) return; + if (this.opponentBoard.board[i][j] != 'e') return; this.turn = false; + this.socket.emit('attack', [i, j]); } diff --git a/app/src/routes/+page.svelte b/app/src/routes/+page.svelte index 8403baf..ce4632e 100644 --- a/app/src/routes/+page.svelte +++ b/app/src/routes/+page.svelte @@ -3,7 +3,8 @@ import Header from '$lib/header.svelte'; import { State } from '$lib/state.svelte'; - let gameState = new State(); + const hostname = window.location.hostname; + let gameState = new State(hostname);
diff --git a/app/tailwind.config.js b/app/tailwind.config.js index 41ab34d..f666207 100644 --- a/app/tailwind.config.js +++ b/app/tailwind.config.js @@ -9,8 +9,8 @@ export default { ], daisyui: { - themes: ["night"], // false: only light + dark | true: all themes | array: specific themes like this ["light", "dark", "cupcake"] - darkTheme: "night", // name of one of the included themes for dark mode + themes: ["cupcake", "night"], // false: only light + dark | true: all themes | array: specific themes like this ["light", "dark", "cupcake"] + darkTheme: "cupcake", // name of one of the included themes for dark mode base: true, // applies background color and foreground color for root element by default styled: true, // include daisyUI colors and design decisions for all components utils: true, // adds responsive and modifier utility classes diff --git a/migrations/0001_battleship.sql b/migrations/0001_battleship.sql index c1339fe..f42da13 100644 --- a/migrations/0001_battleship.sql +++ b/migrations/0001_battleship.sql @@ -3,7 +3,7 @@ CREATE TYPE STAT AS ENUM ('waiting', 'p1turn', 'p2turn'); CREATE TABLE IF NOT EXISTS players ( id CHAR(16) PRIMARY KEY, - board CHAR [10] [10], + board CHAR(10) [10], room_code CHAR(4) NOT NULL ); diff --git a/src/board.rs b/src/board.rs new file mode 100644 index 0000000..e47d57f --- /dev/null +++ b/src/board.rs @@ -0,0 +1,122 @@ +use std::ops::{Deref, DerefMut}; + +use axum::Json; +use rand::Rng; +use serde::Deserialize; + +#[derive(Debug, Deserialize)] +pub struct Board(pub [[char; 10]; 10]); + +impl From for Vec { + fn from(board: Board) -> Self { + board.iter().map(|row| row.iter().collect()).collect() + } +} + +impl From> for Board { + fn from(board: Vec) -> Self { + let mut arr = [['e'; 10]; 10]; + for (i, row) in board.iter().enumerate() { + for (j, cell) in row.chars().enumerate() { + arr[i][j] = cell; + } + } + Board(arr) + } +} + +impl Deref for Board { + type Target = [[char; 10]; 10]; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl DerefMut for Board { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +impl Board { + const SHIPS: [i32; 5] = [5, 4, 3, 3, 2]; + + pub fn from_json(Json(board): Json) -> Self { + board + } + + pub fn randomize() -> Self { + let mut board = Board([['e'; 10]; 10]); + for &length in Self::SHIPS.iter() { + loop { + let dir = rand::thread_rng().gen_bool(0.5); + let x = rand::thread_rng().gen_range(0..(if dir { 10 } else { 11 - length })); + let y = rand::thread_rng().gen_range(0..(if dir { 11 - length } else { 10 })); + if board.is_overlapping(x, y, length, dir) { + continue; + } + for i in 0..length { + let (tx, ty) = if dir { (x, y + i) } else { (x + i, y) }; + board[tx as usize][ty as usize] = 's'; + } + break; + } + } + board + } + + fn is_overlapping(&self, x: i32, y: i32, length: i32, dir: bool) -> bool { + for i in -1..2 { + for j in -1..=length { + let (tx, ty) = if dir { (x + i, y + j) } else { (x + j, y + i) }; + if !(0..10).contains(&tx) || !(0..10).contains(&ty) { + continue; + } + if self[tx as usize][ty as usize] != 'e' { + return true; + } + } + } + false + } + + pub fn has_sunk(&self, (i, j): (usize, usize)) -> Option<[(usize, usize); 2]> { + let mut queue = vec![(i, j)]; + let mut visited = vec![vec![false; 10]; 10]; + let mut bounds = [(i, j), (i, j)]; + visited[i][j] = true; + while let Some((x, y)) = queue.pop() { + if self[x][y] == 's' { + return None; + } + bounds[0].0 = bounds[0].0.min(x); + bounds[0].1 = bounds[0].1.min(y); + bounds[1].0 = bounds[1].0.max(x); + bounds[1].1 = bounds[1].1.max(y); + for (dx, dy) in [(-1, 0), (1, 0), (0, -1), (0, 1)].iter() { + let (tx, ty) = ((x as i32 + dx) as usize, (y as i32 + dy) as usize); + if (0..10).contains(&tx) + && (0..10).contains(&ty) + && !visited[tx][ty] + && matches!(self[tx][ty], 'h' | 's') + { + visited[tx][ty] = true; + queue.push((tx, ty)); + } + } + } + Some(bounds) + } + + // fn validate_syntax(&self) -> bool { + // self + // .iter() + // .all(|row| row.iter().all(|cell| matches!(cell, 'e' | 'h' | 'm' | 's'))) + // } +} + +// pub async fn create_board_route(board: Json) -> Json { +// let board = Board::from_json(board).await; +// Json(format!("{:?}", board)) +// } diff --git a/src/game.rs b/src/game.rs index fd5cbe6..b81c392 100644 --- a/src/game.rs +++ b/src/game.rs @@ -1,11 +1,8 @@ -use std::convert::Infallible; - -use axum::Json; -use rand::Rng; -use serde::Deserialize; use socketioxide::socket::Sid; use thiserror::Error; +use crate::board::Board; + pub const ROOM_CODE_LENGTH: usize = 4; pub type Result = std::result::Result; @@ -20,6 +17,8 @@ pub enum Error { AlreadyInRoom, #[error("Not in room")] NotInRoom, + #[error("Invalid Move")] + InvalidMove, #[error("SQL Error\n{0:?}")] Sqlx(#[from] sqlx::Error), } @@ -85,25 +84,14 @@ pub async fn join_room(sid: Sid, code: String, pool: &sqlx::PgPool) -> Result<() } pub async fn add_board(sid: Sid, board: Board, pool: &sqlx::PgPool) -> Result<()> { - 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(","), + let board: Vec = board.into(); + sqlx::query!( + "UPDATE players SET board = $1 WHERE id = $2", + &board, sid.as_str() - ); - sqlx::query(&query).execute(pool).await?; + ) + .execute(pool) + .await?; Ok(()) } @@ -137,7 +125,11 @@ pub async fn start(sid: Sid, code: String, pool: &sqlx::PgPool) -> Result<()> { Ok(()) } -pub async fn attack(sid: Sid, (i, j): (usize, usize), pool: &sqlx::PgPool) -> Result { +pub async fn attack( + sid: Sid, + (i, j): (usize, usize), + pool: &sqlx::PgPool, +) -> Result<(bool, Option<[(usize, usize); 2]>)> { let player = sqlx::query!(r"SELECT room_code FROM players WHERE id = $1", sid.as_str()) .fetch_one(pool) .await?; @@ -159,42 +151,42 @@ pub async fn attack(sid: Sid, (i, j): (usize, usize), pool: &sqlx::PgPool) -> Re _ => return Err(Error::RoomNotFull), // room not full }; + let mut board: Board = sqlx::query!(r"SELECT board FROM players WHERE id = $1", other) + .fetch_one(pool) + .await? + .board + .unwrap() + .into(); + + let hit = match board[i][j] { + 's' => true, + 'e' => false, + _ => return Err(Error::InvalidMove), + }; + board[i][j] = if hit { 'h' } else { 'm' }; + let mut txn = pool.begin().await?; - - 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"#, + r#"UPDATE players SET board[$1] = $2 WHERE id = $3"#, i as i32 + 1, - j as i32 + 1, + board[i].iter().collect::(), 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?; + if !hit { + 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") + Ok((hit, if hit { board.has_sunk((i, j)) } else { None })) } pub async fn disconnect(sid: Sid, pool: &sqlx::PgPool) -> Result<()> { @@ -211,60 +203,3 @@ enum Status { P1Turn, P2Turn, } - -#[derive(Debug, Deserialize)] -pub struct Board(pub [[char; 10]; 10]); - -impl Board { - const SHIPS: [i32; 5] = [5, 4, 3, 3, 2]; - - pub fn from_json(Json(board): Json) -> Self { - board - } - - pub fn randomize() -> Self { - let mut board = Board([['e'; 10]; 10]); - for &length in Self::SHIPS.iter() { - loop { - let dir = rand::thread_rng().gen_bool(0.5); - let x = rand::thread_rng().gen_range(0..(if dir { 10 } else { 11 - length })); - let y = rand::thread_rng().gen_range(0..(if dir { 11 - length } else { 10 })); - if board.is_overlapping(x, y, length, dir) { - continue; - } - for i in 0..length { - let (tx, ty) = if dir { (x, y + i) } else { (x + i, y) }; - board.0[tx as usize][ty as usize] = 's'; - } - break; - } - } - board - } - - fn is_overlapping(&self, x: i32, y: i32, length: i32, dir: bool) -> bool { - for i in -1..2 { - for j in -1..=length { - let (tx, ty) = if dir { (x + i, y + j) } else { (x + j, y + i) }; - if !(0..10).contains(&tx) || !(0..10).contains(&ty) { - continue; - } - if self.0[tx as usize][ty as usize] != 'e' { - return true; - } - } - } - false - } - - // fn validate_syntax(&self) -> bool { - // self.0 - // .iter() - // .all(|row| row.iter().all(|cell| matches!(cell, 'e' | 'h' | 'm' | 's'))) - // } -} - -// pub async fn create_board_route(board: Json) -> Json { -// let board = Board::from_json(board).await; -// Json(format!("{:?}", board.0)) -// } diff --git a/src/main.rs b/src/main.rs index 8c71bc2..91e8f64 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,9 +1,10 @@ +mod board; mod game; - 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, Board, ROOM_CODE_LENGTH}; +use game::{add_board, add_room, attack, disconnect, join_room, start, ROOM_CODE_LENGTH}; use rand::Rng; use socketioxide::{ extract::{Data, SocketRef, State}, @@ -33,7 +34,7 @@ async fn main() -> Result<(), Box> { .layer(layer) .layer(CorsLayer::very_permissive()); - let listener = TcpListener::bind("127.0.0.1:3000").await?; + let listener = TcpListener::bind("0.0.0.0:3000").await?; println!("listening on {}", listener.local_addr()?); axum::serve(listener, app).await?; Ok(()) @@ -98,7 +99,6 @@ fn on_connect(socket: SocketRef) { if let Err(e) = add_board(id, ack.data.pop().unwrap(), &pool).await { tracing::error!("{:?}", e); - return; } } Err(err) => tracing::error!("Ack error, {}", err), @@ -121,19 +121,19 @@ fn on_connect(socket: SocketRef) { socket.on( "attack", |socket: SocketRef, Data::<[usize; 2]>([i, j]), pool: State| async move { - let res = match attack(socket.id, (i, j), &pool).await { + let (hit, sunk) = match attack(socket.id, (i, j), &pool).await { Ok(res) => res, Err(e) => { tracing::error!("{:?}", e); return; } }; - tracing::info!("Attacking at: ({}, {}), result: {}", i, j, res); + tracing::info!("Attacking at: ({}, {}), result: {:?}", i, j, hit); socket .within(socket.rooms().unwrap().first().unwrap().clone()) .emit( "attacked", - serde_json::json!({"by": socket.id.as_str(), "at": [i, j], "res": res}), + serde_json::json!({"by": socket.id.as_str(), "at": [i, j], "hit": hit, "sunk": sunk}), ) .unwrap(); }, @@ -144,7 +144,6 @@ fn on_connect(socket: SocketRef) { socket.leave_all().unwrap(); if let Err(e) = disconnect(socket.id, &pool).await { tracing::error!("{:?}", e); - return; } }); }