summaryrefslogtreecommitdiffstats
path: root/src/server/core.rs
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--src/server/core.rs303
1 files changed, 303 insertions, 0 deletions
diff --git a/src/server/core.rs b/src/server/core.rs
new file mode 100644
index 0000000..84ad4d2
--- /dev/null
+++ b/src/server/core.rs
@@ -0,0 +1,303 @@
1use bcrypt::{hash, verify, DEFAULT_COST};
2use rusqlite::Connection;
3
4use crate::server::database;
5use crate::server::games::registry::GameRegistry;
6use crate::server::games::session::SessionManager;
7use crate::server::games::traits::GameError;
8
9#[derive(Debug, Default)]
10pub struct ConnectionState {
11 user: Option<String>,
12}
13
14impl ConnectionState {
15 pub fn user(&self) -> Option<&str> {
16 self.user.as_deref()
17 }
18}
19
20pub struct ServerCore {
21 conn: Connection,
22 sessions: SessionManager,
23 registry: GameRegistry,
24 bcrypt_cost: u32,
25}
26
27impl ServerCore {
28 pub fn new(conn: Connection) -> Self {
29 Self::with_registry(conn, GameRegistry::with_defaults(), DEFAULT_COST)
30 }
31
32 pub fn with_registry(
33 conn: Connection,
34 registry: GameRegistry,
35 bcrypt_cost: u32,
36 ) -> Self {
37 Self {
38 conn,
39 sessions: SessionManager::new(),
40 registry,
41 bcrypt_cost,
42 }
43 }
44
45 pub fn open(path: &str) -> rusqlite::Result<Self> {
46 let conn = Connection::open(path)?;
47 database::init_db(&conn);
48 Ok(Self::new(conn))
49 }
50
51 pub fn handle_message(&mut self, state: &mut ConnectionState, msg: &str) -> String {
52 let parts: Vec<&str> = msg.splitn(5, ' ').collect();
53 match parts[0] {
54 "GAME_LIST" => {
55 let mut games = self.registry.list_games();
56 games.sort();
57 let info: Vec<String> = games
58 .iter()
59 .filter_map(|game_type| {
60 self.registry
61 .get_game(game_type)
62 .map(|game| format!("{}:{}", game_type, game.get_name()))
63 })
64 .collect();
65 if info.is_empty() {
66 "ERROR No games available".to_string()
67 } else {
68 format!("GAME_LIST {}", info.join(","))
69 }
70 }
71 "GAME_INFO" => {
72 if parts.len() != 2 {
73 "ERROR Invalid GAME_INFO format. Usage: GAME_INFO <game_type>"
74 .to_string()
75 } else {
76 match self.registry.get_game(parts[1]) {
77 Some(game) => format!(
78 "GAME_INFO {} {} {}",
79 parts[1],
80 game.get_name(),
81 game.get_description()
82 ),
83 None => format!("ERROR Unknown game type: {}", parts[1]),
84 }
85 }
86 }
87 "PLAY_START" => {
88 if parts.len() != 3 {
89 "ERROR Invalid PLAY_START format. Usage: PLAY_START <game_type> <bet_amount>"
90 .to_string()
91 } else {
92 self.handle_play_start(state, parts[1], parts[2])
93 }
94 }
95 "REGISTER" => {
96 if parts.len() != 3 {
97 "ERROR Invalid REGISTER format".to_string()
98 } else {
99 self.handle_register(parts[1], parts[2])
100 }
101 }
102 "AUTH" => {
103 if parts.len() != 3 {
104 "ERROR Invalid AUTH format".to_string()
105 } else {
106 self.handle_auth(state, parts[1], parts[2])
107 }
108 }
109 "PING" => "PONG".to_string(),
110 "INFO" => self.handle_info(state),
111 _ => msg.to_string(),
112 }
113 }
114
115 fn handle_register(&mut self, username: &str, credential: &str) -> String {
116 if database::get_hashed_password(&self.conn, username).is_some() {
117 return "ERROR User already exists".to_string();
118 }
119
120 let hashed = match hash(credential, self.bcrypt_cost) {
121 Ok(hashed) => hashed,
122 Err(error) => {
123 eprintln!("bcrypt failed for {username}: {error}");
124 return "ERROR Could not hash password".to_string();
125 }
126 };
127
128 match database::add_user(&self.conn, username, &hashed) {
129 Ok(()) => format!("REGISTERED {}", username),
130 Err(_) => "ERROR User already exists".to_string(),
131 }
132 }
133
134 fn handle_auth(
135 &mut self,
136 state: &mut ConnectionState,
137 username: &str,
138 credential: &str,
139 ) -> String {
140 let Some(stored) = database::get_hashed_password(&self.conn, username) else {
141 return "ERROR User not found".to_string();
142 };
143
144 let valid = if stored.starts_with("$2") {
145 verify(credential, &stored).unwrap_or(false)
146 } else if stored == credential {
147 // Legacy unsalted SHA-256 row: accept once, then upgrade in place.
148 match hash(credential, self.bcrypt_cost) {
149 Ok(upgraded) => {
150 if let Err(error) =
151 database::set_hashed_password(&self.conn, username, &upgraded)
152 {
153 eprintln!("failed to upgrade password for {username}: {error}");
154 }
155 }
156 Err(error) => eprintln!("bcrypt failed for {username}: {error}"),
157 }
158 true
159 } else {
160 false
161 };
162
163 if valid {
164 state.user = Some(username.to_string());
165 format!("OK {}", username)
166 } else {
167 "ERROR Invalid credentials".to_string()
168 }
169 }
170
171 fn handle_info(&self, state: &ConnectionState) -> String {
172 let Some(user) = state.user.as_deref() else {
173 return "ERROR Not logged in".to_string();
174 };
175
176 match (
177 database::get_account_id(&self.conn, user),
178 database::get_balance(&self.conn, user),
179 ) {
180 (Some(id), Some(balance)) => format!("INFO {} {} {}", user, id, balance),
181 _ => "ERROR Error retrieving user info".to_string(),
182 }
183 }
184
185 fn handle_play_start(
186 &mut self,
187 state: &ConnectionState,
188 game_type: &str,
189 bet_amount_str: &str,
190 ) -> String {
191 let Some(user) = state.user.as_deref() else {
192 return "ERROR Not logged in".to_string();
193 };
194
195 let bet_amount: i64 = match bet_amount_str.parse() {
196 Ok(amount) => amount,
197 Err(_) => return "ERROR Invalid bet amount".to_string(),
198 };
199 if bet_amount <= 0 {
200 return "ERROR Invalid bet amount".to_string();
201 }
202
203 let Some(game) = self.registry.get_game(game_type) else {
204 return format!("ERROR Unknown game type: {}", game_type);
205 };
206
207 let Some(balance) = database::get_balance(&self.conn, user) else {
208 return "ERROR Could not retrieve balance".to_string();
209 };
210 if balance < 0 {
211 return "ERROR Invalid balance".to_string();
212 }
213
214 if let Err(error) = game.validate_bet(bet_amount as u64, balance as u64) {
215 return game_error(error);
216 }
217
218 let game_state = game.prepare_state(user, bet_amount as u64);
219 let new_state = match game.process_action(&game_state, "spin", &[]) {
220 Ok(state) => state,
221 Err(error) => return game_error(error),
222 };
223 let result = match game.calculate_result(&new_state) {
224 Ok(result) => result,
225 Err(error) => return game_error(error),
226 };
227
228 // The session is the audit guard for the deduction and is created
229 // before the balance changes. The durable audit row is `plays`.
230 let session_id =
231 self.sessions
232 .create_session(user, game_type.to_string(), new_state.clone());
233
234 let winnings = match i64::try_from(result.winnings) {
235 Ok(winnings) => winnings,
236 Err(_) => {
237 let _ = self.sessions.remove_session(&session_id);
238 return "ERROR Winnings out of range".to_string();
239 }
240 };
241
242 let reels = new_state.data.get("reels").cloned().unwrap_or_default();
243 let win_type = new_state
244 .data
245 .get("win_type")
246 .cloned()
247 .unwrap_or_default();
248
249 match database::apply_spin(
250 &mut self.conn,
251 &session_id,
252 user,
253 game_type,
254 bet_amount,
255 winnings,
256 &reels,
257 &win_type,
258 ) {
259 Ok(new_balance) => {
260 let _ = self.sessions.remove_session(&session_id);
261 format!(
262 "GAME_RESULT {session_id} {winnings} {bet_amount} {new_balance} {reels} {win_type}"
263 )
264 }
265 Err(error) => {
266 let _ = self.sessions.remove_session(&session_id);
267 eprintln!("Balance transaction failed for {user}: {error}");
268 "ERROR Failed to update balance".to_string()
269 }
270 }
271 }
272
273 pub fn disconnect(&mut self, state: &ConnectionState) {
274 if let Some(user) = state.user.as_deref() {
275 for session in self.sessions.get_user_sessions(user) {
276 let _ = self.sessions.remove_session(&session.session_id);
277 }
278 }
279 }
280
281 pub fn balance(&self, username: &str) -> Option<i64> {
282 database::get_balance(&self.conn, username)
283 }
284
285 pub fn play_count(&self, username: &str) -> i64 {
286 database::count_plays(&self.conn, username)
287 }
288
289 pub fn session_count(&self) -> usize {
290 self.sessions.len()
291 }
292}
293
294fn game_error(error: GameError) -> String {
295 match error {
296 GameError::InsufficientBalance => "ERROR Insufficient balance".to_string(),
297 GameError::InvalidBet => "ERROR Invalid bet amount".to_string(),
298 GameError::InvalidAction(message) => format!("ERROR {message}"),
299 GameError::GameOver => "ERROR Game over".to_string(),
300 GameError::SessionNotFound => "ERROR Session not found".to_string(),
301 GameError::InternalError(message) => format!("ERROR {message}"),
302 }
303}