client/server
This commit is contained in:
Generated
-6128
File diff suppressed because it is too large
Load Diff
+3
-17
@@ -1,17 +1,3 @@
|
|||||||
[package]
|
[workspace]
|
||||||
name = "audiopoker"
|
resolver = "2"
|
||||||
version = "0.1.0"
|
members = ["core", "client", "server"]
|
||||||
edition = "2024"
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
bevy = { version = "0.19", features = ["webgl2"] }
|
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
|
||||||
serde_json = "1.0"
|
|
||||||
wasm-bindgen = "0.2"
|
|
||||||
web-sys = { version = "0.3", features = ["SpeechRecognition", "SpeechSynthesis", "SpeechSynthesisUtterance", "Window", "HtmlInputElement", "console"] }
|
|
||||||
js-sys = "0.3"
|
|
||||||
futures = "0.3"
|
|
||||||
rand = "0.8.5"
|
|
||||||
|
|
||||||
[target.wasm]
|
|
||||||
edition = 2024
|
|
||||||
|
|||||||
@@ -25,10 +25,18 @@ Ein barrierefreies Multiplayer-Pokergame für blinde Spieler, das primär über
|
|||||||
- [x] **Audio-Grundlagen**: `audio::tts` ruft die Web Speech API korrekt an (`SpeechSynthesisUtterance`); auf nativen Targets Dummy-Ausgabe für Tests.
|
- [x] **Audio-Grundlagen**: `audio::tts` ruft die Web Speech API korrekt an (`SpeechSynthesisUtterance`); auf nativen Targets Dummy-Ausgabe für Tests.
|
||||||
- [x] Bevy-`App`-Grundgerüst aufgesetzt: `AudiopokerPlugin` (`src/game_plugin.rs`) verdrahtet `core::game`/`core::logic` und `audio` über Bevy-Messages (`Announce`, `PlaySfx`) mit `Startup`/`Update`-Systemen. API-Stand: Bevy 0.19 (`Message`/`MessageWriter`/`MessageReader`/`add_message`, seit 0.17 getrennt von Observer-`Event`s). **Bitte lokal mit `cargo check`/`cargo run` verifizieren**, da hier kein zu Bevy 0.19 passender Compiler zur Verfügung stand.
|
- [x] Bevy-`App`-Grundgerüst aufgesetzt: `AudiopokerPlugin` (`src/game_plugin.rs`) verdrahtet `core::game`/`core::logic` und `audio` über Bevy-Messages (`Announce`, `PlaySfx`) mit `Startup`/`Update`-Systemen. API-Stand: Bevy 0.19 (`Message`/`MessageWriter`/`MessageReader`/`add_message`, seit 0.17 getrennt von Observer-`Event`s). **Bitte lokal mit `cargo check`/`cargo run` verifizieren**, da hier kein zu Bevy 0.19 passender Compiler zur Verfügung stand.
|
||||||
|
|
||||||
|
## Projektstruktur (seit Phase 2: Cargo-Workspace)
|
||||||
|
- `core/` (Lib `audiopoker_core`): `logic`, `game`, `network` - unverändert engine-unabhängig, jetzt von Client *und* Server genutzt.
|
||||||
|
- `client/` (Bin `audiopoker_client`): Bevy/Wasm, bisheriger Inhalt von `src/`.
|
||||||
|
- `server/` (Bin `audiopoker_server`): neuer nativer WebSocket-Server (Tokio + tokio-tungstenite).
|
||||||
|
|
||||||
### Phase 2: Networking & Multiplayer
|
### Phase 2: Networking & Multiplayer
|
||||||
- [ ] Server-Architektur für Spielräume und Spielerverwaltung aufbauen.
|
- [x] **Server-Architektur**: `server/src/table.rs` (`TableActor`, Actor-Modell: ein Tokio-Task pro Tisch, kein geteilter Mutex für den Spielzustand) + `server/src/lobby.rs` (Tisch-Registry, legt Tische on-demand per Namen an). `server/src/main.rs` nimmt WebSocket-Verbindungen an (`tokio-tungstenite`) und verbindet sie mit dem passenden Tisch.
|
||||||
- [ ] Netzwerkprotokoll für Aktionen (Deal, Bet, Fold) definieren.
|
- [x] **Netzwerkprotokoll**: `core::network::{ClientMessage, ServerMessage}` neu entworfen, sodass der Server dieselben Announce-/PlaySfx-"Häppchen" verschickt, die der lokale Client schon per TTS/SFX verarbeitet (`GameManager::take_action` liefert sie direkt). Erhöhungsbeträge werden bewusst serverseitig festgelegt (kein clientseitig frei wählbarer Raise-Betrag).
|
||||||
- [ ] Multiplayer-Lobby und Tischsuche implementieren.
|
- [x] **Multiplayer-Lobby/Tischsuche (einfache Variante)**: Tischname im `JoinTable` dient als Suchbegriff; unbekannte Namen legen automatisch einen neuen Tisch an. Kein Auflisten aller offenen Tische (`ListTables`) - falls gewünscht, wäre das die nächste Ausbaustufe.
|
||||||
|
- **Getestet**: `cargo test -p audiopoker_core` (10 Tests) und ein Ende-zu-Ende-Smoke-Test mit zwei echten WebSocket-Clients (Python) gegen den laufenden `audiopoker_server` liefen hier erfolgreich durch (kompletter Hand-Durchlauf Pre-Flop bis Showdown, korrekte Sieger-Ermittlung).
|
||||||
|
- [ ] **Client-Anbindung an den Server fehlt noch**: Der Bevy-Client (`client/`) spielt aktuell weiterhin nur lokal/offline (siehe `game_plugin.rs`, 2 fest verdrahtete Spieler). Die Anbindung per `web_sys::WebSocket` (Feature ist in `client/Cargo.toml` schon vorbereitet) an `audiopoker_server` ist der nächste sinnvolle Schritt.
|
||||||
|
- [ ] Tisch-Übersicht/`ListTables` für echte Tischsuche (aktuell nur "Name kennen und beitreten").
|
||||||
|
|
||||||
### Phase 3: Audio Experience & UI
|
### Phase 3: Audio Experience & UI
|
||||||
- [x] **TTS-Logik verfeinert**: `GameManager::take_action` erzeugt kontextuelle Ansagen (Aktion des Spielers, Rundenwechsel inkl. aufgedeckter Community Cards, Showdown-Gewinner mit Hand-Kategorie, "wer ist am Zug"). Karten werden über `Card`/`Suit`-`Display` sprachfreundlich ausgegeben ("König Herz").
|
- [x] **TTS-Logik verfeinert**: `GameManager::take_action` erzeugt kontextuelle Ansagen (Aktion des Spielers, Rundenwechsel inkl. aufgedeckter Community Cards, Showdown-Gewinner mit Hand-Kategorie, "wer ist am Zug"). Karten werden über `Card`/`Suit`-`Display` sprachfreundlich ausgegeben ("König Herz").
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
[package]
|
||||||
|
name = "audiopoker_client"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
audiopoker_core = { path = "../core" }
|
||||||
|
bevy = { version = "0.19", features = ["webgl2"] }
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
serde_json = "1.0"
|
||||||
|
# wasm-bindgen/web-sys/js-sys/futures: aktuell nur für audio::tts (Web Speech
|
||||||
|
# API) genutzt. Werden zusätzlich gebraucht, sobald der Client per
|
||||||
|
# web_sys::WebSocket an den `server` (siehe ../server) angebunden wird -
|
||||||
|
# das ist noch offen, siehe PLAN.md.
|
||||||
|
wasm-bindgen = "0.2"
|
||||||
|
web-sys = { version = "0.3", features = ["SpeechRecognition", "SpeechSynthesis", "SpeechSynthesisUtterance", "Window", "HtmlInputElement", "console", "WebSocket", "MessageEvent"] }
|
||||||
|
js-sys = "0.3"
|
||||||
|
futures = "0.3"
|
||||||
|
rand = "0.8.5"
|
||||||
@@ -13,8 +13,8 @@
|
|||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
|
|
||||||
use crate::audio::AudioEngine;
|
use crate::audio::AudioEngine;
|
||||||
use crate::core::game::{BettingAction, GameManager, GameState, Round};
|
use audiopoker_core::game::{BettingAction, GameManager, GameState, Round, RAISE_INCREMENT};
|
||||||
use crate::core::logic::Deck;
|
use audiopoker_core::logic::Deck;
|
||||||
|
|
||||||
/// Bevy-Resource-Wrapper um den Engine-unabhängigen Spielzustand.
|
/// Bevy-Resource-Wrapper um den Engine-unabhängigen Spielzustand.
|
||||||
/// Bewusst als Newtype, damit `core::game` selbst keine Bevy-Abhängigkeit braucht.
|
/// Bewusst als Newtype, damit `core::game` selbst keine Bevy-Abhängigkeit braucht.
|
||||||
@@ -33,8 +33,6 @@ pub struct ActionCursor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ACTION_LABELS: [&str; 3] = ["Fold", "Call/Check", "Raise"];
|
const ACTION_LABELS: [&str; 3] = ["Fold", "Call/Check", "Raise"];
|
||||||
/// Fester Erhöhungsbetrag für "Raise" (vereinfachtes Modell, kein Pot-Limit/No-Limit-Sizing).
|
|
||||||
const RAISE_INCREMENT: u64 = 20;
|
|
||||||
|
|
||||||
/// Message: Ein Text soll per TTS angesagt werden (z. B. "Spieler 1 setzt 50").
|
/// Message: Ein Text soll per TTS angesagt werden (z. B. "Spieler 1 setzt 50").
|
||||||
#[derive(Message, Debug, Clone)]
|
#[derive(Message, Debug, Clone)]
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
mod audio;
|
mod audio;
|
||||||
mod core;
|
|
||||||
mod game_plugin;
|
mod game_plugin;
|
||||||
|
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
[package]
|
||||||
|
name = "audiopoker_core"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
rand = "0.8.5"
|
||||||
@@ -5,8 +5,8 @@
|
|||||||
//! Re-Raises). Das reicht für eine barrierefreie MVP-Demo, ist aber keine
|
//! Re-Raises). Das reicht für eine barrierefreie MVP-Demo, ist aber keine
|
||||||
//! vollständige Tournament-taugliche Poker-Regelimplementierung.
|
//! vollständige Tournament-taugliche Poker-Regelimplementierung.
|
||||||
|
|
||||||
use crate::core::logic::card::Card;
|
use crate::logic::card::Card;
|
||||||
use crate::core::logic::hand::Hand;
|
use crate::logic::hand::Hand;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum Round {
|
pub enum Round {
|
||||||
@@ -126,6 +126,10 @@ impl GameState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fester Erhöhungsbetrag für "Raise" (vereinfachtes Modell, kein Pot-Limit/No-Limit-Sizing).
|
||||||
|
/// Zentral definiert, damit Client und Server garantiert denselben Wert verwenden.
|
||||||
|
pub const RAISE_INCREMENT: u64 = 20;
|
||||||
|
|
||||||
/// Wett-Aktionen, wie sie über `core::network` vom Client kommen (Deal/Bet/Fold, siehe PLAN.md).
|
/// Wett-Aktionen, wie sie über `core::network` vom Client kommen (Deal/Bet/Fold, siehe PLAN.md).
|
||||||
#[derive(Debug, Clone, Copy)]
|
#[derive(Debug, Clone, Copy)]
|
||||||
pub enum BettingAction {
|
pub enum BettingAction {
|
||||||
@@ -363,11 +367,11 @@ mod tests {
|
|||||||
fn volle_strasse_lässt_runde_automatisch_weiterlaufen() {
|
fn volle_strasse_lässt_runde_automatisch_weiterlaufen() {
|
||||||
let mut state = GameManager::start_new_game(vec!["Anna".into(), "Ben".into(), "Cara".into()]);
|
let mut state = GameManager::start_new_game(vec!["Anna".into(), "Ben".into(), "Cara".into()]);
|
||||||
state.community_cards = vec![
|
state.community_cards = vec![
|
||||||
Card::new(crate::core::logic::card::Suit::Hearts, 2),
|
Card::new(crate::logic::card::Suit::Hearts, 2),
|
||||||
Card::new(crate::core::logic::card::Suit::Hearts, 3),
|
Card::new(crate::logic::card::Suit::Hearts, 3),
|
||||||
Card::new(crate::core::logic::card::Suit::Hearts, 4),
|
Card::new(crate::logic::card::Suit::Hearts, 4),
|
||||||
Card::new(crate::core::logic::card::Suit::Hearts, 5),
|
Card::new(crate::logic::card::Suit::Hearts, 5),
|
||||||
Card::new(crate::core::logic::card::Suit::Hearts, 9),
|
Card::new(crate::logic::card::Suit::Hearts, 9),
|
||||||
];
|
];
|
||||||
|
|
||||||
GameManager::take_action(&mut state, 0, BettingAction::Check);
|
GameManager::take_action(&mut state, 0, BettingAction::Check);
|
||||||
@@ -380,7 +384,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn showdown_kuert_besseren_spieler_zum_gewinner() {
|
fn showdown_kuert_besseren_spieler_zum_gewinner() {
|
||||||
use crate::core::logic::card::Suit;
|
use crate::logic::card::Suit;
|
||||||
let mut state = GameManager::start_new_game(vec!["Anna".into(), "Ben".into()]);
|
let mut state = GameManager::start_new_game(vec!["Anna".into(), "Ben".into()]);
|
||||||
state.current_round = Round::River;
|
state.current_round = Round::River;
|
||||||
state.pot = 200;
|
state.pot = 200;
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
//! `audiopoker_core`: reine, engine-unabhängige Spiellogik (siehe PLAN.md).
|
||||||
|
//! Wird sowohl vom Bevy/Wasm-`client` als auch vom nativen `server` genutzt.
|
||||||
|
|
||||||
|
pub mod logic;
|
||||||
|
pub mod game;
|
||||||
|
pub mod network;
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use crate::core::logic::card::Card;
|
use crate::logic::card::Card;
|
||||||
|
|
||||||
/// Kategorie einer Poker-Hand, aufsteigend nach Stärke sortiert.
|
/// Kategorie einer Poker-Hand, aufsteigend nach Stärke sortiert.
|
||||||
/// Die Reihenfolge der Varianten wird für den Vergleich (`Ord`) genutzt.
|
/// Die Reihenfolge der Varianten wird für den Vergleich (`Ord`) genutzt.
|
||||||
@@ -166,7 +166,7 @@ fn score_five(cards: &[Card; 5]) -> HandScore {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::core::logic::card::Suit;
|
use crate::logic::card::Suit;
|
||||||
|
|
||||||
fn c(suit: Suit, rank: u8) -> Card {
|
fn c(suit: Suit, rank: u8) -> Card {
|
||||||
Card::new(suit, rank)
|
Card::new(suit, rank)
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
//! Netzwerkprotokoll für die Multiplayer-Synchronisation (siehe PLAN.md, Phase 2).
|
||||||
|
//!
|
||||||
|
//! Design-Entscheidung: Der Server ist die einzige Quelle der Wahrheit
|
||||||
|
//! (führt `GameManager::take_action` autoritativ aus) und schickt dieselben
|
||||||
|
//! "Announce"/"PlaySfx"-Häppchen an die Clients, die der lokale
|
||||||
|
//! `game_plugin`-Code bereits als `Announce`/`PlaySfx`-Bevy-Messages kennt.
|
||||||
|
//! Der Client muss also keine eigene Spiellogik nachbilden, sondern reicht
|
||||||
|
//! ankommende `ServerMessage`s nur an TTS/SFX weiter (bzw. zeigt Fehler an).
|
||||||
|
//!
|
||||||
|
//! Sicherheitsaspekt: `Raise` enthält bewusst keinen vom Client gewählten
|
||||||
|
//! Betrag - der Server bestimmt die Höhe selbst (`RAISE_INCREMENT`), damit
|
||||||
|
//! ein Client nicht durch manipulierte Nachrichten beliebige Beträge setzen kann.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::logic::card::Card;
|
||||||
|
|
||||||
|
/// Nachrichten, die ein Client an den Server schickt.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub enum ClientMessage {
|
||||||
|
/// Tritt einem (ggf. neu anzulegenden) Tisch bei. `table_name` ermöglicht
|
||||||
|
/// die einfache "Tischsuche" aus PLAN.md: bekannte Namen treffen auf
|
||||||
|
/// denselben Tisch, unbekannte legen einen neuen an.
|
||||||
|
JoinTable { table_name: String, player_name: String },
|
||||||
|
Fold,
|
||||||
|
Check,
|
||||||
|
Call,
|
||||||
|
Raise,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Nachrichten, die der Server an (einen oder alle) Clients eines Tisches sendet.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub enum ServerMessage {
|
||||||
|
/// Antwort auf `JoinTable`: dem Client wurde diese Spieler-ID zugewiesen.
|
||||||
|
Welcome { player_id: u32 },
|
||||||
|
/// Textansage fürs TTS - identisch zum lokalen `Announce` in `game_plugin`.
|
||||||
|
Announce(String),
|
||||||
|
/// Soundeffekt-Cue fürs SFX - identisch zum lokalen `PlaySfx`.
|
||||||
|
PlaySfx(String),
|
||||||
|
/// Private Hole Cards; wird nur an den jeweiligen Spieler geschickt, nie gebroadcastet.
|
||||||
|
HoleCards(Vec<Card>),
|
||||||
|
/// Aktuell aufgedeckte Community Cards (kumulativ je nach Runde).
|
||||||
|
CommunityCards(Vec<Card>),
|
||||||
|
/// Wer als Nächstes agieren muss.
|
||||||
|
TurnToAct { player_id: u32 },
|
||||||
|
/// Aktion war ungültig (z. B. nicht am Zug, Tisch schon voll) oder ein
|
||||||
|
/// sonstiger Fehler ist aufgetreten.
|
||||||
|
Error(String),
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
[package]
|
||||||
|
name = "audiopoker_server"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
audiopoker_core = { path = "../core" }
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
serde_json = "1.0"
|
||||||
|
tokio = { version = "1", features = ["rt-multi-thread", "net", "macros", "sync", "io-util"] }
|
||||||
|
tokio-tungstenite = "0.24"
|
||||||
|
futures-util = "0.3"
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
//! Verwaltet alle aktiven Tische. Einfache Umsetzung der "Tischsuche" aus
|
||||||
|
//! PLAN.md: Der Tischname selbst ist der Suchbegriff - unbekannte Namen
|
||||||
|
//! legen automatisch einen neuen Tisch (mit eigenem Tokio-Task) an.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
|
use crate::table::{TableActor, TableCommand};
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct Lobby {
|
||||||
|
tables: Mutex<HashMap<String, mpsc::UnboundedSender<TableCommand>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Lobby {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gibt den Befehls-Sender für den genannten Tisch zurück; legt bei
|
||||||
|
/// Bedarf einen neuen Tisch samt eigenem Hintergrund-Task an.
|
||||||
|
pub fn table_sender(&self, table_name: &str) -> mpsc::UnboundedSender<TableCommand> {
|
||||||
|
let mut tables = self.tables.lock().expect("Lobby-Mutex vergiftet");
|
||||||
|
if let Some(tx) = tables.get(table_name) {
|
||||||
|
return tx.clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
let (tx, rx) = mpsc::unbounded_channel();
|
||||||
|
let actor = TableActor::new(table_name.to_string());
|
||||||
|
tokio::spawn(actor.run(rx));
|
||||||
|
tables.insert(table_name.to_string(), tx.clone());
|
||||||
|
tx
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
//! Nativer WebSocket-Server für Audiopoker (siehe PLAN.md, Phase 2).
|
||||||
|
//! Führt die Spiellogik autoritativ aus (`audiopoker_core::game`) und
|
||||||
|
//! synchronisiert alle verbundenen Clients über `audiopoker_core::network`.
|
||||||
|
|
||||||
|
mod lobby;
|
||||||
|
mod table;
|
||||||
|
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use futures_util::stream::{SplitSink, SplitStream};
|
||||||
|
use futures_util::{SinkExt, StreamExt};
|
||||||
|
use tokio::net::{TcpListener, TcpStream};
|
||||||
|
use tokio::sync::{mpsc, oneshot};
|
||||||
|
use tokio_tungstenite::tungstenite::Message;
|
||||||
|
use tokio_tungstenite::WebSocketStream;
|
||||||
|
|
||||||
|
use audiopoker_core::network::{ClientMessage, ServerMessage};
|
||||||
|
use lobby::Lobby;
|
||||||
|
use table::TableCommand;
|
||||||
|
|
||||||
|
type WsSink = SplitSink<WebSocketStream<TcpStream>, Message>;
|
||||||
|
type WsStream = SplitStream<WebSocketStream<TcpStream>>;
|
||||||
|
|
||||||
|
const DEFAULT_ADDR: &str = "0.0.0.0:9001";
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() {
|
||||||
|
let addr = std::env::var("AUDIOPOKER_ADDR").unwrap_or_else(|_| DEFAULT_ADDR.to_string());
|
||||||
|
let listener = TcpListener::bind(&addr)
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|err| panic!("Konnte nicht auf {addr} binden: {err}"));
|
||||||
|
println!("Audiopoker-Server lauscht auf ws://{addr}");
|
||||||
|
|
||||||
|
let lobby = Arc::new(Lobby::new());
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let (stream, peer) = match listener.accept().await {
|
||||||
|
Ok(conn) => conn,
|
||||||
|
Err(err) => {
|
||||||
|
eprintln!("Fehler beim Annehmen einer Verbindung: {err}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let lobby = Arc::clone(&lobby);
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if let Err(err) = handle_connection(stream, peer, lobby).await {
|
||||||
|
eprintln!("Verbindung zu {peer} beendet: {err}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_connection(
|
||||||
|
stream: TcpStream,
|
||||||
|
peer: SocketAddr,
|
||||||
|
lobby: Arc<Lobby>,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
let ws_stream = tokio_tungstenite::accept_async(stream).await?;
|
||||||
|
println!("Neue Verbindung von {peer}");
|
||||||
|
let (mut ws_sink, mut ws_stream) = ws_stream.split();
|
||||||
|
|
||||||
|
let Some((table_name, player_name)) = await_join(&mut ws_sink, &mut ws_stream).await? else {
|
||||||
|
return Ok(()); // Verbindung wurde beendet, bevor ein Tisch gewählt wurde.
|
||||||
|
};
|
||||||
|
|
||||||
|
let table_tx = lobby.table_sender(&table_name);
|
||||||
|
let (reply_tx, mut reply_rx) = mpsc::unbounded_channel::<ServerMessage>();
|
||||||
|
let (assigned_id_tx, assigned_id_rx) = oneshot::channel();
|
||||||
|
|
||||||
|
table_tx.send(TableCommand::Join {
|
||||||
|
player_name,
|
||||||
|
reply_tx,
|
||||||
|
assigned_id_tx,
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let player_id = match assigned_id_rx.await? {
|
||||||
|
Ok(id) => id,
|
||||||
|
Err(reason) => {
|
||||||
|
send_error(&mut ws_sink, &reason).await;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
outgoing = reply_rx.recv() => {
|
||||||
|
match outgoing {
|
||||||
|
Some(message) => send_message(&mut ws_sink, &message).await?,
|
||||||
|
None => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
incoming = ws_stream.next() => {
|
||||||
|
match incoming {
|
||||||
|
Some(Ok(Message::Text(text))) => {
|
||||||
|
match serde_json::from_str::<ClientMessage>(&text) {
|
||||||
|
Ok(client_msg) => {
|
||||||
|
table_tx.send(TableCommand::Action { player_id, message: client_msg })?;
|
||||||
|
}
|
||||||
|
Err(err) => send_error(&mut ws_sink, &format!("Ungültige Nachricht: {err}")).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(Ok(Message::Close(_))) | None => break,
|
||||||
|
Some(Ok(_)) => {} // Ping/Pong/Binary werden ignoriert.
|
||||||
|
Some(Err(err)) => {
|
||||||
|
eprintln!("WebSocket-Fehler von {peer}: {err}");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = table_tx.send(TableCommand::Disconnected { player_id });
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wartet auf die erste Client-Nachricht, die ein `JoinTable` sein muss.
|
||||||
|
/// Alles andere wird mit einer Fehlermeldung beantwortet, ohne die
|
||||||
|
/// Verbindung zu schließen (der Client darf es erneut versuchen).
|
||||||
|
async fn await_join(
|
||||||
|
ws_sink: &mut WsSink,
|
||||||
|
ws_stream: &mut WsStream,
|
||||||
|
) -> Result<Option<(String, String)>, Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
loop {
|
||||||
|
let Some(msg) = ws_stream.next().await else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
match msg? {
|
||||||
|
Message::Text(text) => match serde_json::from_str::<ClientMessage>(&text) {
|
||||||
|
Ok(ClientMessage::JoinTable { table_name, player_name }) => {
|
||||||
|
return Ok(Some((table_name, player_name)));
|
||||||
|
}
|
||||||
|
Ok(_) => send_error(ws_sink, "Bitte zuerst mit JoinTable einem Tisch beitreten.").await,
|
||||||
|
Err(err) => send_error(ws_sink, &format!("Ungültige Nachricht: {err}")).await,
|
||||||
|
},
|
||||||
|
Message::Close(_) => return Ok(None),
|
||||||
|
_ => {} // Ping/Pong/Binary werden ignoriert.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn send_message(
|
||||||
|
ws_sink: &mut WsSink,
|
||||||
|
message: &ServerMessage,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
let text = serde_json::to_string(message)?;
|
||||||
|
ws_sink.send(Message::Text(text)).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn send_error(ws_sink: &mut WsSink, reason: &str) {
|
||||||
|
let _ = send_message(ws_sink, &ServerMessage::Error(reason.to_string())).await;
|
||||||
|
}
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
//! Ein `TableActor` läuft als eigener Tokio-Task und besitzt exklusiv den
|
||||||
|
//! `GameState` für genau einen Tisch (Actor-Modell: keine geteilten Mutexe
|
||||||
|
//! nötig, alle Zugriffe laufen sequenziell über einen mpsc-Channel).
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use tokio::sync::{mpsc, oneshot};
|
||||||
|
|
||||||
|
use audiopoker_core::game::{BettingAction, GameManager, GameState, Round, RAISE_INCREMENT};
|
||||||
|
use audiopoker_core::logic::Deck;
|
||||||
|
use audiopoker_core::network::{ClientMessage, ServerMessage};
|
||||||
|
|
||||||
|
/// Maximale Spielerzahl pro Tisch. Sobald diese Zahl an `JoinTable`-Anfragen
|
||||||
|
/// eingetroffen ist, wird automatisch eine neue Hand gestartet.
|
||||||
|
pub const MAX_PLAYERS: usize = 2;
|
||||||
|
|
||||||
|
/// Befehle, die eine WebSocket-Verbindung an den Tisch-Aktor schickt.
|
||||||
|
pub enum TableCommand {
|
||||||
|
Join {
|
||||||
|
player_name: String,
|
||||||
|
reply_tx: mpsc::UnboundedSender<ServerMessage>,
|
||||||
|
assigned_id_tx: oneshot::Sender<Result<u32, String>>,
|
||||||
|
},
|
||||||
|
Action {
|
||||||
|
player_id: u32,
|
||||||
|
message: ClientMessage,
|
||||||
|
},
|
||||||
|
/// Verbindung wurde geschlossen (aktuell nur zur Kenntnisnahme geloggt;
|
||||||
|
/// ein vorzeitig aussteigender Spieler mitten in einer Hand ist noch
|
||||||
|
/// nicht behandelt - siehe PLAN.md, offene Punkte).
|
||||||
|
Disconnected {
|
||||||
|
player_id: u32,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct TableActor {
|
||||||
|
name: String,
|
||||||
|
state: Option<GameState>,
|
||||||
|
pending_names: Vec<String>,
|
||||||
|
senders: HashMap<u32, mpsc::UnboundedSender<ServerMessage>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TableActor {
|
||||||
|
pub fn new(name: String) -> Self {
|
||||||
|
Self {
|
||||||
|
name,
|
||||||
|
state: None,
|
||||||
|
pending_names: Vec::new(),
|
||||||
|
senders: HashMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn run(mut self, mut rx: mpsc::UnboundedReceiver<TableCommand>) {
|
||||||
|
while let Some(cmd) = rx.recv().await {
|
||||||
|
match cmd {
|
||||||
|
TableCommand::Join { player_name, reply_tx, assigned_id_tx } => {
|
||||||
|
self.handle_join(player_name, reply_tx, assigned_id_tx);
|
||||||
|
}
|
||||||
|
TableCommand::Action { player_id, message } => {
|
||||||
|
self.handle_action(player_id, message);
|
||||||
|
}
|
||||||
|
TableCommand::Disconnected { player_id } => {
|
||||||
|
println!("[Tisch {}] Spieler {player_id} getrennt.", self.name);
|
||||||
|
self.senders.remove(&player_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn broadcast(&self, message: ServerMessage) {
|
||||||
|
for tx in self.senders.values() {
|
||||||
|
let _ = tx.send(message.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn send_to(&self, player_id: u32, message: ServerMessage) {
|
||||||
|
if let Some(tx) = self.senders.get(&player_id) {
|
||||||
|
let _ = tx.send(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_join(
|
||||||
|
&mut self,
|
||||||
|
player_name: String,
|
||||||
|
reply_tx: mpsc::UnboundedSender<ServerMessage>,
|
||||||
|
assigned_id_tx: oneshot::Sender<Result<u32, String>>,
|
||||||
|
) {
|
||||||
|
if self.state.is_some() {
|
||||||
|
let _ = assigned_id_tx.send(Err("Tisch ist bereits voll, das Spiel läuft schon.".into()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let player_id = self.pending_names.len() as u32;
|
||||||
|
self.pending_names.push(player_name.clone());
|
||||||
|
self.senders.insert(player_id, reply_tx);
|
||||||
|
let _ = assigned_id_tx.send(Ok(player_id));
|
||||||
|
self.send_to(player_id, ServerMessage::Welcome { player_id });
|
||||||
|
|
||||||
|
if self.pending_names.len() < MAX_PLAYERS {
|
||||||
|
self.broadcast(ServerMessage::Announce(format!(
|
||||||
|
"{player_name} ist dem Tisch beigetreten. Warte auf weitere Spieler ({}/{}).",
|
||||||
|
self.pending_names.len(),
|
||||||
|
MAX_PLAYERS
|
||||||
|
)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.start_hand();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Teilt Karten aus und startet eine neue Hand, sobald genug Spieler da sind.
|
||||||
|
fn start_hand(&mut self) {
|
||||||
|
let mut game = GameManager::start_new_game(std::mem::take(&mut self.pending_names));
|
||||||
|
|
||||||
|
let mut deck = Deck::new();
|
||||||
|
deck.shuffle();
|
||||||
|
for player in game.players.iter_mut() {
|
||||||
|
player.hole_cards = deck.deal_hand(2);
|
||||||
|
}
|
||||||
|
game.community_cards = deck.deal_hand(5);
|
||||||
|
|
||||||
|
self.broadcast(ServerMessage::Announce(format!(
|
||||||
|
"Tisch ist voll, Spiel startet mit {} Spielern.",
|
||||||
|
game.players.len()
|
||||||
|
)));
|
||||||
|
self.broadcast(ServerMessage::PlaySfx("shuffle".into()));
|
||||||
|
|
||||||
|
for player in &game.players {
|
||||||
|
self.send_to(player.id, ServerMessage::HoleCards(player.hole_cards.clone()));
|
||||||
|
}
|
||||||
|
|
||||||
|
self.broadcast(ServerMessage::TurnToAct {
|
||||||
|
player_id: game.players[game.to_act].id,
|
||||||
|
});
|
||||||
|
|
||||||
|
self.state = Some(game);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_action(&mut self, player_id: u32, message: ClientMessage) {
|
||||||
|
let Some(state) = self.state.as_mut() else {
|
||||||
|
self.send_to(player_id, ServerMessage::Error("Das Spiel hat noch nicht begonnen.".into()));
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
if state.to_act as u32 != player_id {
|
||||||
|
self.send_to(player_id, ServerMessage::Error("Du bist gerade nicht am Zug.".into()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let to_call = GameManager::amount_to_call(state, player_id as usize);
|
||||||
|
let action = match message {
|
||||||
|
ClientMessage::Fold => BettingAction::Fold,
|
||||||
|
ClientMessage::Check if to_call == 0 => BettingAction::Check,
|
||||||
|
// Client wollte checken, es ist aber ein Call fällig - wir werten das
|
||||||
|
// grosszügig als Call, statt die Aktion abzulehnen.
|
||||||
|
ClientMessage::Check => BettingAction::Call(to_call),
|
||||||
|
ClientMessage::Call => BettingAction::Call(to_call),
|
||||||
|
// Der Erhöhungsbetrag wird bewusst serverseitig festgelegt (siehe
|
||||||
|
// Kommentar in `core::network`), damit Clients ihn nicht manipulieren können.
|
||||||
|
ClientMessage::Raise => BettingAction::Raise(to_call + RAISE_INCREMENT),
|
||||||
|
ClientMessage::JoinTable { .. } => {
|
||||||
|
self.send_to(player_id, ServerMessage::Error("Du bist diesem Tisch bereits beigetreten.".into()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let round_before = state.current_round;
|
||||||
|
let outcome = GameManager::take_action(state, player_id as usize, action);
|
||||||
|
|
||||||
|
for line in outcome.announcements {
|
||||||
|
self.broadcast(ServerMessage::Announce(line));
|
||||||
|
}
|
||||||
|
for cue in outcome.sfx_cues {
|
||||||
|
self.broadcast(ServerMessage::PlaySfx(cue.to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let state = self.state.as_ref().expect("state wurde oben bereits entpackt");
|
||||||
|
|
||||||
|
if state.current_round != round_before && state.current_round != Round::Showdown {
|
||||||
|
let revealed_count = state.current_round.revealed_community_count();
|
||||||
|
self.broadcast(ServerMessage::CommunityCards(
|
||||||
|
state.community_cards[..revealed_count].to_vec(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if state.current_round != Round::Showdown {
|
||||||
|
self.broadcast(ServerMessage::TurnToAct {
|
||||||
|
player_id: state.players[state.to_act].id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
//! Kern-Logik des Spiels, unabhängig von Grafik/Audio (siehe PLAN.md).
|
|
||||||
|
|
||||||
pub mod logic;
|
|
||||||
pub mod game;
|
|
||||||
pub mod network;
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
//! Netzwerkprotokoll für die Multiplayer-Synchronisation (siehe PLAN.md, Phase 2).
|
|
||||||
//!
|
|
||||||
//! Die eigentliche WebSocket-Anbindung (Server + Client) ist noch nicht
|
|
||||||
//! implementiert; dies definiert vorerst nur den Nachrichten-Vertrag,
|
|
||||||
//! damit `core::logic` und die künftige Transportschicht entkoppelt bleiben.
|
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
use crate::core::logic::card::Card;
|
|
||||||
|
|
||||||
/// Nachrichten, die ein Client an den Server schickt.
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub enum ClientMessage {
|
|
||||||
JoinTable { player_name: String },
|
|
||||||
Fold,
|
|
||||||
Check,
|
|
||||||
Call { amount: u64 },
|
|
||||||
Raise { amount: u64 },
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Nachrichten, die der Server an alle Clients eines Tisches broadcastet.
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub enum ServerMessage {
|
|
||||||
PlayerJoined { player_id: u32, name: String },
|
|
||||||
HoleCardsDealt { player_id: u32, cards: Vec<Card> },
|
|
||||||
CommunityCardsRevealed { cards: Vec<Card> },
|
|
||||||
PotUpdated { pot: u64 },
|
|
||||||
PlayerActed { player_id: u32, description: String },
|
|
||||||
RoundChanged { round: String },
|
|
||||||
Showdown { winner_id: u32 },
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user