restore state on reload/new join

This commit is contained in:
sparshg
2024-09-22 04:41:53 +05:30
parent b994cd7439
commit 44e72d77f2
7 changed files with 333 additions and 48 deletions

View File

@@ -23,13 +23,13 @@
{#if roomCode}
<div class="space-x-2 flex flex-row justify-center items-center">
<div
class="text-3xl font-bold tracking-widest text-accent-content font-mono bg-accent py-3 rounded-full px-12"
class="text-3xl font-bold tracking-widest text-secondary-content font-mono bg-secondary py-3 rounded-full px-12"
>
{roomCode}
</div>
<button
class="btn btn-accent btn-circle size-16"
class="btn btn-secondary btn-circle size-16"
onclick={() => navigator.clipboard.writeText(roomCode)}
>
<ClipboardCopy />
@@ -51,7 +51,7 @@
/>
<button
onclick={() => joinRoom(joinCode)}
class="w-full btn btn-outline btn-neutral text-neutral hover:text-neutral hover:bg-transparent text-xl"
class="w-full btn btn-outline btn-neutral text-neutral hover:border-neutral hover:bg-transparent text-xl"
>
Join Room
</button>

View File

@@ -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<Array<CellType>> = $state(Array.from({ length: 10 }, () => Array.from({ length: 10 }, () => 'e')));

View File

@@ -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 @@
<h2 class="text-2xl font-semibold rounded-full bg-base-300 py-3 px-6">
{gameState.hasNotStarted()
? 'Place your ships'
: gameState.turn
: gameState.turn >= 0
? 'Make a guess'
: 'Waiting for opponent'}
</h2>
<div class="flex space-x-4">
<div class="text-blue-600">Your Ships: {5}</div>
<div class="text-red-600">Enemy Ships: {5}</div>
{#if gameState.room}
<div class="flex flex-row h-full items-center space-x-4">
<button
class="rounded-full bg-base-300 px-4 uppercase font-mono font-bold tracking-wide text-xl py-2.5 tooltip tooltip-bottom"
data-tip="Copy"
onclick={() => navigator.clipboard.writeText(gameState.room)}
>
{gameState.room}
</button>
<div class="rounded-full bg-base-300 px-4 flex items-center space-x-2 py-3">
<div
class="size-3 bg-green-500 rounded-full shadow-[0_0_10px] shadow-green-500"
></div>
<div class="font-mono font-bold">{gameState.users}</div>
<Users />
</div>
<button
class="btn btn-error text-xl"
onclick={() => {
gameState.socket.emit('leave');
gameState = new State(window.location.hostname);
}}>Leave</button
>
</div>
{/if}
</div>
<div class="grid md:grid-cols-2 gap-8">
@@ -33,7 +55,7 @@
<h3 class="text-lg font-medium mb-2">Your Board</h3>
<Board
class={!gameState.turn ? 'scale-[1.01]' : 'opacity-60'}
class={gameState.turn < 0 ? 'scale-[1.01]' : 'opacity-60'}
board={gameState.playerBoard}
callback={() => {}}
/>
@@ -42,7 +64,7 @@
<h3 class="text-lg font-medium mb-2">Opponent's Board</h3>
<div class="relative">
<Board
class={gameState.turn ? 'scale-[1.01]' : 'opacity-60'}
class={gameState.turn >= 0 ? 'scale-[1.01]' : 'opacity-60'}
board={gameState.opponentBoard}
callback={(i, j) => gameState.attack(i, j)}
/>

View File

@@ -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 (

View File

@@ -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<Board> for Vec<String> {
@@ -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()

View File

@@ -1,3 +1,4 @@
use serde::Serialize;
use socketioxide::socket::Sid;
use thiserror::Error;
@@ -9,8 +10,8 @@ pub type Result<T> = std::result::Result<T, Error>;
#[derive(Debug, Error)]
pub enum Error {
#[error("Room full")]
RoomFull,
#[error("Room full, potential replacement {0:?}")]
RoomFull(Option<String>),
#[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<Option<String>> {
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<Option<String>> {
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<String> = 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<String>, Vec<String>)> {
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<String> = 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<String> = opponent_board.mark_redundant().into();
let opponent_board: Vec<String> = opponent_board
.into_iter()
.map(|row| {
row.chars()
.into_iter()
.map(|x| if x == 's' { 'e' } else { x })
.collect()
})
.collect::<Vec<_>>();
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<bool> {
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
}

View File

@@ -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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
Ok(())
}
fn on_connect(socket: SocketRef) {
#[derive(Debug, Deserialize)]
struct AuthPayload {
pub session: Option<String>,
}
async fn on_connect(socket: SocketRef, Data(auth): Data<AuthPayload>, pool: State<PgPool>) {
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<PgPool>| 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 {
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<PgPool>| async move {
tracing::info!("Leaving Rooms: {:?}", socket.id);
leave_and_inform(&socket, &pool).await;
},
);
socket.on_disconnect(|socket: SocketRef, pool: State<PgPool>| 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();
}