summaryrefslogtreecommitdiffstats
path: root/src/server/games/slots.rs
diff options
context:
space:
mode:
authorsalmonik <salmonik@tuta.io>2026-10-28 22:28:05 +0100
committersalmonik <salmonik@tuta.io>2026-10-28 22:28:05 +0100
commitea5e7d336719571e51ef14478ab603e71126e3d8 (patch)
tree4fb59337501f798e6591701e52b9e785307efd63 /src/server/games/slots.rs
parentFix TUI: render_lines 6 for correct MoveUp, drain buffered events before clea... (diff)
downloadzagan-bet-ea5e7d336719571e51ef14478ab603e71126e3d8.tar.gz
zagan-bet-ea5e7d336719571e51ef14478ab603e71126e3d8.zip
v0.2.2: split client/server logic into the library
Diffstat (limited to 'src/server/games/slots.rs')
-rw-r--r--src/server/games/slots.rs179
1 files changed, 179 insertions, 0 deletions
diff --git a/src/server/games/slots.rs b/src/server/games/slots.rs
new file mode 100644
index 0000000..0a9797d
--- /dev/null
+++ b/src/server/games/slots.rs
@@ -0,0 +1,179 @@
1use super::traits::{Game, GameError, GamePhase, GameResult, GameState};
2use rand::Rng;
3use std::collections::HashMap;
4
5pub struct SlotsGame {
6 symbols: Vec<char>,
7 payouts: HashMap<String, u64>,
8}
9
10impl SlotsGame {
11 pub fn new() -> Self {
12 let symbols = vec!['C', 'L', 'O', 'G', 'D', 'S'];
13 let mut payouts = HashMap::new();
14
15 // Balanced payouts: EV = 210/216 ≈ 0.972 (2.8% house edge)
16 payouts.insert("CCC".to_string(), 2);
17 payouts.insert("LLL".to_string(), 3);
18 payouts.insert("OOO".to_string(), 5);
19 payouts.insert("GGG".to_string(), 8);
20 payouts.insert("DDD".to_string(), 14);
21 payouts.insert("SSS".to_string(), 28);
22
23 payouts.insert("CC".to_string(), 1);
24 payouts.insert("LL".to_string(), 1);
25 payouts.insert("OO".to_string(), 1);
26 payouts.insert("GG".to_string(), 2);
27 payouts.insert("DD".to_string(), 2);
28 payouts.insert("SS".to_string(), 3);
29
30 Self { symbols, payouts }
31 }
32
33 fn spin_reels(&self) -> Vec<char> {
34 let mut rng = rand::thread_rng();
35 (0..3)
36 .map(|_| self.symbols[rng.gen_range(0..self.symbols.len())])
37 .collect()
38 }
39
40 pub fn calculate_payout(&self, reels: &[char], bet: u64) -> u64 {
41 let reel_string: String = reels.iter().collect();
42
43 if let Some(&multiplier) = self.payouts.get(&reel_string) {
44 return bet * multiplier;
45 }
46
47 // Check all three pairs including non-adjacent (e.g. [C,D,C])
48 let pairs: [(usize, usize); 3] = [(0, 1), (1, 2), (0, 2)];
49 let mut best: u64 = 0;
50 for &(a, b) in &pairs {
51 if reels[a] == reels[b] {
52 let two_of_kind = format!("{}{}", reels[a], reels[a]);
53 if let Some(&multiplier) = self.payouts.get(&two_of_kind) {
54 best = best.max(bet * multiplier);
55 }
56 }
57 }
58 best
59 }
60}
61
62impl Game for SlotsGame {
63 fn game_type(&self) -> &'static str {
64 "slots"
65 }
66
67 fn get_name(&self) -> &'static str {
68 "Slots"
69 }
70
71 fn get_description(&self) -> &'static str {
72 "Spin the reels and match symbols to win!"
73 }
74
75 fn prepare_state(&self, username: &str, bet_amount: u64) -> GameState {
76 let mut data = HashMap::new();
77 data.insert("username".to_string(), username.to_string());
78 data.insert("status".to_string(), "ready".to_string());
79
80 GameState {
81 session_id: String::new(),
82 game_type: "slots".to_string(),
83 bet_amount,
84 phase: GamePhase::WaitingToStart,
85 data,
86 }
87 }
88
89 fn process_action(
90 &self,
91 state: &GameState,
92 action: &str,
93 _params: &[&str],
94 ) -> Result<GameState, GameError> {
95 if state.phase == GamePhase::Finished {
96 return Err(GameError::GameOver);
97 }
98
99 match action {
100 "spin" => {
101 if state.phase != GamePhase::WaitingToStart && state.phase != GamePhase::Playing {
102 return Err(GameError::InvalidAction(
103 "Cannot spin in current phase".to_string(),
104 ));
105 }
106
107 let reels = self.spin_reels();
108 let reel_string: String = reels.iter().collect();
109
110 let mut new_data = state.data.clone();
111 new_data.insert("reels".to_string(), reel_string.clone());
112 new_data.insert("status".to_string(), "spun".to_string());
113
114 let is_jackpot = reels[0] == reels[1] && reels[1] == reels[2];
115 let is_two = reels[0] == reels[1] || reels[1] == reels[2] || reels[0] == reels[2];
116 new_data.insert(
117 "win_type".to_string(),
118 if is_jackpot {
119 "jackpot".to_string()
120 } else if is_two {
121 "two".to_string()
122 } else {
123 "none".to_string()
124 },
125 );
126
127 Ok(GameState {
128 session_id: state.session_id.clone(),
129 game_type: state.game_type.clone(),
130 bet_amount: state.bet_amount,
131 phase: GamePhase::Finished,
132 data: new_data,
133 })
134 }
135 _ => Err(GameError::InvalidAction(format!(
136 "Unknown action: {}",
137 action
138 ))),
139 }
140 }
141
142 fn calculate_result(&self, state: &GameState) -> Result<GameResult, GameError> {
143 if state.phase != GamePhase::Finished {
144 return Err(GameError::InternalError("Game not finished".to_string()));
145 }
146
147 let reel_string = state
148 .data
149 .get("reels")
150 .ok_or(GameError::InternalError("No reels found".to_string()))?;
151
152 let reels: Vec<char> = reel_string.chars().collect();
153 let winnings = self.calculate_payout(&reels, state.bet_amount);
154
155 let mut result_data = HashMap::new();
156 result_data.insert("reels".to_string(), reel_string.clone());
157 result_data.insert(
158 "payout_multiplier".to_string(),
159 if winnings > 0 && state.bet_amount > 0 {
160 (winnings / state.bet_amount).to_string()
161 } else {
162 "0".to_string()
163 },
164 );
165 result_data.insert(
166 "win_type".to_string(),
167 state.data.get("win_type").cloned().unwrap_or_default(),
168 );
169
170 Ok(GameResult {
171 session_id: state.session_id.clone(),
172 game_type: state.game_type.clone(),
173 bet_amount: state.bet_amount,
174 winnings,
175 final_balance: 0,
176 result_data,
177 })
178 }
179}