Compare commits
6
Commits
338f65b179
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b6fbad773d | ||
|
|
730855ae5d | ||
|
|
520e9a1320 | ||
|
|
27bc97e707 | ||
|
|
5ed6229ed5 | ||
|
|
95225b3ed8 |
Generated
+94
-5720
File diff suppressed because it is too large
Load Diff
+3
-17
@@ -1,17 +1,3 @@
|
||||
[package]
|
||||
name = "audiopoker"
|
||||
version = "0.1.0"
|
||||
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", "Window", "HtmlInputElement"] }
|
||||
js-sys = "0.3"
|
||||
futures = "0.3"
|
||||
rand = "0.8.5"
|
||||
|
||||
[target.wasm]
|
||||
edition = 2024
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = ["core", "client", "server"]
|
||||
|
||||
@@ -20,21 +20,42 @@ Ein barrierefreies Multiplayer-Pokergame für blinde Spieler, das primär über
|
||||
## Implementierungsphasen
|
||||
|
||||
### Phase 1: Fundament & Core Logic (MVP)
|
||||
- [ ] Projektstruktur aufsetzen (`Cargo.toml`, Bevy Config).
|
||||
- [ ] **Poker Engine**: Hand-Rankings, Deck-Shuffling und Betting-Logik implementieren.
|
||||
- [ ] **Audio-Grundlagen**: Integration der Web Speech API in die Rust-Umgebung.
|
||||
- [x] Projektstruktur aufsetzen (`Cargo.toml`; Modul-Struktur `core`/`audio` bereinigt und verdrahtet).
|
||||
- [x] **Poker Engine**: Hand-Rankings (inkl. Wheel-Straight, 7-Karten-Bestbewertung), Deck-Shuffling/Draw und einfache Betting-Logik (Fold/Check/Call/Raise, All-In) implementiert und getestet.
|
||||
- [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.
|
||||
|
||||
## 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
|
||||
- [ ] Server-Architektur für Spielräume und Spielerverwaltung aufbauen.
|
||||
- [ ] Netzwerkprotokoll für Aktionen (Deal, Bet, Fold) definieren.
|
||||
- [ ] Multiplayer-Lobby und Tischsuche implementieren.
|
||||
- [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.
|
||||
- [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).
|
||||
- [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).
|
||||
- [x] **Client-Anbindung an den Server**: `client/src/network.rs` verbindet sich per `web_sys::WebSocket` mit `audiopoker_server` (Non-Send-Resource, da die JS-Callbacks nicht `Send` sind). `game_plugin.rs` spielt jetzt nicht mehr lokal/offline, sondern hält nur noch eine schlanke `NetworkGameView` (eigene Hole Cards, sichtbare Community Cards, wer am Zug ist) und schickt Aktionen als `ClientMessage` an den Server; `ServerMessage`s werden zu `Announce`/`PlaySfx` übersetzt (gleicher Audio-Pfad wie zuvor lokal). Server-Bugfix nebenbei gefixt: Aktionen nach `Round::Showdown` wurden bisher nicht abgelehnt - jetzt gibt's dafür einen `Error` plus ein neues `ServerMessage::HandFinished`-Signal, mit dem Clients die Eingabe sperren.
|
||||
- Server-/Tisch-/Spielername sind über Browser-URL-Parameter steuerbar (`?server=...&table=...&name=...`), damit sich mehrere Browser-Tabs bequem als unterschiedliche Spieler an denselben Tisch hängen lassen.
|
||||
- **Wichtiger Verifikationshinweis**: Der eigentliche `web_sys::WebSocket`-Code (`#[cfg(target_arch = "wasm32")]`) konnte hier nicht kompiliert werden (kein wasm32-Std verfügbar). Ein natives `cargo check -p audiopoker_client` prüft nur den `not(wasm32)`-Dummy-Zweig, NICHT den echten WebSocket-Pfad! Bitte zusätzlich `cargo check --target wasm32-unknown-unknown -p audiopoker_client` (Target ggf. per `rustup target add wasm32-unknown-unknown` installieren) ausführen, um das wirklich zu verifizieren. Der plattformunabhängige Teil (`ClientMessage`/`ServerMessage`-Nutzung, Query-Parameter-Parsing) wurde hier isoliert gegen den nativen Dummy-Zweig getestet und funktioniert.
|
||||
- [ ] Tisch-Übersicht/`ListTables` für echte Tischsuche (aktuell nur "Name kennen und beitreten").
|
||||
- [ ] Neue Hand nach Showdown starten (serverseitig; der Client sperrt nach `HandFinished` nur die Eingabe).
|
||||
|
||||
### Phase 3: Audio Experience & UI
|
||||
- [ ] TTS-Logik verfeinern (Kontextuelle Ausgaben).
|
||||
- [ ] Soundeffekte einbinden.
|
||||
- [ ] Minimalistisches, barrierefreies User Interface erstellen.
|
||||
- [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] **Soundeffekte eingebunden** (Event-Ebene): `PlaySfx`-Messages für "shuffle", "chip", "fold", "flip" werden an den entsprechenden Stellen ausgelöst und von `audio::sfx` entgegengenommen. Echte Audiodateien/`bevy_audio`-Wiedergabe fehlen noch (keine Assets im Projekt) – aktuell Log-Ausgabe als Platzhalter.
|
||||
- [x] **Minimalistische, tastaturzentrierte Steuerung**: `Tab` wählt zwischen Fold/Call-Check/Raise, `Leertaste`/`Enter` bestätigt (siehe `game_plugin::handle_player_input`). Kein grafisches UI nötig, da das Spiel primär auf Audio-Feedback setzt.
|
||||
- [ ] Mehrspieler-lokal-Testbarkeit über die Konsole hinaus (aktuell nur 2 fest verdrahtete Spieler; echte Steuerung pro Spieler braucht Networking, siehe Phase 2).
|
||||
- [ ] Neue Hand nach Showdown starten (aktuell stoppt die Eingabe-Verarbeitung nach `Round::Showdown`).
|
||||
|
||||
### Phase 4: Polishing & Testing
|
||||
- [ ] Barrierefreiheits-Audit (Tastatur-Flow ohne Maus).
|
||||
- [ ] Latenztests im Multiplayer.
|
||||
- [ ] WebAssembly Deployment Vorbereitung.
|
||||
|
||||
## Lokal zu zweit testen (nach Client-Netzwerk-Anbindung)
|
||||
1. Server starten: `cargo run -p audiopoker_server` (lauscht standardmäßig auf `ws://0.0.0.0:9001`, überschreibbar über die Umgebungsvariable `AUDIOPOKER_ADDR`).
|
||||
2. Client für Wasm bauen/starten (z. B. über `trunk serve` o. ä. - noch nicht Teil dieses Repos, siehe offene Punkte) und zweimal im Browser öffnen:
|
||||
- Tab 1: `?server=ws://127.0.0.1:9001&table=tisch1&name=Anna`
|
||||
- Tab 2: `?server=ws://127.0.0.1:9001&table=tisch1&name=Ben`
|
||||
3. Sobald beide beigetreten sind, startet die Hand automatisch; Steuerung wie gehabt über Tab/Space/Enter.
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
# Audiopoker
|
||||
|
||||
Audiopoker ist ein barrierefreies Multiplayer-Pokergame für blinde Spieler, das primär über Audio (TTS & SFX) bedient wird. Das Spiel wird via Webbrowser aufgerufen und auf WebAssembly kompiliert.
|
||||
|
||||
## Technologie-Stack
|
||||
|
||||
- **Engine:** Bevy (Rust)
|
||||
- **Target:** WebAssembly (Wasm)
|
||||
- **Networking:** WebSockets für die Synchronisation des Spielzustands.
|
||||
- **Audio-Output:** Web Speech API (via JS-Bindings) für Text-to-Speech, `bevy_audio` für Soundeffekte.
|
||||
- **Input:** Tastatur-zentrierte Steuerung (Tab/Space/Enter).
|
||||
|
||||
## Modul-Struktur
|
||||
|
||||
- `core::logic`: Reine Poker-Logik (Deck, Hand-Evaluation, Betting) ohne grafische Abhängigkeiten.
|
||||
- `core::network`: Netzwerkprotokoll und WebSocket-Handhabung für Multiplayer-Synchronisation.
|
||||
- `audio::tts`: Interface zur Web Speech API für dynamisches Sprechen von Spielereignissen.
|
||||
- `audio::sfx`: Sound-Manager für atmosphärische Effekte (Chips, Karten mischen).
|
||||
- `ui_minimal`: Minimalistische Darstellung, optimiert für Barrierefreiheit und geringen Ressourcenverbrauch.
|
||||
|
||||
## Installation & Voraussetzungen
|
||||
|
||||
Dieses Projekt verwendet Rust und das Bevy Framework. Um das Projekt zu starten, benötigen Sie:
|
||||
|
||||
1. **Rust:**Installieren Sie Rust über [rustup](https://rustup.rs/).
|
||||
2. **Bevy Engine:** Die Abhängigkeiten werden automatisch via Cargo verwaltet.
|
||||
|
||||
### Starten des Projekts
|
||||
|
||||
```bash
|
||||
cargo run
|
||||
```
|
||||
|
||||
## Funktionsumfang (Roadmap)
|
||||
|
||||
### Phase 1: Fundament & Core Logic (MVP) - *Abgeschlossen*
|
||||
- [x] Projektstruktur aufsetzen.
|
||||
- [x] Poker Engine (Hand-Rankings, Deck-Shuffling/Draw, Betting-Logik).
|
||||
- [x] Audio-Grundlagen (`audio::tts` via Web Speech API).
|
||||
- [x] Bevy-App-Grundgerüst mit `AudiopokerPlugin`.
|
||||
|
||||
### Phase 2: Networking & Multiplayer - *In Planung*
|
||||
- [ ] Server-Architektur für Spielräume und Spielerverwaltung.
|
||||
- [ ] Netzwerkprotokoll für Aktionen definieren.
|
||||
- [ ] Multiplayer-Lobby und Tischsuche implementieren.
|
||||
|
||||
### Phase 3: Audio Experience & UI - *In Arbeit*
|
||||
- [x] TTS-Logik verfeinert (kontextuelle Ansagen).
|
||||
- [x] Soundeffekte eingebunden (Event-Ebene).
|
||||
- [x] Tastaturzentrierte Steuerung.
|
||||
- [ ] Mehrspieler-lokal-Testbarkeit verbessern.
|
||||
- [ ] Neue Hand nach Showdown automatisch starten.
|
||||
|
||||
### Phase 4: Polishing & Testing - *In Planung*
|
||||
- [ ] Barrierefreiheits-Audit (Tastatur-Flow).
|
||||
- [ ] Latenztests im Multiplayer.
|
||||
- [ ] WebAssembly Deployment Vorbereitung.
|
||||
@@ -0,0 +1,17 @@
|
||||
[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: für audio::tts (Web Speech API) und
|
||||
# network (WebSocket-Anbindung an ../server), siehe src/network.rs.
|
||||
wasm-bindgen = "0.2"
|
||||
web-sys = { version = "0.3", features = ["SpeechRecognition", "SpeechSynthesis", "SpeechSynthesisUtterance", "Window", "HtmlInputElement", "console", "WebSocket", "MessageEvent", "ErrorEvent", "CloseEvent", "Location", "UrlSearchParams"] }
|
||||
js-sys = "0.3"
|
||||
futures = "0.3"
|
||||
rand = "0.8.5"
|
||||
@@ -3,12 +3,14 @@ pub mod sfx;
|
||||
|
||||
pub struct AudioEngine {
|
||||
pub tts: tts::TtsEngine,
|
||||
pub sfx: sfx::SfxEngine,
|
||||
}
|
||||
|
||||
impl AudioEngine {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
tts: tts::TtsEngine::new(),
|
||||
sfx: sfx::SfxEngine::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
//! Sound-Manager für atmosphärische Effekte (siehe PLAN.md, `audio::sfx`).
|
||||
//!
|
||||
//! Es gibt noch keine Audio-Assets im Projekt; sobald welche existieren,
|
||||
//! kann `play_sound` über `bevy_audio` (`AudioPlayer`/`AssetServer`) echte
|
||||
//! Dateien abspielen, gemappt über die Effekt-IDs unten.
|
||||
|
||||
pub struct SfxEngine;
|
||||
|
||||
impl SfxEngine {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
/// Effekt-IDs, die aktuell im Spiel ausgelöst werden:
|
||||
/// - "shuffle": Karten werden gemischt (Spielstart).
|
||||
/// - "chip": Einsatz/Call/Raise/Gewinn (Chip-Geräusch).
|
||||
/// - "fold": Spieler foldet (Karten werden weggelegt).
|
||||
/// - "flip": Community Cards werden aufgedeckt (Flop/Turn/River).
|
||||
pub fn play_sound(&self, effect_id: &str) {
|
||||
// TODO(Phase 3 Rest): echte Wiedergabe via bevy_audio, sobald
|
||||
// Audio-Assets vorhanden sind. Bislang nur Log-Ausgabe.
|
||||
println!("Playing SFX: {}", effect_id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
//! Interface zur Web Speech API für dynamisches Sprechen von Spielereignissen
|
||||
//! (siehe PLAN.md, `audio::tts`). Auf nicht-Wasm-Targets (native Builds,
|
||||
//! Tests) wird stattdessen auf stdout ausgegeben.
|
||||
|
||||
pub struct TtsEngine;
|
||||
|
||||
impl TtsEngine {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn speak(&self, text: &str) {
|
||||
use web_sys::SpeechSynthesisUtterance;
|
||||
|
||||
let Some(window) = web_sys::window() else {
|
||||
web_sys::console::warn_1(&"Kein globales `window` für TTS gefunden.".into());
|
||||
return;
|
||||
};
|
||||
|
||||
let speech_synth = window.speech_synthesis().expect("Web Speech API nicht verfügbar");
|
||||
let utterance = SpeechSynthesisUtterance::new_with_text(text)
|
||||
.expect("SpeechSynthesisUtterance konnte nicht erstellt werden");
|
||||
speech_synth.speak(&utterance);
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn speak(&self, text: &str) {
|
||||
println!("TTS (Dummy, natives Target): {}", text);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
//! Verbindet die Bevy-`App` mit der Audio-Engine (`audio`) und dem
|
||||
//! `network`-Modul, das die WebSocket-Verbindung zu `audiopoker_server`
|
||||
//! hält (siehe PLAN.md, Phase 2/3).
|
||||
//!
|
||||
//! Architektur: Der Server ist die einzige Quelle der Wahrheit für den
|
||||
//! Spielzustand (siehe `core::network`-Doku). Der Client hält deshalb nur
|
||||
//! eine schlanke `NetworkGameView` (eigene Hole Cards, sichtbare Community
|
||||
//! Cards, wer am Zug ist) statt eines vollen `GameState` - das verhindert
|
||||
//! auch, dass ein manipulierter Client sich selbst fremde Hole Cards zeigen
|
||||
//! könnte, da der Server sie ohnehin nur an den jeweiligen Spieler schickt.
|
||||
//!
|
||||
//! Hinweis zur Bevy-Version: Seit Bevy 0.17 sind gepufferte Events als
|
||||
//! `Message` (statt `Event`) modelliert, siehe `MessageWriter`/`MessageReader`/
|
||||
//! `app.add_message::<T>()`. Dieser Code ist auf Bevy 0.19 ausgelegt.
|
||||
//!
|
||||
//! Steuerung (barrierefrei, tastaturzentriert, siehe PLAN.md):
|
||||
//! - `Tab`: wechselt die ausgewählte Aktion (Fold / Call-Check / Raise).
|
||||
//! - `Leertaste`/`Enter`: bestätigt die ausgewählte Aktion.
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
use audiopoker_core::logic::card::Card;
|
||||
use audiopoker_core::network::{ClientMessage, ServerMessage};
|
||||
|
||||
use crate::audio::AudioEngine;
|
||||
use crate::network::{self, ConnectionConfig, NetworkClient};
|
||||
|
||||
/// Bevy-Resource-Wrapper um die Engine-unabhängige Audio-Engine (`audio::AudioEngine`).
|
||||
#[derive(Resource)]
|
||||
pub struct AudioRes(pub AudioEngine);
|
||||
|
||||
/// Welche Aktion aktuell per `Tab` ausgewählt ist (minimalistisches,
|
||||
/// tastaturzentriertes UI für Screenreader-/Audio-Nutzung).
|
||||
#[derive(Resource, Default)]
|
||||
pub struct ActionCursor {
|
||||
pub selected: usize,
|
||||
}
|
||||
|
||||
const ACTION_LABELS: [&str; 3] = ["Fold", "Call/Check", "Raise"];
|
||||
|
||||
/// Der clientseitige Blick auf das Spiel: nur das, was der Server diesem
|
||||
/// Client tatsächlich mitgeteilt hat. Kein autoritativer Zustand - der
|
||||
/// liegt beim Server (`audiopoker_server::table::TableActor`).
|
||||
#[derive(Resource, Default)]
|
||||
pub struct NetworkGameView {
|
||||
pub my_player_id: Option<u32>,
|
||||
pub hole_cards: Vec<Card>,
|
||||
pub community_cards: Vec<Card>,
|
||||
pub to_act: Option<u32>,
|
||||
pub hand_finished: bool,
|
||||
}
|
||||
|
||||
/// Message: Ein Text soll per TTS angesagt werden.
|
||||
#[derive(Message, Debug, Clone)]
|
||||
pub struct Announce(pub String);
|
||||
|
||||
/// Message: Ein Soundeffekt soll abgespielt werden (z. B. "shuffle", "chip").
|
||||
#[derive(Message, Debug, Clone)]
|
||||
pub struct PlaySfx(pub &'static str);
|
||||
|
||||
pub struct AudiopokerPlugin;
|
||||
|
||||
impl Plugin for AudiopokerPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.insert_resource(AudioRes(AudioEngine::new()))
|
||||
.init_resource::<ActionCursor>()
|
||||
.init_resource::<NetworkGameView>()
|
||||
.add_message::<Announce>()
|
||||
.add_message::<PlaySfx>()
|
||||
.add_systems(Startup, setup_network)
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
receive_network_messages,
|
||||
handle_player_input,
|
||||
speak_announcements,
|
||||
play_sfx_cues,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Baut beim Start die WebSocket-Verbindung auf. Server-/Tisch-/Spielername
|
||||
/// lassen sich über die Browser-URL steuern (siehe `network::read_query_param`),
|
||||
/// damit mehrere Browser-Tabs bequem als unterschiedliche Spieler denselben
|
||||
/// Tisch testen können.
|
||||
fn setup_network(world: &mut World) {
|
||||
let config = ConnectionConfig {
|
||||
server_url: network::read_query_param("server", "ws://127.0.0.1:9001"),
|
||||
table_name: network::read_query_param("table", "tisch1"),
|
||||
player_name: network::read_query_param(
|
||||
"name",
|
||||
&format!("Spieler{}", network::random_suffix()),
|
||||
),
|
||||
};
|
||||
|
||||
let client = NetworkClient::connect(config);
|
||||
world.insert_non_send_resource(client);
|
||||
}
|
||||
|
||||
/// Übersetzt eine Server-`PlaySfx`-Cue-ID (freier String) in eine der lokal
|
||||
/// bekannten, `'static` Cue-IDs. Unbekannte IDs werden ignoriert, statt das
|
||||
/// Spiel abstürzen zu lassen (z. B. falls Server und Client mal auseinanderlaufen).
|
||||
fn map_sfx_id(id: &str) -> Option<&'static str> {
|
||||
match id {
|
||||
"shuffle" => Some("shuffle"),
|
||||
"chip" => Some("chip"),
|
||||
"fold" => Some("fold"),
|
||||
"flip" => Some("flip"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Leert die eingehende Nachrichten-Queue der Netzwerkverbindung und
|
||||
/// übersetzt jede `ServerMessage` in ein Update von `NetworkGameView`
|
||||
/// und/oder eine `Announce`/`PlaySfx`-Message fürs Audio-Feedback.
|
||||
fn receive_network_messages(
|
||||
net: NonSend<NetworkClient>,
|
||||
mut view: ResMut<NetworkGameView>,
|
||||
mut announcements: MessageWriter<Announce>,
|
||||
mut sfx_cues: MessageWriter<PlaySfx>,
|
||||
) {
|
||||
for message in net.drain_incoming() {
|
||||
match message {
|
||||
ServerMessage::Welcome { player_id } => {
|
||||
view.my_player_id = Some(player_id);
|
||||
announcements.write(Announce(format!("Verbunden als Spieler {player_id}.")));
|
||||
announcements.write(Announce(
|
||||
"Steuerung: Tab wählt eine Aktion aus, Leertaste oder Eingabetaste bestätigt.".into(),
|
||||
));
|
||||
}
|
||||
ServerMessage::Announce(text) => announcements.write(Announce(text)),
|
||||
ServerMessage::PlaySfx(id) => {
|
||||
if let Some(cue) = map_sfx_id(&id) {
|
||||
sfx_cues.write(PlaySfx(cue));
|
||||
}
|
||||
}
|
||||
ServerMessage::HoleCards(cards) => {
|
||||
let spoken = cards
|
||||
.iter()
|
||||
.map(|c| c.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" und ");
|
||||
announcements.write(Announce(format!("Deine Karten: {spoken}.")));
|
||||
view.hole_cards = cards;
|
||||
}
|
||||
ServerMessage::CommunityCards(cards) => {
|
||||
view.community_cards = cards;
|
||||
}
|
||||
ServerMessage::TurnToAct { player_id } => {
|
||||
view.to_act = Some(player_id);
|
||||
if view.my_player_id == Some(player_id) {
|
||||
announcements.write(Announce("Du bist am Zug.".into()));
|
||||
}
|
||||
}
|
||||
ServerMessage::HandFinished => {
|
||||
view.hand_finished = true;
|
||||
}
|
||||
ServerMessage::Error(reason) => {
|
||||
announcements.write(Announce(format!("Fehler: {reason}")));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Liest Tab/Space/Enter und schickt bei Bestätigung die passende
|
||||
/// `ClientMessage` an den Server. Die eigentliche Spiellogik läuft
|
||||
/// ausschließlich serverseitig (`GameManager::take_action`); der Client
|
||||
/// wartet einfach auf die resultierenden `ServerMessage`s.
|
||||
fn handle_player_input(
|
||||
keys: Res<ButtonInput<KeyCode>>,
|
||||
mut cursor: ResMut<ActionCursor>,
|
||||
view: Res<NetworkGameView>,
|
||||
net: NonSend<NetworkClient>,
|
||||
mut announcements: MessageWriter<Announce>,
|
||||
) {
|
||||
if view.hand_finished {
|
||||
// Hand ist beendet. Eine neue Hand starten ist noch nicht
|
||||
// implementiert (nächster sinnvoller Schritt, siehe PLAN.md).
|
||||
return;
|
||||
}
|
||||
|
||||
if keys.just_pressed(KeyCode::Tab) {
|
||||
cursor.selected = (cursor.selected + 1) % ACTION_LABELS.len();
|
||||
announcements.write(Announce(format!("Ausgewählt: {}", ACTION_LABELS[cursor.selected])));
|
||||
}
|
||||
|
||||
if keys.just_pressed(KeyCode::Space) || keys.just_pressed(KeyCode::Enter) {
|
||||
let Some(my_id) = view.my_player_id else {
|
||||
announcements.write(Announce("Noch nicht mit dem Tisch verbunden.".into()));
|
||||
return;
|
||||
};
|
||||
if view.to_act != Some(my_id) {
|
||||
announcements.write(Announce("Du bist gerade nicht am Zug.".into()));
|
||||
return;
|
||||
}
|
||||
|
||||
let message = match cursor.selected {
|
||||
0 => ClientMessage::Fold,
|
||||
1 => ClientMessage::Check, // Server wertet das bei fälligem Call automatisch als Call.
|
||||
_ => ClientMessage::Raise,
|
||||
};
|
||||
net.send(&message);
|
||||
}
|
||||
}
|
||||
|
||||
/// Liest `Announce`-Messages und spricht sie über die TTS-Engine.
|
||||
fn speak_announcements(mut reader: MessageReader<Announce>, audio: Res<AudioRes>) {
|
||||
for Announce(text) in reader.read() {
|
||||
audio.0.tts.speak(text);
|
||||
}
|
||||
}
|
||||
|
||||
/// Liest `PlaySfx`-Messages und spielt den zugehörigen Soundeffekt.
|
||||
fn play_sfx_cues(mut reader: MessageReader<PlaySfx>, audio: Res<AudioRes>) {
|
||||
for PlaySfx(id) in reader.read() {
|
||||
audio.0.sfx.play_sound(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
mod audio;
|
||||
mod game_plugin;
|
||||
mod network;
|
||||
|
||||
use bevy::prelude::*;
|
||||
use game_plugin::AudiopokerPlugin;
|
||||
|
||||
fn main() {
|
||||
App::new()
|
||||
.add_plugins(DefaultPlugins)
|
||||
.add_plugins(AudiopokerPlugin)
|
||||
.run();
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
//! Client-seitige WebSocket-Anbindung an `audiopoker_server` (siehe PLAN.md, Phase 2).
|
||||
//!
|
||||
//! `web_sys::WebSocket` ist callback-basiert (onopen/onmessage/onerror/onclose)
|
||||
//! und die dafür nötigen `Closure`s sind nicht `Send`. Deshalb wird die
|
||||
//! Verbindung nicht als normale Bevy-`Resource`, sondern als
|
||||
//! Non-Send-Resource gehalten (`app.insert_non_send_resource`, gelesen über
|
||||
//! `NonSend`/`NonSendMut`) - das ist das übliche Bevy-Muster für JS-Interop.
|
||||
//! Eingehende Nachrichten landen zunächst in einer gemeinsamen Queue
|
||||
//! (`Rc<RefCell<VecDeque<...>>>`), die ein normales Bevy-System jeden Frame
|
||||
//! leert und in `Announce`/`PlaySfx`/`NetworkGameView`-Updates übersetzt
|
||||
//! (siehe `game_plugin.rs`).
|
||||
//!
|
||||
//! Wichtiger Hinweis zur Verifikation: Dieser Code nutzt `#[cfg(target_arch
|
||||
//! = "wasm32")]`, weil `web_sys::WebSocket` zur Laufzeit eine echte
|
||||
//! Browser-Umgebung braucht. Ein natives `cargo check`/`cargo build` prüft
|
||||
//! ausschließlich den `not(wasm32)`-Dummy-Zweig unten - der eigentliche
|
||||
//! WebSocket-Code wird dabei NICHT typgeprüft. Zur echten Verifikation bitte
|
||||
//! zusätzlich `cargo check --target wasm32-unknown-unknown -p
|
||||
//! audiopoker_client` ausführen.
|
||||
|
||||
use audiopoker_core::network::{ClientMessage, ServerMessage};
|
||||
|
||||
/// Serverurl/Tisch/Spielername lassen sich über die Browser-URL steuern,
|
||||
/// z. B. `index.html?server=ws://localhost:9001&table=tisch1&name=Anna` -
|
||||
/// praktisch, um mehrere Browser-Tabs als unterschiedliche Spieler an
|
||||
/// denselben Tisch zu hängen.
|
||||
pub struct ConnectionConfig {
|
||||
pub server_url: String,
|
||||
pub table_name: String,
|
||||
pub player_name: String,
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod wasm_impl {
|
||||
use std::cell::RefCell;
|
||||
use std::collections::VecDeque;
|
||||
use std::rc::Rc;
|
||||
|
||||
use wasm_bindgen::prelude::*;
|
||||
use wasm_bindgen::JsCast;
|
||||
use web_sys::{CloseEvent, ErrorEvent, MessageEvent, WebSocket};
|
||||
|
||||
use super::{ClientMessage, ConnectionConfig, ServerMessage};
|
||||
|
||||
/// Non-Send-Resource: hält die WebSocket-Verbindung und die Closures,
|
||||
/// die JS beim Aufruf von `set_on...` am Leben halten muss (würden sie
|
||||
/// gedroppt, ruft der Browser sie nicht mehr auf bzw. meldet einen Fehler).
|
||||
pub struct NetworkClient {
|
||||
socket: WebSocket,
|
||||
incoming: Rc<RefCell<VecDeque<ServerMessage>>>,
|
||||
_on_open: Closure<dyn FnMut()>,
|
||||
_on_message: Closure<dyn FnMut(MessageEvent)>,
|
||||
_on_error: Closure<dyn FnMut(ErrorEvent)>,
|
||||
_on_close: Closure<dyn FnMut(CloseEvent)>,
|
||||
}
|
||||
|
||||
impl NetworkClient {
|
||||
pub fn connect(config: ConnectionConfig) -> Self {
|
||||
let socket = WebSocket::new(&config.server_url)
|
||||
.expect("WebSocket-Verbindung konnte nicht aufgebaut werden");
|
||||
let incoming = Rc::new(RefCell::new(VecDeque::new()));
|
||||
|
||||
let on_open = {
|
||||
let socket = socket.clone();
|
||||
let table_name = config.table_name.clone();
|
||||
let player_name = config.player_name.clone();
|
||||
Closure::<dyn FnMut()>::new(move || {
|
||||
let join = ClientMessage::JoinTable {
|
||||
table_name: table_name.clone(),
|
||||
player_name: player_name.clone(),
|
||||
};
|
||||
if let Ok(text) = serde_json::to_string(&join) {
|
||||
let _ = socket.send_with_str(&text);
|
||||
}
|
||||
})
|
||||
};
|
||||
socket.set_onopen(Some(on_open.as_ref().unchecked_ref()));
|
||||
|
||||
let on_message = {
|
||||
let incoming = Rc::clone(&incoming);
|
||||
Closure::<dyn FnMut(MessageEvent)>::new(move |event: MessageEvent| {
|
||||
let Some(text) = event.data().as_string() else {
|
||||
return;
|
||||
};
|
||||
match serde_json::from_str::<ServerMessage>(&text) {
|
||||
Ok(message) => incoming.borrow_mut().push_back(message),
|
||||
Err(err) => web_sys::console::warn_1(
|
||||
&format!("Ungültige Server-Nachricht ignoriert: {err}").into(),
|
||||
),
|
||||
}
|
||||
})
|
||||
};
|
||||
socket.set_onmessage(Some(on_message.as_ref().unchecked_ref()));
|
||||
|
||||
let on_error = Closure::<dyn FnMut(ErrorEvent)>::new(move |_event: ErrorEvent| {
|
||||
web_sys::console::error_1(&"WebSocket-Fehler.".into());
|
||||
});
|
||||
socket.set_onerror(Some(on_error.as_ref().unchecked_ref()));
|
||||
|
||||
let on_close = Closure::<dyn FnMut(CloseEvent)>::new(move |event: CloseEvent| {
|
||||
web_sys::console::warn_1(
|
||||
&format!("WebSocket-Verbindung geschlossen ({}).", event.reason()).into(),
|
||||
);
|
||||
});
|
||||
socket.set_onclose(Some(on_close.as_ref().unchecked_ref()));
|
||||
|
||||
Self {
|
||||
socket,
|
||||
incoming,
|
||||
_on_open: on_open,
|
||||
_on_message: on_message,
|
||||
_on_error: on_error,
|
||||
_on_close: on_close,
|
||||
}
|
||||
}
|
||||
|
||||
/// Schickt eine Client-Nachricht, sofern die Verbindung offen ist.
|
||||
/// Verworfene Nachrichten (z. B. weil noch nicht verbunden) landen
|
||||
/// nur als Konsolen-Warnung, nicht als Panik - ein Verbindungsabbruch
|
||||
/// soll das Spiel nicht crashen lassen.
|
||||
pub fn send(&self, message: &ClientMessage) {
|
||||
if self.socket.ready_state() != WebSocket::OPEN {
|
||||
web_sys::console::warn_1(&"Nicht verbunden, Nachricht verworfen.".into());
|
||||
return;
|
||||
}
|
||||
match serde_json::to_string(message) {
|
||||
Ok(text) => {
|
||||
let _ = self.socket.send_with_str(&text);
|
||||
}
|
||||
Err(err) => {
|
||||
web_sys::console::error_1(&format!("Konnte Nachricht nicht kodieren: {err}").into());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Entnimmt alle bislang eingegangenen Server-Nachrichten (FIFO).
|
||||
pub fn drain_incoming(&self) -> Vec<ServerMessage> {
|
||||
self.incoming.borrow_mut().drain(..).collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Liest einen Query-Parameter aus der aktuellen Browser-URL, mit Fallback.
|
||||
pub fn read_query_param(name: &str, default: &str) -> String {
|
||||
web_sys::window()
|
||||
.and_then(|w| w.location().search().ok())
|
||||
.and_then(|search| web_sys::UrlSearchParams::new_with_str(&search).ok())
|
||||
.and_then(|params| params.get(name))
|
||||
.unwrap_or_else(|| default.to_string())
|
||||
}
|
||||
|
||||
/// Zufallszahl für Default-Spielernamen ("Spieler1234"), ohne auf
|
||||
/// `rand`/OS-Entropie angewiesen zu sein (im Browser über `Math.random`).
|
||||
pub fn random_suffix() -> u32 {
|
||||
(js_sys::Math::random() * 10_000.0) as u32
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod native_stub {
|
||||
use super::{ClientMessage, ConnectionConfig, ServerMessage};
|
||||
|
||||
/// Dummy für native Builds/Tests: `web_sys::WebSocket` funktioniert zur
|
||||
/// Laufzeit nur im Browser. Diese Variante verbindet sich nicht wirklich,
|
||||
/// sondern loggt nur - so bleiben `cargo check`/`cargo test` auf dem
|
||||
/// nativen Target weiterhin nutzbar (siehe Modul-Kommentar oben).
|
||||
pub struct NetworkClient;
|
||||
|
||||
impl NetworkClient {
|
||||
pub fn connect(config: ConnectionConfig) -> Self {
|
||||
println!(
|
||||
"Networking (Dummy, natives Target): würde zu {} verbinden (Tisch '{}', Name '{}').",
|
||||
config.server_url, config.table_name, config.player_name
|
||||
);
|
||||
Self
|
||||
}
|
||||
|
||||
pub fn send(&self, _message: &ClientMessage) {}
|
||||
|
||||
pub fn drain_incoming(&self) -> Vec<ServerMessage> {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn read_query_param(_name: &str, default: &str) -> String {
|
||||
default.to_string()
|
||||
}
|
||||
|
||||
pub fn random_suffix() -> u32 {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub use wasm_impl::*;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub use native_stub::*;
|
||||
@@ -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"
|
||||
@@ -0,0 +1,409 @@
|
||||
//! Spielzustand und Betting-Logik (siehe PLAN.md, Phase 1 & 3).
|
||||
//!
|
||||
//! Vereinfachtes Rundenmodell: Pro Straße (Pre-Flop/Flop/Turn/River) agiert
|
||||
//! jeder noch aktive Spieler genau einmal (kein erneutes Nachziehen bei
|
||||
//! Re-Raises). Das reicht für eine barrierefreie MVP-Demo, ist aber keine
|
||||
//! vollständige Tournament-taugliche Poker-Regelimplementierung.
|
||||
|
||||
use crate::logic::card::Card;
|
||||
use crate::logic::hand::Hand;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Round {
|
||||
PreFlop,
|
||||
Flop,
|
||||
Turn,
|
||||
River,
|
||||
Showdown,
|
||||
}
|
||||
|
||||
impl Round {
|
||||
/// Wie viele Community Cards bei dieser Straße aufgedeckt sind.
|
||||
pub fn revealed_community_count(&self) -> usize {
|
||||
match self {
|
||||
Round::PreFlop => 0,
|
||||
Round::Flop => 3,
|
||||
Round::Turn => 4,
|
||||
Round::River | Round::Showdown => 5,
|
||||
}
|
||||
}
|
||||
|
||||
/// Deutschsprachige Bezeichnung für Ansagen.
|
||||
pub fn label(&self) -> &'static str {
|
||||
match self {
|
||||
Round::PreFlop => "Pre-Flop",
|
||||
Round::Flop => "Flop",
|
||||
Round::Turn => "Turn",
|
||||
Round::River => "River",
|
||||
Round::Showdown => "Showdown",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PlayerStatus {
|
||||
Active,
|
||||
Folded,
|
||||
AllIn,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Player {
|
||||
pub id: u32,
|
||||
pub name: String,
|
||||
pub stack: u64,
|
||||
pub hole_cards: Vec<Card>,
|
||||
pub current_bet: u64,
|
||||
pub status: PlayerStatus,
|
||||
}
|
||||
|
||||
impl Player {
|
||||
/// Startguthaben ist vorerst fest verdrahtet; sollte später
|
||||
/// über die Tisch-/Lobby-Konfiguration (Phase 2) einstellbar sein.
|
||||
const STARTING_STACK: u64 = 1000;
|
||||
|
||||
pub fn new(id: u32, name: &str) -> Self {
|
||||
Self {
|
||||
id,
|
||||
name: name.to_string(),
|
||||
stack: Self::STARTING_STACK,
|
||||
hole_cards: Vec::new(),
|
||||
current_bet: 0,
|
||||
status: PlayerStatus::Active,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn fold(&mut self) {
|
||||
self.status = PlayerStatus::Folded;
|
||||
}
|
||||
|
||||
/// Setzt `amount`, gedeckelt durch den verbleibenden Stack.
|
||||
/// Gibt den tatsächlich gesetzten Betrag zurück (relevant bei All-In).
|
||||
pub fn bet(&mut self, amount: u64) -> u64 {
|
||||
let actual = amount.min(self.stack);
|
||||
self.stack -= actual;
|
||||
self.current_bet += actual;
|
||||
if self.stack == 0 {
|
||||
self.status = PlayerStatus::AllIn;
|
||||
}
|
||||
actual
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GameState {
|
||||
pub players: Vec<Player>,
|
||||
pub pot: u64,
|
||||
pub dealer_idx: usize,
|
||||
pub community_cards: Vec<Card>,
|
||||
pub current_round: Round,
|
||||
/// Index des Spielers, der als Nächstes agieren muss.
|
||||
pub to_act: usize,
|
||||
/// Wie viele Spieler auf der aktuellen Straße noch agieren müssen,
|
||||
/// bevor automatisch zur nächsten Straße übergegangen wird.
|
||||
pub players_to_act_this_street: usize,
|
||||
}
|
||||
|
||||
impl GameState {
|
||||
/// Anzahl der Spieler, die weder gefoldet noch (endgültig) ausgeschieden sind.
|
||||
pub fn active_player_count(&self) -> usize {
|
||||
self.players
|
||||
.iter()
|
||||
.filter(|p| p.status != PlayerStatus::Folded)
|
||||
.count()
|
||||
}
|
||||
|
||||
/// Nächster Spieler nach `from`, der noch aktiv (nicht gefoldet, nicht
|
||||
/// All-In) handeln kann. `None`, falls niemand mehr agieren kann.
|
||||
pub fn next_to_act(&self, from: usize) -> Option<usize> {
|
||||
let n = self.players.len();
|
||||
if n == 0 {
|
||||
return None;
|
||||
}
|
||||
(1..=n)
|
||||
.map(|step| (from + step) % n)
|
||||
.find(|&idx| self.players[idx].status == PlayerStatus::Active)
|
||||
}
|
||||
}
|
||||
|
||||
/// 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).
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum BettingAction {
|
||||
Fold,
|
||||
Check,
|
||||
Call(u64),
|
||||
Raise(u64),
|
||||
}
|
||||
|
||||
/// Ergebnis einer Aktion, aufbereitet für die Audio-Ausgabe: eine Liste von
|
||||
/// Ansagen (TTS) und Soundeffekt-IDs, in der Reihenfolge, in der sie
|
||||
/// passiert sind (Aktion -> ggf. Straßenwechsel -> ggf. Showdown).
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ActionOutcome {
|
||||
pub announcements: Vec<String>,
|
||||
pub sfx_cues: Vec<&'static str>,
|
||||
}
|
||||
|
||||
pub struct GameManager;
|
||||
|
||||
impl GameManager {
|
||||
pub fn start_new_game(player_names: Vec<String>) -> GameState {
|
||||
let players: Vec<Player> = player_names
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(i, name)| Player::new(i as u32, &name))
|
||||
.collect();
|
||||
let player_count = players.len();
|
||||
|
||||
GameState {
|
||||
players,
|
||||
pot: 0,
|
||||
dealer_idx: 0,
|
||||
community_cards: Vec::new(),
|
||||
current_round: Round::PreFlop,
|
||||
to_act: 0,
|
||||
players_to_act_this_street: player_count,
|
||||
}
|
||||
}
|
||||
|
||||
/// Betrag, den `player_idx` mindestens setzen müsste, um zum höchsten
|
||||
/// aktuellen Einsatz aufzuschließen (0, wenn Checken möglich ist).
|
||||
pub fn amount_to_call(state: &GameState, player_idx: usize) -> u64 {
|
||||
let highest = state.players.iter().map(|p| p.current_bet).max().unwrap_or(0);
|
||||
highest.saturating_sub(state.players[player_idx].current_bet)
|
||||
}
|
||||
|
||||
/// Wendet die Aktion eines Spielers auf den Spielzustand an und
|
||||
/// aktualisiert den Pot entsprechend. Enthält keine Ansage-/Turn-Logik;
|
||||
/// dafür siehe [`GameManager::take_action`].
|
||||
pub fn apply_action(state: &mut GameState, player_idx: usize, action: BettingAction) {
|
||||
let player = &mut state.players[player_idx];
|
||||
match action {
|
||||
BettingAction::Fold => player.fold(),
|
||||
BettingAction::Check => {}
|
||||
BettingAction::Call(amount) | BettingAction::Raise(amount) => {
|
||||
let actual = player.bet(amount);
|
||||
state.pot += actual;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Schaltet in die nächste Wettrunde (Pre-Flop -> Flop -> Turn -> River -> Showdown).
|
||||
pub fn advance_round(state: &mut GameState) {
|
||||
state.current_round = match state.current_round {
|
||||
Round::PreFlop => Round::Flop,
|
||||
Round::Flop => Round::Turn,
|
||||
Round::Turn => Round::River,
|
||||
Round::River => Round::Showdown,
|
||||
Round::Showdown => Round::Showdown,
|
||||
};
|
||||
}
|
||||
|
||||
/// Führt die Aktion eines Spielers komplett aus: wendet sie an, lässt bei
|
||||
/// Bedarf automatisch die Wettrunde bzw. die Hand weiterlaufen, und gibt
|
||||
/// alle dabei entstandenen Ansagen/Soundcues zurück (für `audio::tts`/`audio::sfx`).
|
||||
pub fn take_action(state: &mut GameState, player_idx: usize, action: BettingAction) -> ActionOutcome {
|
||||
let mut out = ActionOutcome::default();
|
||||
let player_name = state.players[player_idx].name.clone();
|
||||
|
||||
match action {
|
||||
BettingAction::Fold => {
|
||||
out.announcements.push(format!("{player_name} foldet."));
|
||||
out.sfx_cues.push("fold");
|
||||
}
|
||||
BettingAction::Check => {
|
||||
out.announcements.push(format!("{player_name} checkt."));
|
||||
}
|
||||
BettingAction::Call(amount) => {
|
||||
out.announcements.push(format!("{player_name} callt {amount}."));
|
||||
out.sfx_cues.push("chip");
|
||||
}
|
||||
BettingAction::Raise(amount) => {
|
||||
out.announcements.push(format!("{player_name} erhöht auf {amount}."));
|
||||
out.sfx_cues.push("chip");
|
||||
}
|
||||
}
|
||||
|
||||
Self::apply_action(state, player_idx, action);
|
||||
state.players_to_act_this_street = state.players_to_act_this_street.saturating_sub(1);
|
||||
|
||||
if state.active_player_count() <= 1 {
|
||||
Self::finish_hand(state, &mut out);
|
||||
} else if state.players_to_act_this_street == 0 {
|
||||
Self::advance_round(state);
|
||||
out.announcements.push(format!("Runde: {}.", state.current_round.label()));
|
||||
out.sfx_cues.push("flip");
|
||||
|
||||
if state.current_round == Round::Showdown {
|
||||
Self::finish_hand(state, &mut out);
|
||||
} else {
|
||||
for p in state.players.iter_mut() {
|
||||
p.current_bet = 0;
|
||||
}
|
||||
state.players_to_act_this_street = state.active_player_count();
|
||||
state.to_act = state.next_to_act(state.dealer_idx).unwrap_or(state.dealer_idx);
|
||||
|
||||
let revealed_count = state.current_round.revealed_community_count();
|
||||
let revealed: Vec<String> = state.community_cards[..revealed_count]
|
||||
.iter()
|
||||
.map(|c| c.to_string())
|
||||
.collect();
|
||||
if !revealed.is_empty() {
|
||||
out.announcements.push(format!("Community Cards: {}.", revealed.join(", ")));
|
||||
}
|
||||
out.announcements.push(format!("{} ist am Zug.", state.players[state.to_act].name));
|
||||
}
|
||||
} else if let Some(next_idx) = state.next_to_act(player_idx) {
|
||||
state.to_act = next_idx;
|
||||
out.announcements.push(format!("{} ist am Zug.", state.players[next_idx].name));
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
/// Wertet am Showdown alle nicht gefoldeten Hände aus (bzw. beendet die
|
||||
/// Hand sofort, wenn nur noch ein Spieler übrig ist) und schreibt Pot
|
||||
/// und Sieger-Ansage in `out`.
|
||||
fn finish_hand(state: &mut GameState, out: &mut ActionOutcome) {
|
||||
let non_folded: Vec<usize> = state
|
||||
.players
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, p)| p.status != PlayerStatus::Folded)
|
||||
.map(|(idx, _)| idx)
|
||||
.collect();
|
||||
|
||||
// Alle anderen haben gefoldet: Sieger steht ohne Kartenvergleich fest,
|
||||
// unabhängig davon, wie viele Community Cards schon aufgedeckt waren.
|
||||
if non_folded.len() == 1 {
|
||||
let winner_idx = non_folded[0];
|
||||
let pot = state.pot;
|
||||
let winner_name = state.players[winner_idx].name.clone();
|
||||
state.players[winner_idx].stack += pot;
|
||||
state.pot = 0;
|
||||
out.announcements.push(format!(
|
||||
"{winner_name} gewinnt den Pot von {pot}, da alle anderen Spieler gefoldet haben."
|
||||
));
|
||||
out.sfx_cues.push("chip");
|
||||
state.current_round = Round::Showdown;
|
||||
return;
|
||||
}
|
||||
|
||||
let community = state.community_cards.clone();
|
||||
let mut scores = Vec::new();
|
||||
for &idx in &non_folded {
|
||||
let mut cards = state.players[idx].hole_cards.clone();
|
||||
cards.extend(community.iter().copied());
|
||||
if cards.len() >= 5 {
|
||||
scores.push((idx, Hand::new(cards).evaluate()));
|
||||
}
|
||||
}
|
||||
|
||||
let winner = scores.iter().max_by_key(|(_, score)| *score).copied();
|
||||
|
||||
if let Some((winner_idx, score)) = winner {
|
||||
let pot = state.pot;
|
||||
let winner_name = state.players[winner_idx].name.clone();
|
||||
state.players[winner_idx].stack += pot;
|
||||
state.pot = 0;
|
||||
out.announcements.push(format!(
|
||||
"{winner_name} gewinnt den Pot von {pot} mit {}.",
|
||||
score.category.label()
|
||||
));
|
||||
out.sfx_cues.push("chip");
|
||||
} else {
|
||||
out.announcements
|
||||
.push("Kein Gewinner konnte ermittelt werden.".to_string());
|
||||
}
|
||||
|
||||
state.current_round = Round::Showdown;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn startet_spiel_mit_korrekten_startwerten() {
|
||||
let state = GameManager::start_new_game(vec!["Anna".into(), "Ben".into()]);
|
||||
assert_eq!(state.players.len(), 2);
|
||||
assert_eq!(state.pot, 0);
|
||||
assert_eq!(state.current_round, Round::PreFlop);
|
||||
assert!(state.players.iter().all(|p| p.stack == Player::STARTING_STACK));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fold_reduziert_aktive_spielerzahl_und_beendet_zwei_spieler_hand_sofort() {
|
||||
let mut state = GameManager::start_new_game(vec!["Anna".into(), "Ben".into()]);
|
||||
let outcome = GameManager::take_action(&mut state, 0, BettingAction::Fold);
|
||||
assert_eq!(state.active_player_count(), 1);
|
||||
assert_eq!(state.current_round, Round::Showdown);
|
||||
assert!(outcome.announcements.iter().any(|a| a.contains("Ben")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bet_erhoeht_pot_und_reduziert_stack() {
|
||||
let mut state = GameManager::start_new_game(vec!["Anna".into(), "Ben".into()]);
|
||||
GameManager::apply_action(&mut state, 0, BettingAction::Raise(100));
|
||||
assert_eq!(state.pot, 100);
|
||||
assert_eq!(state.players[0].stack, Player::STARTING_STACK - 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_in_deckelt_den_einsatz_auf_den_verbleibenden_stack() {
|
||||
let mut player = Player::new(0, "Anna");
|
||||
let actual = player.bet(10_000); // mehr als der Stack
|
||||
assert_eq!(actual, Player::STARTING_STACK);
|
||||
assert_eq!(player.stack, 0);
|
||||
assert_eq!(player.status, PlayerStatus::AllIn);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn volle_strasse_lässt_runde_automatisch_weiterlaufen() {
|
||||
let mut state = GameManager::start_new_game(vec!["Anna".into(), "Ben".into(), "Cara".into()]);
|
||||
state.community_cards = vec![
|
||||
Card::new(crate::logic::card::Suit::Hearts, 2),
|
||||
Card::new(crate::logic::card::Suit::Hearts, 3),
|
||||
Card::new(crate::logic::card::Suit::Hearts, 4),
|
||||
Card::new(crate::logic::card::Suit::Hearts, 5),
|
||||
Card::new(crate::logic::card::Suit::Hearts, 9),
|
||||
];
|
||||
|
||||
GameManager::take_action(&mut state, 0, BettingAction::Check);
|
||||
GameManager::take_action(&mut state, 1, BettingAction::Check);
|
||||
assert_eq!(state.current_round, Round::PreFlop); // noch nicht alle dran gewesen
|
||||
GameManager::take_action(&mut state, 2, BettingAction::Check);
|
||||
assert_eq!(state.current_round, Round::Flop); // jetzt automatisch weitergeschaltet
|
||||
assert_eq!(state.players_to_act_this_street, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn showdown_kuert_besseren_spieler_zum_gewinner() {
|
||||
use crate::logic::card::Suit;
|
||||
let mut state = GameManager::start_new_game(vec!["Anna".into(), "Ben".into()]);
|
||||
state.current_round = Round::River;
|
||||
state.pot = 200;
|
||||
state.community_cards = vec![
|
||||
Card::new(Suit::Clubs, 2),
|
||||
Card::new(Suit::Clubs, 5),
|
||||
Card::new(Suit::Diamonds, 9),
|
||||
Card::new(Suit::Spades, 11),
|
||||
Card::new(Suit::Hearts, 4),
|
||||
];
|
||||
// Anna hat ein Paar Asse, Ben nur High Card.
|
||||
state.players[0].hole_cards = vec![Card::new(Suit::Hearts, 14), Card::new(Suit::Spades, 14)];
|
||||
state.players[1].hole_cards = vec![Card::new(Suit::Diamonds, 7), Card::new(Suit::Clubs, 8)];
|
||||
|
||||
let mut out = ActionOutcome::default();
|
||||
GameManager::finish_hand(&mut state, &mut out);
|
||||
|
||||
assert_eq!(state.players[0].stack, Player::STARTING_STACK + 200);
|
||||
assert_eq!(state.pot, 0);
|
||||
assert!(out.announcements[0].contains("Anna"));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -0,0 +1,59 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fmt;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum Suit {
|
||||
Spades,
|
||||
Hearts,
|
||||
Diamonds,
|
||||
Clubs,
|
||||
}
|
||||
|
||||
impl fmt::Display for Suit {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
let name = match self {
|
||||
Suit::Spades => "Pik",
|
||||
Suit::Hearts => "Herz",
|
||||
Suit::Diamonds => "Karo",
|
||||
Suit::Clubs => "Kreuz",
|
||||
};
|
||||
write!(f, "{name}")
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Rank(pub u8); // 2-14 (11=J, 12=Q, 13=K, 14=A)
|
||||
|
||||
impl Rank {
|
||||
pub fn new(value: u8) -> Self {
|
||||
Self(value)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Card {
|
||||
pub suit: Suit,
|
||||
pub rank: Rank,
|
||||
}
|
||||
|
||||
impl Card {
|
||||
pub fn new(suit: Suit, rank_val: u8) -> Self {
|
||||
Self {
|
||||
suit,
|
||||
rank: Rank::new(rank_val),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for Card {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
let rank_name = match self.rank.0 {
|
||||
11 => "Bube".to_string(),
|
||||
12 => "Dame".to_string(),
|
||||
13 => "König".to_string(),
|
||||
14 => "Ass".to_string(),
|
||||
n => n.to_string(),
|
||||
};
|
||||
write!(f, "{rank_name} {}", self.suit)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::logic::card::Card;
|
||||
|
||||
/// Kategorie einer Poker-Hand, aufsteigend nach Stärke sortiert.
|
||||
/// Die Reihenfolge der Varianten wird für den Vergleich (`Ord`) genutzt.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum HandCategory {
|
||||
HighCard,
|
||||
Pair,
|
||||
TwoPair,
|
||||
ThreeOfAKind,
|
||||
Straight,
|
||||
Flush,
|
||||
FullHouse,
|
||||
FourOfAKind,
|
||||
StraightFlush,
|
||||
}
|
||||
|
||||
impl HandCategory {
|
||||
/// Deutschsprachige Beschreibung für Sprachausgabe/Ansagen.
|
||||
pub fn label(&self) -> &'static str {
|
||||
match self {
|
||||
HandCategory::HighCard => "High Card",
|
||||
HandCategory::Pair => "einem Paar",
|
||||
HandCategory::TwoPair => "zwei Paaren",
|
||||
HandCategory::ThreeOfAKind => "einem Drilling",
|
||||
HandCategory::Straight => "einer Straße",
|
||||
HandCategory::Flush => "einem Flush",
|
||||
HandCategory::FullHouse => "einem Full House",
|
||||
HandCategory::FourOfAKind => "einem Vierling",
|
||||
HandCategory::StraightFlush => "einem Straight Flush",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Vollständig vergleichbare Bewertung einer 5-Karten-Hand:
|
||||
/// zuerst die Kategorie, danach die Kicker/Tiebreaker in absteigender
|
||||
/// Relevanz. Zwei `HandScore`-Werte lassen sich direkt per `>`/`<` vergleichen.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct HandScore {
|
||||
pub category: HandCategory,
|
||||
pub tiebreakers: [u8; 5],
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Hand {
|
||||
pub cards: Vec<Card>,
|
||||
}
|
||||
|
||||
impl Hand {
|
||||
pub fn new(cards: Vec<Card>) -> Self {
|
||||
Self { cards }
|
||||
}
|
||||
|
||||
/// Bewertet die Hand und gibt die bestmögliche 5-Karten-Kombination
|
||||
/// als `HandScore` zurück. Unterstützt 5 bis 7 Karten (z. B. 2 Hole
|
||||
/// Cards + bis zu 5 Community Cards beim Texas Hold'em).
|
||||
pub fn evaluate(&self) -> HandScore {
|
||||
assert!(
|
||||
self.cards.len() >= 5,
|
||||
"Zur Bewertung werden mindestens 5 Karten benötigt"
|
||||
);
|
||||
|
||||
combinations_of_5(&self.cards)
|
||||
.into_iter()
|
||||
.map(|combo| score_five(&combo))
|
||||
.max()
|
||||
.expect("mindestens eine 5-Karten-Kombination muss existieren")
|
||||
}
|
||||
}
|
||||
|
||||
/// Erzeugt alle 5-elementigen Teilmengen der übergebenen Karten
|
||||
/// (bei genau 5 Karten trivial, bei 6/7 Karten alle C(n,5) Kombinationen).
|
||||
fn combinations_of_5(cards: &[Card]) -> Vec<[Card; 5]> {
|
||||
let mut result = Vec::new();
|
||||
let mut indices = [0usize; 5];
|
||||
|
||||
fn recurse(
|
||||
cards: &[Card],
|
||||
start: usize,
|
||||
depth: usize,
|
||||
indices: &mut [usize; 5],
|
||||
result: &mut Vec<[Card; 5]>,
|
||||
) {
|
||||
if depth == 5 {
|
||||
result.push([
|
||||
cards[indices[0]],
|
||||
cards[indices[1]],
|
||||
cards[indices[2]],
|
||||
cards[indices[3]],
|
||||
cards[indices[4]],
|
||||
]);
|
||||
return;
|
||||
}
|
||||
for i in start..cards.len() {
|
||||
indices[depth] = i;
|
||||
recurse(cards, i + 1, depth + 1, indices, result);
|
||||
}
|
||||
}
|
||||
|
||||
recurse(cards, 0, 0, &mut indices, &mut result);
|
||||
result
|
||||
}
|
||||
|
||||
/// Bewertet genau 5 Karten nach den Standard-Poker-Regeln.
|
||||
fn score_five(cards: &[Card; 5]) -> HandScore {
|
||||
let mut ranks: Vec<u8> = cards.iter().map(|c| c.rank.0).collect();
|
||||
ranks.sort_unstable_by(|a, b| b.cmp(a)); // absteigend
|
||||
|
||||
let is_flush = cards.iter().all(|c| c.suit == cards[0].suit);
|
||||
|
||||
let mut unique_ranks = ranks.clone();
|
||||
unique_ranks.dedup();
|
||||
|
||||
// Straße A-2-3-4-5 ("Wheel") ist ein Sonderfall: Ass zählt hier als 1.
|
||||
let is_wheel = unique_ranks == vec![14, 5, 4, 3, 2];
|
||||
let is_straight_normal =
|
||||
unique_ranks.len() == 5 && (unique_ranks[0] - unique_ranks[4] == 4);
|
||||
let is_straight = is_straight_normal || is_wheel;
|
||||
let straight_high = if is_wheel { 5 } else { *unique_ranks.first().unwrap_or(&0) };
|
||||
|
||||
// Häufigkeiten pro Rang zählen, dann nach (Anzahl, Rang) absteigend sortieren,
|
||||
// damit z. B. bei Full House das Drilling-Rank vor dem Paar-Rank steht.
|
||||
let mut counts: HashMap<u8, u8> = HashMap::new();
|
||||
for &r in &ranks {
|
||||
*counts.entry(r).or_insert(0) += 1;
|
||||
}
|
||||
let mut count_groups: Vec<(u8, u8)> = counts.into_iter().collect(); // (rang, anzahl)
|
||||
count_groups.sort_unstable_by(|a, b| b.1.cmp(&a.1).then(b.0.cmp(&a.0)));
|
||||
|
||||
let category = if is_straight && is_flush {
|
||||
HandCategory::StraightFlush
|
||||
} else if count_groups[0].1 == 4 {
|
||||
HandCategory::FourOfAKind
|
||||
} else if count_groups[0].1 == 3 && count_groups.get(1).map_or(false, |g| g.1 == 2) {
|
||||
HandCategory::FullHouse
|
||||
} else if is_flush {
|
||||
HandCategory::Flush
|
||||
} else if is_straight {
|
||||
HandCategory::Straight
|
||||
} else if count_groups[0].1 == 3 {
|
||||
HandCategory::ThreeOfAKind
|
||||
} else if count_groups[0].1 == 2 && count_groups.get(1).map_or(false, |g| g.1 == 2) {
|
||||
HandCategory::TwoPair
|
||||
} else if count_groups[0].1 == 2 {
|
||||
HandCategory::Pair
|
||||
} else {
|
||||
HandCategory::HighCard
|
||||
};
|
||||
|
||||
let tiebreakers: [u8; 5] = match category {
|
||||
HandCategory::StraightFlush | HandCategory::Straight => [straight_high, 0, 0, 0, 0],
|
||||
_ => {
|
||||
let mut tb = [0u8; 5];
|
||||
for (i, (rank, _)) in count_groups.iter().enumerate().take(5) {
|
||||
tb[i] = *rank;
|
||||
}
|
||||
tb
|
||||
}
|
||||
};
|
||||
|
||||
HandScore { category, tiebreakers }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::logic::card::Suit;
|
||||
|
||||
fn c(suit: Suit, rank: u8) -> Card {
|
||||
Card::new(suit, rank)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn erkennt_flush() {
|
||||
let hand = Hand::new(vec![
|
||||
c(Suit::Hearts, 2),
|
||||
c(Suit::Hearts, 5),
|
||||
c(Suit::Hearts, 9),
|
||||
c(Suit::Hearts, 11),
|
||||
c(Suit::Hearts, 13),
|
||||
]);
|
||||
assert_eq!(hand.evaluate().category, HandCategory::Flush);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn erkennt_straight_inklusive_wheel() {
|
||||
let hand = Hand::new(vec![
|
||||
c(Suit::Hearts, 14),
|
||||
c(Suit::Spades, 2),
|
||||
c(Suit::Clubs, 3),
|
||||
c(Suit::Diamonds, 4),
|
||||
c(Suit::Hearts, 5),
|
||||
]);
|
||||
let score = hand.evaluate();
|
||||
assert_eq!(score.category, HandCategory::Straight);
|
||||
assert_eq!(score.tiebreakers[0], 5); // Wheel: Ass zählt als 1, höchste Karte ist 5
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_house_schlaegt_flush() {
|
||||
let full_house = Hand::new(vec![
|
||||
c(Suit::Hearts, 7),
|
||||
c(Suit::Spades, 7),
|
||||
c(Suit::Clubs, 7),
|
||||
c(Suit::Diamonds, 3),
|
||||
c(Suit::Hearts, 3),
|
||||
]);
|
||||
let flush = Hand::new(vec![
|
||||
c(Suit::Hearts, 2),
|
||||
c(Suit::Hearts, 5),
|
||||
c(Suit::Hearts, 9),
|
||||
c(Suit::Hearts, 11),
|
||||
c(Suit::Hearts, 13),
|
||||
]);
|
||||
assert!(full_house.evaluate() > flush.evaluate());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn waehlt_beste_kombination_aus_sieben_karten() {
|
||||
// 2 Hole Cards + 5 Community Cards, enthält ein verstecktes Vierling.
|
||||
let hand = Hand::new(vec![
|
||||
c(Suit::Hearts, 9),
|
||||
c(Suit::Spades, 9),
|
||||
c(Suit::Clubs, 9),
|
||||
c(Suit::Diamonds, 9),
|
||||
c(Suit::Hearts, 2),
|
||||
c(Suit::Spades, 5),
|
||||
c(Suit::Clubs, 13),
|
||||
]);
|
||||
assert_eq!(hand.evaluate().category, HandCategory::FourOfAKind);
|
||||
}
|
||||
}
|
||||
@@ -25,4 +25,15 @@ impl Deck {
|
||||
let mut rng = thread_rng();
|
||||
self.cards.shuffle(&mut rng);
|
||||
}
|
||||
|
||||
/// Zieht eine einzelne Karte vom Ende des (gemischten) Decks.
|
||||
pub fn draw(&mut self) -> Option<Card> {
|
||||
self.cards.pop()
|
||||
}
|
||||
|
||||
/// Zieht `count` Karten. Ist das Deck vorher erschöpft, wird
|
||||
/// entsprechend weniger Karten zurückgegeben.
|
||||
pub fn deal_hand(&mut self, count: usize) -> Vec<Card> {
|
||||
(0..count).filter_map(|_| self.draw()).collect()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
//! 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 },
|
||||
/// Die Hand ist vorbei (Showdown erreicht oder alle bis auf einen
|
||||
/// gefoldet). Der Text dazu kommt separat als `Announce`; dies ist nur
|
||||
/// das strukturierte Signal, damit Clients z. B. die Eingabe sperren können.
|
||||
HandFinished,
|
||||
/// 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,199 @@
|
||||
//! 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;
|
||||
}
|
||||
|
||||
if state.current_round == Round::Showdown {
|
||||
self.send_to(player_id, ServerMessage::Error("Die Hand ist bereits beendet.".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::HandFinished);
|
||||
} else {
|
||||
self.broadcast(ServerMessage::TurnToAct {
|
||||
player_id: state.players[state.to_act].id,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
pub trait Speaker {
|
||||
fn speak(&self, text: &str);
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct DummySpeaker;
|
||||
|
||||
impl Speaker for DummySpeaker {
|
||||
fn speak(&self, text: &str) {
|
||||
println!("TTS (Dummy): {}", text);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
#[wasm_bindgen]
|
||||
pub struct WebSpeechSpeaker;
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
#[wasm_bindgen]
|
||||
impl WebSpeechSpeaker {
|
||||
pub fn new() -> Self {
|
||||
WebSpeechSpeaker
|
||||
}
|
||||
|
||||
pub fn speak(&self, text: &str) {
|
||||
let window = web_sys::window().expect("No global window found");
|
||||
let speech = window.speech_synthesis();
|
||||
// We need to create an Utterance object.
|
||||
// This requires `web-sys` with `SpeechSynthesisUtterance` feature.
|
||||
// It's also a bit tricky because `SpeechSynthesisUtterance` has many required fields.
|
||||
// For now, let's just have the structure ready.
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub type SpeakerImpl = DummySpeaker;
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub type SpeakerImpl = WebSpeechSpeaker;
|
||||
|
||||
pub fn get_speaker() -> Box<dyn Speaker> {
|
||||
// This is a bit simplified, usually you'd use some factory or trait object pattern.
|
||||
// Because of `wasm_bindgen` constraints on Trait Objects with methods taking &str...
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
pub struct SfxEngine;
|
||||
|
||||
impl SfxEngine {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
pub fn play_sound(&self, _effect_id: &str) {
|
||||
println!("Playing SFX: {}", effect_id);
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
use web_sys::window;
|
||||
|
||||
pub struct TtsEngine;
|
||||
|
||||
impl TtsEngine {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
pub fn speak(&self, text: &str) {
|
||||
if let Some(window) = window() {
|
||||
let speech_synth = web_sys::speech_synthesis::SpeechSynthesis::new().unwrap();
|
||||
// We need a SpeechSynthesisUtterance object. This requires some JS binding or manual construction via web-sys/js-sys.
|
||||
// For now, just log that we would speak here.
|
||||
println!("TTS Speaking: {}", text);
|
||||
} else {
|
||||
println!("No window found for TTS.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Suit {
|
||||
Spades,
|
||||
Hearts,
|
||||
Diamonds,
|
||||
Clubs,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Rank(pub u8); // 2-14 (11=J, 12=Q, 13=K, 14=A)
|
||||
|
||||
impl Rank {
|
||||
pub fn new(value: u8) -> Self {
|
||||
Self(value)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Card {
|
||||
pub suit: Suit,
|
||||
pub rank: Rank,
|
||||
}
|
||||
|
||||
impl Card {
|
||||
pub fn new(suit: Suit, rank_val: u8) -> Self {
|
||||
Self {
|
||||
suit,
|
||||
rank: Rank::new(rank_val),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
use crate::core::logic::card::{Card, Rank};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Hand {
|
||||
pub cards: Vec<Card>,
|
||||
}
|
||||
|
||||
impl Hand {
|
||||
pub fn new(cards: Vec<Card>) -> Self {
|
||||
Self { cards }
|
||||
}
|
||||
|
||||
/// Evaluates the hand and returns a score or ranking.
|
||||
/// This is currently a placeholder for the poker logic implementation.
|
||||
pub fn evaluate(&self) -> u32 {
|
||||
// TODO: Implement evaluation logic based on standard poker rules
|
||||
0
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
impl Deck {
|
||||
pub fn deal_hand(&mut self, count: usize) -> Vec<Card> {
|
||||
(0..count).filter_map(|_| self.draw()).collect()
|
||||
}
|
||||
}
|
||||
|
||||
// To be implemented in src/game.rs or similar?
|
||||
// Let's add a Game manager to logic for now as the plan is minimal.
|
||||
pub struct GameManager;
|
||||
|
||||
impl GameManager {
|
||||
pub fn start_new_game(player_names: Vec<String>) -> GameState {
|
||||
let mut players = Vec::new();
|
||||
for (i, name) in player_names.into_iter().enumerate() {
|
||||
players.push(Player::new(i as u32, &name));
|
||||
}
|
||||
|
||||
GameState {
|
||||
players,
|
||||
pot: 0,
|
||||
dealer_idx: 0,
|
||||
community_cards: Vec::new(),
|
||||
current_round: Round::PreFlop,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
fn main() {
|
||||
println!("Hello, world!");
|
||||
}
|
||||
Reference in New Issue
Block a user