24 Commits
v1.0.1 ... main

Author SHA1 Message Date
8a6fb7ccdb Uses prebuilt server image
Some checks failed
Check / frontend (push) Has been cancelled
Check / backend (push) Has been cancelled
Simplifies deployment by using a prebuilt Docker image for the server.

Removes build configuration and related port mappings from the compose file, relying on the image configuration instead.

Also adds a compose template file with the original configuration.
2025-07-16 23:40:37 +02:00
sparshg
bc4bf2c696 add sqlx checks 2024-10-06 00:56:55 +05:30
sparshg
2b19134d88 v1.2.0 2024-10-06 00:55:40 +05:30
sparshg
2bb5efd9bb fmt, clippy 2024-10-06 00:53:46 +05:30
sparshg
5fee374ac9 fix game restart 2024-10-06 00:53:46 +05:30
Khadeer
cd1a156d3e feat: end screen 2024-10-06 00:53:46 +05:30
sparshg
1d61f8fc32 v1.1.2 2024-10-03 02:28:15 +05:30
sparshg
9e86d40ca8 remove cors layer 2024-10-03 02:27:42 +05:30
arinak1017
cec5d58937 Fixed spelling - "containerized" 2024-10-03 02:09:42 +05:30
sparshg
14e0b47596 leave button styling 2024-10-03 01:16:02 +05:30
Yael Arturo Chavoya Andalón
a7ae0ea4ff refactor(join): change leave button styles to match 2024-10-03 01:16:02 +05:30
Yael Arturo Chavoya Andalón
3db83c03fd refactor(join): shorten prompt text 2024-10-03 01:16:02 +05:30
Yael Arturo Chavoya Andalón
0b5f513520 feat(join): add leave room button when there is no opponent yet 2024-10-03 01:16:02 +05:30
Yael Arturo Chavoya Andalón
e285fa4801 feat(join): hide join button if already on a room 2024-10-03 01:16:02 +05:30
Sidharth-Singh10
db4a58c3e6 Default ALLOWED_ORIGINS to localhost:5173 with trace logging 2024-09-28 23:25:33 +05:30
Sidharth-Singh10
a9ef92721f Updating CORS to be more restrictive 2024-09-28 23:25:33 +05:30
Sparsh Goenka
8a1b9bf603 Update README.md 2024-09-28 13:20:57 +05:30
sparshg
1916ad332b fix themes, bump 2024-09-26 15:36:15 +05:30
sparshg
1c4663d753 images 2024-09-26 04:01:36 +05:30
Sparsh Goenka
f5f3f29595 Update README.md 2024-09-26 03:37:43 +05:30
sparshg
36d2bcaf01 add dev guide 2024-09-26 03:14:28 +05:30
sparshg
26e3d3db20 bump v1.1.0 2024-09-26 01:53:39 +05:30
sparshg
e49e5e086b working pipeline 2024-09-26 01:47:13 +05:30
sparshg
3e5fdf4615 add cd 2024-09-26 01:39:06 +05:30
24 changed files with 294 additions and 152 deletions

View File

@@ -1,4 +1,4 @@
name: Deploy
name: Deploy backend
on:
push:
@@ -7,9 +7,12 @@ on:
workflow_dispatch:
jobs:
docker:
docker-azure:
runs-on: ubuntu-latest
environment: battleship
permissions:
id-token: write
contents: read
steps:
-
name: Login to Docker Hub
@@ -25,6 +28,23 @@ jobs:
uses: docker/build-push-action@v6
with:
push: true
tags: ${{ secrets.DOCKER_IMAGE_PATH }}:latest
cache-from: type=gha
cache-to: type=gha,mode=max
tags: ${{ secrets.DOCKER_IMAGE_PATH }}:${{ github.sha }}
cache-from: type=registry,ref=${{ secrets.DOCKER_IMAGE_PATH }}:buildcache
cache-to: type=registry,ref=${{ secrets.DOCKER_IMAGE_PATH }}:buildcache,mode=max
-
name: Azure login
uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
-
name: Deploy Container
uses: azure/container-apps-deploy-action@v1
with:
registryUrl: docker.io
containerAppName: battleship
resourceGroup: Battleship
imageToDeploy: docker.io/${{ secrets.DOCKER_IMAGE_PATH }}:${{ github.sha }}

View File

@@ -1,11 +1,13 @@
name: Deploy
name: Deploy frontend
on:
push:
branches: [ "main" ]
tags:
- v**
workflow_dispatch:
jobs:
build_site:
build_frontend:
runs-on: ubuntu-latest
steps:
- name: Checkout
@@ -34,8 +36,8 @@ jobs:
# this should match the `pages` option in your adapter-static options
path: 'app/build/'
deploy-site:
needs: build_site
deploy-frontend:
needs: build_frontend
runs-on: ubuntu-latest
permissions:

View File

@@ -11,14 +11,30 @@ env:
SQLX_OFFLINE: true
jobs:
build:
backend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: Swatinem/rust-cache@v2
- name: Check
- name: cargo-check
run: cargo check
- name: Clippy
- name: cargo-clippy
run: cargo clippy
- name: Format
run: cargo fmt --all --check
- name: cargo-fmt
run: cargo fmt --all --check
frontend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
cache-dependency-path: 'app/package-lock.json'
- name: Install dependencies
working-directory: app
run: npm install
- name: lint
working-directory: app
run: npm run lint

View File

@@ -12,7 +12,8 @@
"Enum": [
"waiting",
"p1turn",
"p2turn"
"p2turn",
"gameover"
]
}
}

View File

@@ -23,7 +23,8 @@
"Enum": [
"waiting",
"p1turn",
"p2turn"
"p2turn",
"gameover"
]
}
}

View File

@@ -13,7 +13,8 @@
"Enum": [
"waiting",
"p1turn",
"p2turn"
"p2turn",
"gameover"
]
}
}

2
Cargo.lock generated
View File

@@ -154,7 +154,7 @@ checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b"
[[package]]
name = "battleship"
version = "0.1.0"
version = "1.2.0"
dependencies = [
"axum",
"dotenv",

View File

@@ -1,6 +1,6 @@
[package]
name = "battleship"
version = "0.1.0"
version = "1.2.0"
edition = "2021"
[dependencies]

View File

@@ -45,11 +45,7 @@ cp ./target/release/$APP_NAME /bin/server
# runtime dependencies for the application. This often uses a different base
# image from the build stage where the necessary files are copied from the build
# stage.
#
# The example below uses the alpine image as the foundation for running the app.
# By specifying the "3.18" tag, it will use version 3.18 of alpine. If
# reproducability is important, consider using a digest
# (e.g., alpine@sha256:664888ac9cfd28068e062c991ebcff4b4c7307dc8dd4df9e728bedde5c449d91).
FROM alpine AS final
# Create a non-privileged user that the app will run under.

View File

@@ -1,22 +0,0 @@
### Building and running your application
When you're ready, start your application by running:
`docker compose up --build`.
Your application will be available at http://localhost:3000.
### Deploying your application to the cloud
First, build your image, e.g.: `docker build -t myapp .`.
If your cloud uses a different CPU architecture than your development
machine (e.g., you are on a Mac M1 and your cloud provider is amd64),
you'll want to build the image for that platform, e.g.:
`docker build --platform=linux/amd64 -t myapp .`.
Then, push it to your registry, e.g. `docker push myregistry.com/myapp`.
Consult Docker's [getting started](https://docs.docker.com/go/get-started-sharing/)
docs for more detail on building and pushing.
### References
* [Docker's Rust guide](https://docs.docker.com/language/rust/)

25
README.md Normal file
View File

@@ -0,0 +1,25 @@
# Battleship Online
Play the classic game of Battleship against your friends online! Each player will take turns guessing the location of the other player's ships. The first player to sink all of the other player's ships wins!
Dark mode | Light mode
:-------------------------:|:-------------------------:
![](demo/1.png) | ![](demo/2.png)
## Development Guide
The client is built using SvelteKit (static site) and the server uses Axum framework (Rust). The client and server communicate using Socket.io (WebSockets). PostgreSQL is used as a database to store the game state.
The client can be started using `npm run dev` inside `app` directory.
The server and the database services are containerized. Just run `docker compose up` to start the server and database services if you are working on the frontend.
Make sure to make a `.env` file with these parameters:
```
DATABASE_PASSWORD=db_password
DATABASE_NAME=db_name
DATABASE_URL=postgres://postgres:db_password@localhost:5432/db_name
```
If you are working on the server, you can run `cargo watch -i app -x run` to automatically restart the server when the source code changes, and `docker compose up -d db` to start the database service in the background.
SQLx is used as the database driver for Rust. The driver automatically tests the SQL query macros at compile time. This can fail the rust-analyzer or `cargo build` if the database isn't setup/running. You can run `docker compose up db` to start the database service. To disable this check altogether, set the `SQLX_OFFLINE` environment variable to `true`.

View File

@@ -8,7 +8,7 @@
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --check . && eslint .",
"lint": "eslint .",
"format": "prettier --write ."
},
"devDependencies": {
@@ -38,4 +38,4 @@
"lucide-svelte": "^0.441.0",
"socket.io-client": "^4.7.5"
}
}
}

View File

@@ -7,11 +7,13 @@
class: className = '',
roomCode,
createRoom,
joinRoom
joinRoom,
leaveRoom
}: {
roomCode: string;
createRoom: () => void;
joinRoom: (code: string) => void;
leaveRoom: () => void;
class: string;
} = $props();
</script>
@@ -21,6 +23,7 @@
>
<div class="space-y-4 max-w-[70%]">
{#if roomCode}
<div class="text-center text-lg text-primary-content">Share this room code</div>
<div class="space-x-2 flex flex-row justify-center items-center">
<div
class="text-3xl font-bold tracking-widest text-secondary-content font-mono bg-secondary py-3 rounded-full px-12"
@@ -41,20 +44,31 @@
</button>
{/if}
<div class="text-center text-lg text-primary-content">OR</div>
<div class="space-y-2">
<input
type="text"
placeholder="Enter code"
maxlength="4"
bind:value={joinCode}
class="input input-bordered input-primary uppercase tracking-widest placeholder-primary text-neutral text-center font-bold text-xl lg:text-3xl w-full glass"
/>
<button
onclick={() => joinRoom(joinCode)}
class="w-full btn btn-outline btn-neutral text-neutral hover:border-neutral hover:bg-transparent text-xl"
>
Join Room
</button>
</div>
{#if !roomCode}
<div class="space-y-2">
<input
type="text"
placeholder="Enter code"
maxlength="4"
bind:value={joinCode}
class="input input-bordered input-primary uppercase tracking-widest placeholder-primary text-neutral text-center font-bold text-xl lg:text-3xl w-full glass"
/>
<button
onclick={() => joinRoom(joinCode)}
class="w-full btn btn-outline btn-neutral text-neutral hover:border-neutral hover:bg-transparent text-xl"
>
Join Room
</button>
</div>
{:else}
<div class="space-x-2 flex flex-row justify-center items-center">
<button
class="w-full btn btn-outline btn-neutral text-neutral hover:border-neutral hover:bg-transparent text-xl"
onclick={leaveRoom}
>
Leave room
</button>
</div>
{/if}
</div>
</div>

View File

@@ -1,6 +1,6 @@
import { io, Socket } from "socket.io-client";
export type Phase = 'placement' | 'waiting' | 'selfturn' | 'otherturn';
export type Phase = 'placement' | 'waiting' | 'selfturn' | 'otherturn' | 'gameover';
export type CellType = 'e' | 's' | 'h' | 'm'; // empty, ship, hit, miss
export class State {
@@ -13,7 +13,8 @@ export class State {
socket: Socket;
constructor() {
this.socket = io(`wss://battleship.icyground-d91964e0.centralindia.azurecontainerapps.io`, {
const url = import.meta.env.DEV ? 'ws://localhost:3000' : 'wss://battleship.icyground-d91964e0.centralindia.azurecontainerapps.io';
this.socket = io(url, {
transports: ['websocket'],
auth: { session: sessionStorage.getItem('session') }
});
@@ -28,17 +29,21 @@ export class State {
this.room = room;
this.users = users;
});
this.socket.on('upload', (_, callback) => {
if (this.phase == 'gameover') {
this.playerBoard.randomize();
this.opponentBoard = new Board(true);
this.phase = 'waiting';
}
callback(this.playerBoard.board);
});
this.socket.on('turnover', (id) => {
this.turn = (id == this.socket.id) ? 1 : -1;
this.phase = this.turn ? 'selfturn' : 'otherturn';
});
this.socket.on('attacked', ({ by, at, hit, sunk }) => {
this.socket.on('attacked', ({ by, at, hit, sunk, game_over }) => {
const [i, j]: [number, number] = at;
let board = by == this.socket.id ? this.opponentBoard : this.playerBoard;
const board = by == this.socket.id ? this.opponentBoard : this.playerBoard;
if (by == this.socket.id) {
this.turn = (hit) ? 1 : -1;
} else {
@@ -46,7 +51,7 @@ export class State {
}
if (hit) {
board.board[i][j] = 'h';
for (let [x, y] of [[-1, -1], [1, 1], [1, -1], [-1, 1]]) {
for (const [x, y] of [[-1, -1], [1, 1], [1, -1], [-1, 1]]) {
const [tx, ty] = [i + x, j + y];
if (tx < 0 || tx >= 10 || ty < 0 || ty >= 10) continue;
if (board.board[tx][ty] == 'e')
@@ -69,13 +74,19 @@ export class State {
}
}
}
if (game_over) {
this.phase = 'gameover';
}
});
this.socket.on('restore', ({ turn, player, opponent }: { turn: boolean, player: string[], opponent: string[] }) => {
this.socket.on('restore', ({ turn, player, opponent, gameover }: { turn: boolean, player: string[], opponent: string[], gameover: boolean }) => {
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));
if (gameover) {
this.phase = 'gameover';
}
})
}
@@ -93,13 +104,17 @@ export class State {
joinRoom(code: string) {
code = code.toUpperCase();
if (code.length != 4 || code == this.room) return;
if (code.length != 4 || code == this.room && this.phase !== 'gameover') return;
this.socket.emit('join', code);
}
hasNotStarted() {
return this.phase == 'placement' || this.phase == 'waiting';
}
playAgain() {
this.joinRoom(this.room);
}
}
@@ -132,7 +147,7 @@ export class Board {
isOverlapping(x: number, y: number, length: number, dir: number): boolean {
for (let i = -1; i < 2; i++) {
for (let j = -1; j < length + 1; j++) {
let [tx, ty] = [x + (dir ? i : j), y + (dir ? j : i)];
const [tx, ty] = [x + (dir ? i : j), y + (dir ? j : i)];
if (tx < 0 || tx >= 10 || ty < 0 || ty >= 10) continue;
if (this.board[tx][ty] != 'e') return true;
}

View File

@@ -6,6 +6,11 @@
import { Users } from 'lucide-svelte';
let gameState = new State();
function leaveRoom() {
gameState.socket.emit('leave');
gameState = new State();
}
</script>
<div class="min-h-screen bg-base-300 py-8 px-4 sm:px-6 lg:px-8">
@@ -38,13 +43,7 @@
<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();
}}>Leave</button
>
<button class="btn btn-error text-xl" onclick={leaveRoom}>Leave</button>
</div>
{/if}
</div>
@@ -67,12 +66,33 @@
board={gameState.opponentBoard}
callback={(i, j) => gameState.attack(i, j)}
/>
{#if gameState.phase === 'gameover'}
<div
class="absolute inset-0 flex items-center justify-center bg-black bg-opacity-50 pointer-events-none"
>
<div class="p-6 bg-base-300 rounded-xl text-center">
<h3 class="text-2xl font-semibold">Game Over</h3>
<p class="text-lg">
{gameState.turn >= 0 ? 'You win!' : 'You lose!'}
</p>
<button
class="btn btn-primary mt-4 pointer-events-auto"
onclick={() => gameState.playAgain()}
>
Play Again
</button>
<button class="btn btn-secondary mt-4 ml-4 pointer-events-auto" onclick={leaveRoom}>Leave</button>
</div>
</div>
{/if}
{#if gameState.hasNotStarted()}
<Join
class="absolute top-[24px] left-[15px] w-[calc(100%-15px)] h-[calc(100%-24px)]"
roomCode={gameState.room}
createRoom={() => gameState.createRoom()}
joinRoom={(code) => gameState.joinRoom(code)}
{leaveRoom}
/>
{/if}
</div>

View File

@@ -1,22 +1,22 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./src/**/*.{html,js,svelte,ts}'],
theme: {
extend: {},
},
plugins: [
require('daisyui'),
],
content: ['./src/**/*.{html,js,svelte,ts}'],
theme: {
extend: {},
},
plugins: [
require('daisyui'), // eslint-disable-line
],
daisyui: {
themes: true, // 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
prefix: "", // prefix for daisyUI classnames (components, modifiers and responsive class names. Not colors)
logs: true, // Shows info about daisyUI version and used config in the console when building your CSS
themeRoot: ":root", // The element that receives theme color CSS variables
},
daisyui: {
themes: ["cupcake", "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
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
prefix: "", // prefix for daisyUI classnames (components, modifiers and responsive class names. Not colors)
logs: true, // Shows info about daisyUI version and used config in the console when building your CSS
themeRoot: ":root", // The element that receives theme color CSS variables
},
}

View File

@@ -1,35 +1,14 @@
# Comments are provided throughout this file to help you get started.
# If you need more help, visit the Docker Compose reference guide at
# https://docs.docker.com/go/compose-spec-reference/
# Here the instructions define your application as a service called "server".
# This service is built from the Dockerfile in the current directory.
# You can add other services your application may depend on here, such as a
# database or a cache. For examples, see the Awesome Compose repository:
# https://github.com/docker/awesome-compose
services:
server:
image: ${DOCKER_IMAGE_PATH}
build:
context: .
target: final
image: docker.registry.computerliebe.org/battleship
environment:
DATABASE_URL: postgres://postgres:${DATABASE_PASSWORD}@db:5432/${DATABASE_NAME}
ports:
- 3000:3000
# ports:
# - 3000:3000
depends_on:
db:
condition: service_healthy
# The commented out section below is an example of how to define a PostgreSQL
# database that your application can use. `depends_on` tells Docker Compose to
# start the database before your application. The `db-data` volume persists the
# database data between container restarts. The `db-password` secret is used
# to set the database password. You must create `db/password.txt` and add
# a password of your choosing to it before running `docker compose up`.
# depends_on:
# db:
# condition: service_healthy
db:
image: postgres
restart: always
@@ -39,8 +18,10 @@ services:
environment:
POSTGRES_DB: ${DATABASE_NAME}
POSTGRES_PASSWORD: ${DATABASE_PASSWORD}
expose:
- 5432
# ports:
# - 5432:5432
# expose:
# - 5432
healthcheck:
test: [ "CMD", "pg_isready" ]
interval: 10s

34
compose.yaml.template Normal file
View File

@@ -0,0 +1,34 @@
services:
server:
build:
context: .
target: final
environment:
DATABASE_URL: postgres://postgres:${DATABASE_PASSWORD}@db:5432/${DATABASE_NAME}
ports:
- 3000:3000
depends_on:
db:
condition: service_healthy
db:
image: postgres
restart: always
user: postgres
volumes:
- db-data:/var/lib/postgresql/data
environment:
POSTGRES_DB: ${DATABASE_NAME}
POSTGRES_PASSWORD: ${DATABASE_PASSWORD}
ports:
- 5432:5432
expose:
- 5432
healthcheck:
test: [ "CMD", "pg_isready" ]
interval: 10s
timeout: 5s
retries: 5
volumes:
db-data:

BIN
demo/1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

BIN
demo/2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

View File

@@ -1,5 +1,5 @@
-- DROP OWNED BY CURRENT_USER CASCADE;
CREATE TYPE STAT AS ENUM ('waiting', 'p1turn', 'p2turn');
CREATE TYPE STAT AS ENUM ('waiting', 'p1turn', 'p2turn', 'gameover');
CREATE TABLE IF NOT EXISTS players (
id CHAR(16) PRIMARY KEY,

View File

@@ -134,6 +134,10 @@ impl Board {
self
}
pub fn is_game_over(&self) -> bool {
!self.iter().any(|row| row.iter().any(|&cell| cell == 's'))
}
// fn validate_syntax(&self) -> bool {
// self
// .iter()

View File

@@ -15,6 +15,8 @@ pub enum Error {
RoomFull(Option<String>),
#[error("Room not full")]
RoomNotFull,
#[error("GameOver room joined")]
GameOverRoom,
#[error("Already in room")]
AlreadyInRoom,
#[error("Not in room")]
@@ -33,6 +35,7 @@ pub enum Status {
Waiting,
P1Turn,
P2Turn,
GameOver,
}
pub async fn room_if_player_exists(sid: &str, pool: &sqlx::PgPool) -> Result<Option<String>> {
@@ -79,7 +82,7 @@ pub async fn add_room(sid: Sid, pool: &sqlx::PgPool) -> Result<String> {
pub async fn join_room(sid: Sid, code: String, pool: &sqlx::PgPool) -> Result<()> {
let code = code.to_uppercase();
let room = sqlx::query!(
r#"SELECT player1_id, player2_id FROM rooms WHERE code = $1"#,
r#"SELECT player1_id, player2_id, stat AS "stat: Status" FROM rooms WHERE code = $1"#,
code
)
.fetch_one(pool)
@@ -87,26 +90,40 @@ pub async fn join_room(sid: Sid, code: String, pool: &sqlx::PgPool) -> Result<()
let sid = sid.as_str();
// if player is already in room
if [room.player1_id.as_ref(), room.player2_id.as_ref()]
.into_iter()
.flatten()
.any(|x| x == sid)
{
// if game was over, set status to waiting and return
if room.stat == Status::GameOver {
sqlx::query!(
r"UPDATE rooms SET stat = $1 WHERE code = $2",
Status::Waiting as Status,
code
)
.execute(pool)
.await?;
return Ok(());
}
return Err(Error::AlreadyInRoom);
}
if room.stat == Status::GameOver {
return Err(Error::GameOverRoom);
}
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?;
// 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?;
// 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 {
return Err(Error::AlreadyInRoom);
}
}
if let Some(id) = room.player2_id.as_ref() {
if id == sid {
return Err(Error::AlreadyInRoom);
}
}
delete_sid(sid, pool).await?;
let mut txn = pool.begin().await?;
@@ -158,7 +175,7 @@ pub async fn get_game_state(
sid: &str,
room: &str,
pool: &sqlx::PgPool,
) -> Result<(bool, Vec<String>, Vec<String>)> {
) -> Result<(bool, Vec<String>, Vec<String>, bool)> {
let room_details = sqlx::query!(
r#"SELECT player1_id, player2_id, stat AS "stat: Status" FROM rooms WHERE code = $1"#,
room
@@ -188,7 +205,6 @@ pub async fn get_game_state(
.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"#,
@@ -200,6 +216,11 @@ pub async fn get_game_state(
.board
.unwrap()
.into();
let game_over = player_board.is_game_over() || opponent_board.is_game_over();
let player_board: Vec<String> = player_board.mark_redundant().into();
let opponent_board: Vec<String> = opponent_board.mark_redundant().into();
let opponent_board: Vec<String> = opponent_board
.into_iter()
@@ -210,7 +231,7 @@ pub async fn get_game_state(
})
.collect::<Vec<_>>();
Ok((turn, player_board, opponent_board))
Ok((turn, player_board, opponent_board, game_over))
}
pub async fn start(sid: Sid, code: String, pool: &sqlx::PgPool) -> Result<()> {
@@ -247,7 +268,7 @@ pub async fn attack(
sid: Sid,
(i, j): (usize, usize),
pool: &sqlx::PgPool,
) -> Result<(bool, Option<[(usize, usize); 2]>)> {
) -> Result<(bool, Option<[(usize, usize); 2]>, bool)> {
let player = sqlx::query!(r"SELECT room_code FROM players WHERE id = $1", sid.as_str())
.fetch_one(pool)
.await?;
@@ -302,9 +323,23 @@ pub async fn attack(
.execute(&mut *txn)
.await?;
}
let game_over = board.is_game_over();
if game_over {
sqlx::query!(
r#"UPDATE rooms SET stat = $1 WHERE code = $2"#,
Status::GameOver as Status,
player.room_code
)
.execute(&mut *txn)
.await?;
}
txn.commit().await?;
Ok((hit, if hit { board.has_sunk((i, j)) } else { None }))
Ok((
hit,
if hit { board.has_sunk((i, j)) } else { None },
game_over,
))
}
pub async fn update_sid(oldsid: &str, newsid: &str, pool: &sqlx::PgPool) -> Result<()> {

View File

@@ -1,5 +1,6 @@
mod board;
mod game;
use axum::Router;
use board::Board;
use dotenv::dotenv;
@@ -16,7 +17,6 @@ use socketioxide::{
};
use sqlx::PgPool;
use tokio::net::TcpListener;
use tower_http::cors::CorsLayer;
use tracing_subscriber::FmtSubscriber;
#[tokio::main]
@@ -34,9 +34,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let (layer, io) = SocketIo::builder().with_state(pool).build_layer();
io.ns("/", on_connect);
let app = Router::new()
.layer(layer)
.layer(CorsLayer::very_permissive());
let app = Router::new().layer(layer);
let listener = TcpListener::bind("0.0.0.0:3000").await?;
println!("listening on {}", listener.local_addr()?);
@@ -61,7 +60,7 @@ async fn on_connect(socket: SocketRef, Data(auth): Data<AuthPayload>, pool: Stat
socket
.emit(
"restore",
serde_json::json!({"turn": data.0, "player": data.1, "opponent": data.2}),
serde_json::json!({"turn": data.0, "player": data.1, "opponent": data.2, "gameover": data.3}),
)
.unwrap();
socket.join(room.clone()).unwrap();
@@ -119,7 +118,7 @@ async fn on_connect(socket: SocketRef, Data(auth): Data<AuthPayload>, pool: Stat
socket
.emit(
"restore",
serde_json::json!({"turn": data.0, "player": data.1, "opponent": data.2}),
serde_json::json!({"turn": data.0, "player": data.1, "opponent": data.2, "gameover": data.3}),
)
.unwrap();
} else {
@@ -171,7 +170,7 @@ async fn on_connect(socket: SocketRef, Data(auth): Data<AuthPayload>, pool: Stat
socket.on(
"attack",
|socket: SocketRef, Data::<[usize; 2]>([i, j]), pool: State<PgPool>| async move {
let (hit, sunk) = match attack(socket.id, (i, j), &pool).await {
let (hit, sunk, game_over) = match attack(socket.id, (i, j), &pool).await {
Ok(res) => res,
Err(e) => {
tracing::error!("{:?}", e);
@@ -183,7 +182,7 @@ async fn on_connect(socket: SocketRef, Data(auth): Data<AuthPayload>, pool: Stat
.within(socket.rooms().unwrap().first().unwrap().clone())
.emit(
"attacked",
serde_json::json!({"by": socket.id.as_str(), "at": [i, j], "hit": hit, "sunk": sunk}),
serde_json::json!({"by": socket.id.as_str(), "at": [i, j], "hit": hit, "sunk": sunk, "game_over": game_over}),
)
.unwrap();
},