16 Commits

9 changed files with 246 additions and 125 deletions

View File

@@ -0,0 +1,28 @@
name: Cargo Check
on:
push:
paths:
- "src/**"
- "Cargo.toml"
- "Cargo.lock"
- ".rustfmt.toml"
pull_request:
paths:
- "src/**"
- "Cargo.toml"
- "Cargo.lock"
- ".rustfmt.toml"
jobs:
cargo-check:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
- name: Run cargo check
run: cargo check --all-targets

View File

@@ -0,0 +1,30 @@
name: Cargo Format Check
on:
push:
paths:
- "src/**"
- "Cargo.toml"
- "Cargo.lock"
- ".rustfmt.toml"
pull_request:
paths:
- "src/**"
- "Cargo.toml"
- "Cargo.lock"
- ".rustfmt.toml"
jobs:
cargo-fmt:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install Rust toolchain with rustfmt
uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt
- name: Run cargo fmt check
run: cargo fmt --all -- --check

View File

@@ -0,0 +1,39 @@
name: Docker Image CI
on:
workflow_dispatch:
push:
tags:
- 'v*'
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Log in to Gitea Container Registry
uses: docker/login-action@v3
with:
registry: git.abunchofknowitalls.com
username: ${{ secrets.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Extract metadata for Docker
id: meta
uses: docker/metadata-action@v5
with:
images: git.abunchofknowitalls.com/${{ gitea.repository }}
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

6
.gitignore vendored
View File

@@ -30,4 +30,8 @@ target
/node_modules /node_modules
bracket_pairings.txt bracket_pairings.txt
.cursor/
.claude/
.codex/

View File

@@ -1,14 +1,19 @@
FROM rust:1.92 AS build FROM rust:1.92 AS builder
RUN rustup target add x86_64-unknown-linux-musl && \ RUN rustup target add x86_64-unknown-linux-musl && \
apt update && \ apt-get update && \
apt install -y musl-tools musl-dev && \ apt-get install -y --no-install-recommends musl-tools musl-dev ca-certificates && \
update-ca-certificates rm -rf /var/lib/apt/lists/*
COPY ./src ./src WORKDIR /app
COPY ./Cargo.lock . COPY Cargo.toml Cargo.lock ./
COPY ./Cargo.toml . COPY src ./src
RUN cargo build --target x86_64-unknown-linux-musl --release RUN cargo build --target x86_64-unknown-linux-musl --release
ENTRYPOINT ["./target/x86_64-unknown-linux-musl/release/connect4-moderator-server"] FROM scratch AS runtime
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /app/target/x86_64-unknown-linux-musl/release/connect4-moderator-server /connect4-moderator-server
ENTRYPOINT ["/connect4-moderator-server"]

View File

@@ -1,6 +1,6 @@
MIT License MIT License
Copyright (c) 2025 Joshua Higgins Copyright (c) 2025 Joshua Higgins & RPI Minds and Machines
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal

View File

@@ -21,6 +21,8 @@ async def gameloop(socket):
match message[0]: match message[0]:
case "CONNECT": case "CONNECT":
await socket.send("READY") await socket.send("READY")
case "RECONNECT":
await socket.send("READY")
case "GAME": case "GAME":
if message[1] == "START": if message[1] == "START":

View File

@@ -305,33 +305,47 @@ async fn handle_connection(
// Remove and terminate any matches // Remove and terminate any matches
// We may not be a client disconnecting, do this check // We may not be a client disconnecting, do this check
let clients_guard = sd.clients.read().await; if let Some(username) = sd.usernames.read().await.get(&addr).cloned() {
for client in clients_guard.values() { let tournament_guard = sd.tournament.read().await;
let client = client.read().await; let client = sd.clients.read().await.get(&username).cloned().unwrap();
if client.addr == addr { let mut client = client.write().await;
let username = client.username.clone(); client.ready = false;
let tournament_guard = sd.tournament.read().await;
if client.current_match.is_some() { if client.current_match.is_some() {
sd.disconnected_clients.write().await.push(username.clone()); let current_match =
} else if tournament_guard.is_some() { sd.matches.read().await.get(&client.current_match.unwrap()).cloned().unwrap();
let tourney = tournament_guard.clone().unwrap(); let current_match = current_match.read().await;
if tourney.read().await.contains_player(username.clone()) { if let Some(wait_thread) = &current_match.wait_thread {
sd.disconnected_clients.write().await.push(username.clone()); wait_thread.abort();
}
} }
drop(client); if let Some(timeout_thread) = &current_match.timeout_thread {
drop(clients_guard); timeout_thread.abort();
}
if current_match.demo_mode {
sd.matches.write().await.remove(&current_match.id);
sd.broadcast(&format!("GAME:{}:TERMINATED", current_match.id)).await;
sd.clients.write().await.remove(&username);
sd.broadcast(&format!("DISCONNECT:{}", username)).await;
} else {
sd.disconnected_clients.write().await.push(username.clone());
}
} else if tournament_guard.is_some() {
let tourney = tournament_guard.clone().unwrap();
if tourney.read().await.contains_player(username.clone()) {
sd.disconnected_clients.write().await.push(username.clone());
} else {
sd.clients.write().await.remove(&username);
sd.broadcast(&format!("DISCONNECT:{}", username)).await;
}
} else {
sd.clients.write().await.remove(&username); sd.clients.write().await.remove(&username);
sd.usernames.write().await.remove(&addr);
sd.broadcast(&format!("DISCONNECT:{}", username)).await; sd.broadcast(&format!("DISCONNECT:{}", username)).await;
break;
} }
} }
sd.usernames.write().await.remove(&addr);
sd.observers.write().await.remove(&addr); sd.observers.write().await.remove(&addr);
let mut admin_guard = sd.admin.write().await; let mut admin_guard = sd.admin.write().await;

View File

@@ -63,11 +63,7 @@ impl Server {
))); )));
} }
let mut reconnecting = false; let reconnecting = self.disconnected_clients.read().await.contains(&requested_username);
let disconnected_guard = self.disconnected_clients.read().await;
if disconnected_guard.contains(&requested_username) {
reconnecting = true;
}
let clients_guard = self.clients.read().await; let clients_guard = self.clients.read().await;
let existing_client = clients_guard.get(&requested_username).cloned(); let existing_client = clients_guard.get(&requested_username).cloned();
@@ -105,42 +101,24 @@ impl Server {
let mut client = client_guard.write().await; let mut client = client_guard.write().await;
client.addr = addr; client.addr = addr;
client.connection = tx.clone(); client.connection = tx.clone();
// I don't think this will fail
let match_id = client.current_match.unwrap();
let client_color = client.color;
drop(client); if let Some(current_match_id) = client.current_match {
let current_match = self.matches.read().await.get(&current_match_id).cloned().unwrap();
let matches_guard = self.matches.read().await; let mut current_match = current_match.write().await;
let mut the_match = matches_guard.get(&match_id).unwrap().write().await; current_match.ledger.clear();
if the_match.demo_mode { current_match.board = vec![vec![Color::None; 6]; 7];
drop(the_match); let opponent_username = if current_match.player1 == requested_username {
drop(matches_guard); current_match.player2.clone()
self.terminate_match(match_id).await;
return Ok(());
} else {
the_match.ledger.clear();
the_match.board = vec![vec![Color::None; 6]; 7];
let opponent_username = if the_match.player1 == requested_username {
the_match.player2.clone()
} else { } else {
the_match.player1.clone() current_match.player1.clone()
}; };
if the_match.wait_thread.is_some() {
the_match.wait_thread.as_ref().unwrap().abort();
}
if the_match.timeout_thread.is_some() {
the_match.timeout_thread.as_ref().unwrap().abort();
}
let clients_guard = self.clients.read().await; let clients_guard = self.clients.read().await;
let opponent = clients_guard.get(&opponent_username).unwrap().read().await; let opponent = clients_guard.get(&opponent_username).unwrap().read().await;
let _ = send(&opponent.connection, "GAME:TERMINATED"); let _ = send(&opponent.connection, "GAME:TERMINATED");
let _ = send( let _ = send(
&tx, &tx,
&format!("GAME:START:{}", bool::from(client_color) as u8), &format!("GAME:START:{}", bool::from(client.color) as u8),
); );
let _ = send( let _ = send(
&opponent.connection, &opponent.connection,
@@ -170,27 +148,38 @@ impl Server {
if let Some(client_guard) = found_client { if let Some(client_guard) = found_client {
self.disconnected_clients.write().await.retain(|name| name != &requested_username); self.disconnected_clients.write().await.retain(|name| name != &requested_username);
self.usernames.write().await.insert(addr, requested_username.clone());
let mut client = client_guard.write().await; let mut client = client_guard.write().await;
client.addr = addr; client.addr = addr;
client.connection = tx.clone(); client.connection = tx.clone();
let _ = send(&tx, "RECONNECT:ACK"); if let Some(current_match_id) = client.current_match {
let matches_guard = self.matches.read().await;
let the_match = matches_guard.get(&current_match_id).unwrap().read().await;
let matches_guard = self.matches.read().await; let last = the_match.ledger.last();
let the_match = matches_guard.get(&client.current_match.unwrap()).unwrap().read().await; if last.is_some() && last.unwrap().0 != client.color {
let _ = send(
&tx,
&format!("OPPONENT:{}", the_match.ledger.last().unwrap().1),
);
} else if last.is_none() && client.color == Color::Red {
let _ = send(&tx, "GAME:START:1");
}
} else {
// Clear they're state just in case, even if it's not terminated
let _ = send(&tx, "GAME:TERMINATED");
}
let last = the_match.ledger.last(); let tournament = self.tournament.read().await.as_ref().cloned();
if last.is_some() && last.unwrap().0 != client.color { if let Some(tourney) = tournament {
let _ = send( let tourney = tourney.read().await;
&tx, if !tourney.contains_player(requested_username) {
&format!("OPPONENT:{}", the_match.ledger.last().unwrap().1), let _ = send(&tx, "RECONNECT:ACK");
); }
} }
} else { } else {
return Err(anyhow::anyhow!(format!( return self.handle_connect_cmd(addr, tx, requested_username).await;
"ERROR:INVALID:RECONNECT:{}",
requested_username
)));
} }
Ok(()) Ok(())
@@ -266,7 +255,8 @@ impl Server {
let username = username.unwrap(); let username = username.unwrap();
if clients_guard.get(&username).unwrap().read().await.ready { if clients_guard.get(&username).unwrap().read().await.ready {
return Err(anyhow::anyhow!("ERROR:INVALID:READY")); let _ = send(&tx, "READY:ACK");
return Ok(());
} }
let mut client = clients_guard.get(&username).unwrap().write().await; let mut client = clients_guard.get(&username).unwrap().write().await;
@@ -281,53 +271,55 @@ impl Server {
let mut client = clients_guard.get(&username).unwrap().write().await; let mut client = clients_guard.get(&username).unwrap().write().await;
let mut opponent = clients_guard.get(&opponent_username).unwrap().write().await; let mut opponent = clients_guard.get(&opponent_username).unwrap().write().await;
let match_id: u32 = gen_match_id(&self.matches).await; if opponent.ready {
let new_match = Arc::new(RwLock::new(Match::new( let match_id: u32 = gen_match_id(&self.matches).await;
match_id, let new_match = Arc::new(RwLock::new(Match::new(
username.clone(), match_id,
opponent_username, username.clone(),
false, opponent_username,
))); false,
self.matches.write().await.insert(match_id, new_match.clone()); )));
let match_guard = new_match.read().await; self.matches.write().await.insert(match_id, new_match.clone());
self let match_guard = new_match.read().await;
.broadcast(&format!( self
"GAME:START:{},{},{}", .broadcast(&format!(
match_id, match_guard.player1, match_guard.player2 "GAME:START:{},{},{}",
)) match_id, match_guard.player1, match_guard.player2
.await; ))
drop(match_guard); .await;
drop(match_guard);
client.ready = false; client.ready = false;
client.current_match = Some(match_id); client.current_match = Some(match_id);
client.color = if new_match.read().await.player1 == username { client.color = if new_match.read().await.player1 == username {
let _ = send(&tx, "GAME:START:1"); let _ = send(&tx, "GAME:START:1");
let _ = send(&opponent.connection, "GAME:START:0"); let _ = send(&opponent.connection, "GAME:START:0");
Color::Red Color::Red
} else { } else {
let _ = send(&tx, "GAME:START:0"); let _ = send(&tx, "GAME:START:0");
let _ = send(&opponent.connection, "GAME:START:1"); let _ = send(&opponent.connection, "GAME:START:1");
Color::Yellow Color::Yellow
}; };
opponent.ready = false; opponent.ready = false;
opponent.current_match = Some(match_id); opponent.current_match = Some(match_id);
opponent.color = !client.color; opponent.color = !client.color;
let opponent_username = opponent.username.clone(); let opponent_username = opponent.username.clone();
self self
.reservations .reservations
.write() .write()
.await .await
.retain(|(p1, p2)| !(p1 == &client.username && p2 == &opponent.username)); .retain(|(p1, p2)| !(p1 == &client.username && p2 == &opponent.username));
drop(opponent); drop(opponent);
drop(client); drop(client);
drop(clients_guard); drop(clients_guard);
self.broadcast(&format!("READY:{}:{}", username, false)).await; self.broadcast(&format!("READY:{}:{}", username, false)).await;
self.broadcast(&format!("READY:{}:{}", opponent_username, false)).await; self.broadcast(&format!("READY:{}:{}", opponent_username, false)).await;
return Ok(()); return Ok(());
}
} }
let clients_guard = self.clients.read().await; let clients_guard = self.clients.read().await;
@@ -1384,18 +1376,25 @@ impl Server {
self.broadcast(&format!("GAME:{}:TERMINATED", the_match.id)).await; self.broadcast(&format!("GAME:{}:TERMINATED", the_match.id)).await;
let clients_guard = self.clients.read().await; let clients_guard = self.clients.read().await;
if the_match.player1 != SERVER_PLAYER_USERNAME.to_string() { if the_match.player1 != SERVER_PLAYER_USERNAME.to_string() {
let mut player1 = clients_guard.get(&the_match.player1).unwrap().write().await; let player1 = clients_guard.get(&the_match.player1).cloned();
let _ = send(&player1.connection, "GAME:TERMINATED"); if let Some(player1) = player1 {
player1.current_match = None; let mut player1 = player1.write().await;
player1.color = Color::None; let _ = send(&player1.connection, "GAME:TERMINATED");
player1.current_match = None;
player1.color = Color::None;
}
} }
if the_match.player2 != SERVER_PLAYER_USERNAME.to_string() { if the_match.player2 != SERVER_PLAYER_USERNAME.to_string() {
let mut player2 = clients_guard.get(&the_match.player2).unwrap().write().await; let player2 = clients_guard.get(&the_match.player2).cloned();
let _ = send(&player2.connection, "GAME:TERMINATED"); if let Some(player2) = player2 {
player2.current_match = None; let mut player2 = player2.write().await;
player2.color = Color::None; let _ = send(&player2.connection, "GAME:TERMINATED");
player2.current_match = None;
player2.color = Color::None;
}
} }
drop(clients_guard); drop(clients_guard);