From 397b5016f7c3816acc9f683efab889d97bb17262 Mon Sep 17 00:00:00 2001 From: Malasuerte94 Date: Thu, 14 May 2026 10:48:21 +0300 Subject: [PATCH 01/10] feat: add session memoty in sqlite --- Cargo.lock | 68 +++++ Cargo.toml | 1 + crates/desktop/src/commands.rs | 2 +- crates/lgs/Cargo.toml | 1 + crates/lgs/src/devtool.rs | 8 +- crates/lgs/src/routes.rs | 2 +- crates/lgs/src/session.rs | 440 ++++++++++++++++++++++++++++++-- crates/lgs/src/types.rs | 2 +- ui/src/lib/api.ts | 10 + ui/src/routes/test/+page.svelte | 72 +++++- 10 files changed, 583 insertions(+), 23 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8ff1323..7e83c51 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,6 +8,18 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -1138,6 +1150,18 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fastrand" version = "2.4.1" @@ -1747,6 +1771,9 @@ name = "hashbrown" version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] [[package]] name = "hashbrown" @@ -1769,6 +1796,15 @@ version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] + [[package]] name = "heck" version = "0.4.1" @@ -2348,6 +2384,7 @@ dependencies = [ "rand 0.8.6", "rand_chacha 0.3.1", "rcgen", + "rusqlite", "rustls", "serde", "serde_json", @@ -2433,6 +2470,17 @@ dependencies = [ "redox_syscall 0.7.4", ] +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linux-keyutils" version = "0.2.5" @@ -3872,6 +3920,20 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "rusqlite" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" +dependencies = [ + "bitflags 2.11.1", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + [[package]] name = "rustc-hash" version = "2.1.2" @@ -5775,6 +5837,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version-compare" version = "0.2.1" diff --git a/Cargo.toml b/Cargo.toml index 334b254..bf18ef4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,6 +29,7 @@ serde = { version = "1.0", features = ["derive"] } serde_json = { version = "1.0", features = ["raw_value"] } sonic-rs = "0.3" zstd = "0.13" +rusqlite = { version = "0.32", features = ["bundled"] } dashmap = "6.1" rand = "0.8" rand_chacha = "0.3" diff --git a/crates/desktop/src/commands.rs b/crates/desktop/src/commands.rs index 4f4f09b..a38a419 100644 --- a/crates/desktop/src/commands.rs +++ b/crates/desktop/src/commands.rs @@ -359,7 +359,7 @@ pub async fn prepare_session( balance: payload.balance, currency: payload.currency.as_deref().map(intern_currency), }; - lgs_state.sessions.upsert(&payload.session_id, init); + lgs_state.sessions.prepare(&payload.session_id, init); Ok(()) } diff --git a/crates/lgs/Cargo.toml b/crates/lgs/Cargo.toml index 6e9ef4f..5bc75d9 100644 --- a/crates/lgs/Cargo.toml +++ b/crates/lgs/Cargo.toml @@ -24,6 +24,7 @@ serde.workspace = true serde_json.workspace = true sonic-rs.workspace = true zstd.workspace = true +rusqlite.workspace = true dashmap.workspace = true rand.workspace = true rand_chacha.workspace = true diff --git a/crates/lgs/src/devtool.rs b/crates/lgs/src/devtool.rs index 2f71c77..8642d1e 100644 --- a/crates/lgs/src/devtool.rs +++ b/crates/lgs/src/devtool.rs @@ -17,6 +17,7 @@ use tokio_stream::{Stream, StreamExt}; pub fn router(state: Arc) -> Router { Router::new() + .route("/api/devtool/sessions", delete(reset_sessions)) .route("/api/devtool/sessions/prepare", post(prepare_session)) .route("/api/devtool/status", get(status)) .route("/api/devtool/settings", get(get_settings_handler)) @@ -97,10 +98,15 @@ async fn prepare_session( balance: body.balance, currency: body.currency.as_deref().map(intern_currency), }; - state.sessions.upsert(&body.session_id, init); + state.sessions.prepare(&body.session_id, init); Ok(Json(PrepareSessionResponse { ok: true })) } +async fn reset_sessions(State(state): State>) -> AppResult> { + state.sessions.reset_all()?; + Ok(Json(OkResponse { ok: true })) +} + #[derive(Serialize)] struct StatusResponse { ok: bool, diff --git a/crates/lgs/src/routes.rs b/crates/lgs/src/routes.rs index 4010f6f..35b5a29 100644 --- a/crates/lgs/src/routes.rs +++ b/crates/lgs/src/routes.rs @@ -52,7 +52,7 @@ async fn authenticate( amount: session.balance, currency: session.currency, }, - round: None, + round: session.active_round, config: config::auth_config(), meta: None, })) diff --git a/crates/lgs/src/session.rs b/crates/lgs/src/session.rs index eb01a95..e808f2e 100644 --- a/crates/lgs/src/session.rs +++ b/crates/lgs/src/session.rs @@ -1,6 +1,13 @@ use crate::config; use crate::types::{EventEntry, Round, Session}; +use anyhow::{Context, Result, anyhow}; use dashmap::DashMap; +use parking_lot::Mutex; +use rusqlite::{Connection, Row, params}; +use serde::{Deserialize, Serialize}; +use serde_json::value::RawValue; +use std::path::{Path, PathBuf}; +use std::sync::Arc; use std::sync::atomic::{AtomicU64, Ordering}; use std::time::{SystemTime, UNIX_EPOCH}; use tokio::sync::broadcast; @@ -19,14 +26,58 @@ pub struct SessionStore { sessions: DashMap, bet_counter: AtomicU64, event_channels: DashMap>, + storage: Option, } impl SessionStore { pub fn new() -> Self { + match SessionDb::open_default() { + Ok(storage) => Self::from_storage(Some(storage)), + Err(err) => { + tracing::warn!(error = %err, "session sqlite storage unavailable; using memory only"); + Self::in_memory() + } + } + } + + pub fn in_memory() -> Self { Self { sessions: DashMap::new(), bet_counter: AtomicU64::new(0), event_channels: DashMap::new(), + storage: None, + } + } + + pub fn with_path(path: impl AsRef) -> Result { + Ok(Self::from_storage(Some(SessionDb::open(path)?))) + } + + fn from_storage(storage: Option) -> Self { + let sessions = DashMap::new(); + let mut max_bet_id = 0; + + if let Some(db) = storage.as_ref() { + match db.load_sessions() { + Ok(loaded) => { + for session in loaded { + if let Some(round) = session.active_round.as_ref() { + max_bet_id = max_bet_id.max(round.bet_id); + } + sessions.insert(session.id.clone(), session); + } + } + Err(err) => { + tracing::warn!(error = %err, "failed to load persisted sessions"); + } + } + } + + Self { + sessions, + bet_counter: AtomicU64::new(max_bet_id), + event_channels: DashMap::new(), + storage, } } @@ -76,19 +127,44 @@ impl SessionStore { }; self.sessions .insert(session_id.to_string(), session.clone()); + self.persist_session(&session); session } + /// Create a missing session, or refresh metadata for an existing one + /// without wiping balance, pending round, or event history. + pub fn prepare(&self, session_id: &str, init: SessionInit) -> Session { + if let Some(mut entry) = self.sessions.get_mut(session_id) { + entry.game = init.game; + if let Some(language) = init.language { + entry.language = language; + } + if let Some(currency) = init.currency { + entry.currency = currency; + } + let session = entry.clone(); + drop(entry); + self.persist_session(&session); + return session; + } + + self.upsert(session_id, init) + } + pub fn set_last_event( &self, session_id: &str, event_id: u32, payout_multiplier: u32, ) -> Option { - let mut entry = self.sessions.get_mut(session_id)?; - entry.last_event_id = Some(event_id); - entry.last_payout_multiplier = Some(payout_multiplier); - Some(entry.clone()) + let session = { + let mut entry = self.sessions.get_mut(session_id)?; + entry.last_event_id = Some(event_id); + entry.last_payout_multiplier = Some(payout_multiplier); + entry.clone() + }; + self.persist_session(&session); + Some(session) } /// Push an event entry into the session's history (most-recent-first). @@ -106,6 +182,7 @@ impl SessionStore { if let Some(tx) = self.event_channels.get(session_id) { let _ = tx.send(entry); } + self.persist_session(&session); Some(session) } @@ -123,29 +200,58 @@ impl SessionStore { } pub fn set_active_round(&self, session_id: &str, round: Option) -> Option { - let mut entry = self.sessions.get_mut(session_id)?; - entry.active_round = round; - Some(entry.clone()) + let session = { + let mut entry = self.sessions.get_mut(session_id)?; + entry.active_round = round; + entry.clone() + }; + self.persist_session(&session); + Some(session) } pub fn deduct_bet(&self, session_id: &str, amount: u64) -> Option { - let mut entry = self.sessions.get_mut(session_id)?; - if entry.balance < amount { - return None; - } - entry.balance -= amount; - Some(entry.clone()) + let session = { + let mut entry = self.sessions.get_mut(session_id)?; + if entry.balance < amount { + return None; + } + entry.balance -= amount; + entry.clone() + }; + self.persist_session(&session); + Some(session) } pub fn add_winnings(&self, session_id: &str, amount: u64) -> Option { - let mut entry = self.sessions.get_mut(session_id)?; - entry.balance = entry.balance.saturating_add(amount); - Some(entry.clone()) + let session = { + let mut entry = self.sessions.get_mut(session_id)?; + entry.balance = entry.balance.saturating_add(amount); + entry.clone() + }; + self.persist_session(&session); + Some(session) } pub fn next_bet_id(&self) -> u64 { self.bet_counter.fetch_add(1, Ordering::Relaxed) + 1 } + + pub fn reset_all(&self) -> Result<()> { + self.sessions.clear(); + self.event_channels.clear(); + if let Some(storage) = self.storage.as_ref() { + storage.clear()?; + } + Ok(()) + } + + fn persist_session(&self, session: &Session) { + if let Some(storage) = self.storage.as_ref() + && let Err(err) = storage.save_session(session) + { + tracing::warn!(session_id = %session.id, error = %err, "failed to persist session"); + } + } } impl Default for SessionStore { @@ -153,3 +259,305 @@ impl Default for SessionStore { Self::new() } } + +struct SessionDb { + conn: Mutex, +} + +#[derive(Debug, Serialize, Deserialize)] +struct StoredRound { + bet_id: u64, + amount: u64, + payout: u64, + payout_multiplier: f64, + active: bool, + mode: String, + event: Option, + state: String, +} + +impl SessionDb { + fn open_default() -> Result { + let path = sessions_db_path()?; + Self::open(path) + } + + fn open(path: impl AsRef) -> Result { + let path = path.as_ref(); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("create session db dir {}", parent.display()))?; + } + let conn = Connection::open(path) + .with_context(|| format!("open session db {}", path.display()))?; + conn.execute_batch( + r#" + PRAGMA journal_mode = WAL; + PRAGMA synchronous = NORMAL; + CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY NOT NULL, + game TEXT NOT NULL, + balance INTEGER NOT NULL, + currency TEXT NOT NULL, + language TEXT NOT NULL, + active_round TEXT, + created_at INTEGER NOT NULL, + last_event_id INTEGER, + last_payout_multiplier INTEGER, + event_history TEXT NOT NULL, + updated_at INTEGER NOT NULL + ); + "#, + ) + .context("migrate session db")?; + Ok(Self { + conn: Mutex::new(conn), + }) + } + + fn load_sessions(&self) -> Result> { + let conn = self.conn.lock(); + let mut stmt = conn + .prepare( + r#" + SELECT id, game, balance, currency, language, active_round, + created_at, last_event_id, last_payout_multiplier, event_history + FROM sessions + "#, + ) + .context("prepare load sessions")?; + let mut rows = stmt.query([]).context("query sessions")?; + let mut sessions = Vec::new(); + while let Some(row) = rows.next().context("read session row")? { + match session_from_row(row) { + Ok(session) => sessions.push(session), + Err(err) => tracing::warn!(error = %err, "skipping invalid persisted session"), + } + } + Ok(sessions) + } + + fn save_session(&self, session: &Session) -> Result<()> { + let active_round = session + .active_round + .as_ref() + .map(stored_round_json) + .transpose()?; + let event_history = + serde_json::to_string(&session.event_history).context("serialize event history")?; + let now = now_ms(); + let conn = self.conn.lock(); + conn.execute( + r#" + INSERT INTO sessions ( + id, game, balance, currency, language, active_round, created_at, + last_event_id, last_payout_multiplier, event_history, updated_at + ) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11) + ON CONFLICT(id) DO UPDATE SET + game = excluded.game, + balance = excluded.balance, + currency = excluded.currency, + language = excluded.language, + active_round = excluded.active_round, + created_at = excluded.created_at, + last_event_id = excluded.last_event_id, + last_payout_multiplier = excluded.last_payout_multiplier, + event_history = excluded.event_history, + updated_at = excluded.updated_at + "#, + params![ + session.id, + session.game, + u64_to_i64(session.balance), + session.currency, + session.language, + active_round, + u64_to_i64(session.created_at), + session.last_event_id.map(i64::from), + session.last_payout_multiplier.map(i64::from), + event_history, + u64_to_i64(now), + ], + ) + .with_context(|| format!("upsert session {}", session.id))?; + Ok(()) + } + + fn clear(&self) -> Result<()> { + let conn = self.conn.lock(); + conn.execute("DELETE FROM sessions", []) + .context("delete sessions")?; + Ok(()) + } +} + +fn sessions_db_path() -> Result { + let dir = dirs::data_local_dir() + .ok_or_else(|| anyhow!("could not resolve local data dir"))? + .join("stake-dev-tool"); + Ok(dir.join("sessions.sqlite3")) +} + +fn session_from_row(row: &Row<'_>) -> Result { + let id: String = row.get(0).context("id")?; + let game: String = row.get(1).context("game")?; + let balance: i64 = row.get(2).context("balance")?; + let currency: String = row.get(3).context("currency")?; + let language: String = row.get(4).context("language")?; + let active_round_json: Option = row.get(5).context("active_round")?; + let created_at: i64 = row.get(6).context("created_at")?; + let last_event_id: Option = row.get(7).context("last_event_id")?; + let last_payout_multiplier: Option = row.get(8).context("last_payout_multiplier")?; + let event_history_json: String = row.get(9).context("event_history")?; + + let mut event_history: Vec = + serde_json::from_str(&event_history_json).context("parse event history")?; + if event_history.len() > HISTORY_CAP { + event_history.truncate(HISTORY_CAP); + } + + Ok(Session { + id, + game, + balance: i64_to_u64(balance), + currency: intern_currency(¤cy), + language, + active_round: active_round_json + .as_deref() + .map(round_from_json) + .transpose()?, + created_at: i64_to_u64(created_at), + last_event_id: last_event_id.and_then(i64_to_u32), + last_payout_multiplier: last_payout_multiplier.and_then(i64_to_u32), + event_history, + }) +} + +fn stored_round_json(round: &Round) -> Result { + let stored = StoredRound { + bet_id: round.bet_id, + amount: round.amount, + payout: round.payout, + payout_multiplier: round.payout_multiplier, + active: round.active, + mode: round.mode.clone(), + event: round.event.clone(), + state: round.state.get().to_string(), + }; + serde_json::to_string(&stored).context("serialize active round") +} + +fn round_from_json(json: &str) -> Result { + let stored: StoredRound = serde_json::from_str(json).context("parse active round")?; + let state = RawValue::from_string(stored.state).context("parse active round state")?; + Ok(Round { + bet_id: stored.bet_id, + amount: stored.amount, + payout: stored.payout, + payout_multiplier: stored.payout_multiplier, + active: stored.active, + mode: stored.mode, + event: stored.event, + state: Arc::from(state), + }) +} + +fn now_ms() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0) +} + +fn u64_to_i64(value: u64) -> i64 { + i64::try_from(value).unwrap_or(i64::MAX) +} + +fn i64_to_u64(value: i64) -> u64 { + u64::try_from(value).unwrap_or(0) +} + +fn i64_to_u32(value: i64) -> Option { + u32::try_from(value).ok() +} + +const SUPPORTED_CURRENCIES: &[&str] = &[ + "USD", "CAD", "JPY", "EUR", "RUB", "CNY", "PHP", "INR", "IDR", "KRW", "BRL", "MXN", "DKK", + "PLN", "VND", "TRY", "CLP", "ARS", "PEN", "NGN", "SAR", "ILS", "AED", "TWD", "NOK", "KWD", + "JOD", "CRC", "TND", "SGD", "MYR", "OMR", "QAR", "BHD", "XGC", "XSC", +]; + +fn intern_currency(c: &str) -> &'static str { + SUPPORTED_CURRENCIES + .iter() + .copied() + .find(|s| s.eq_ignore_ascii_case(c)) + .unwrap_or(config::CURRENCY) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn persists_existing_session_without_prepare_wiping_history() { + let path = std::env::temp_dir().join(format!( + "stake-dev-tool-session-test-{}.sqlite3", + uuid::Uuid::new_v4() + )); + + let store = SessionStore::with_path(&path).expect("open store"); + let sid = "session-a"; + store.prepare( + sid, + SessionInit { + game: "game-a".to_string(), + language: Some("en".to_string()), + balance: Some(10_000), + currency: Some("USD"), + }, + ); + store.deduct_bet(sid, 250).expect("deduct"); + store + .push_event( + sid, + EventEntry { + event_id: 42, + mode: "base".to_string(), + bet_amount: 250, + payout: 0, + payout_multiplier: 0, + forced: false, + at: 1, + }, + ) + .expect("push event"); + drop(store); + + let store = SessionStore::with_path(&path).expect("reopen store"); + let loaded = store.get(sid).expect("loaded session"); + assert_eq!(loaded.balance, 9_750); + assert_eq!(loaded.event_history.len(), 1); + + store.prepare( + sid, + SessionInit { + game: "game-a".to_string(), + language: Some("fr".to_string()), + balance: Some(99_999), + currency: Some("EUR"), + }, + ); + let prepared = store.get(sid).expect("prepared session"); + assert_eq!(prepared.balance, 9_750); + assert_eq!(prepared.language, "fr"); + assert_eq!(prepared.currency, "EUR"); + assert_eq!(prepared.event_history.len(), 1); + + store.reset_all().expect("reset"); + assert!(store.get(sid).is_none()); + drop(store); + let _ = std::fs::remove_file(path); + } +} diff --git a/crates/lgs/src/types.rs b/crates/lgs/src/types.rs index 641ecff..ac3b1d5 100644 --- a/crates/lgs/src/types.rs +++ b/crates/lgs/src/types.rs @@ -21,7 +21,7 @@ pub struct Session { pub event_history: Vec, } -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct EventEntry { #[serde(rename = "eventId")] pub event_id: u32, diff --git a/ui/src/lib/api.ts b/ui/src/lib/api.ts index ca67e8d..4d66a4b 100644 --- a/ui/src/lib/api.ts +++ b/ui/src/lib/api.ts @@ -281,6 +281,16 @@ export const historyHttp = { } }; +export const sessionsHttp = { + reset: async (): Promise => { + const r = await fetch('/api/devtool/sessions', { method: 'DELETE' }); + if (!r.ok) { + const t = await r.text(); + throw new Error(`reset sessions: ${r.status} ${t}`); + } + } +}; + // ---- Notable bets per mode (computed from the lookup table) ---- export type NotableBet = { eventId: number; payoutMultiplier: number }; diff --git a/ui/src/routes/test/+page.svelte b/ui/src/routes/test/+page.svelte index f9be5f9..0200e9f 100644 --- a/ui/src/routes/test/+page.svelte +++ b/ui/src/routes/test/+page.svelte @@ -9,6 +9,7 @@ settingsHttp, forcedEventHttp, savedRoundsHttp, + sessionsHttp, betStatsHttp, gameModesHttp, replayUrl, @@ -94,6 +95,7 @@ function rebuildFramesFromResolutions(prev: FrameState[] = []) { const enabled = allResolutions.filter((r) => r.enabled); const byId = new Map(prev.map((f) => [f.res.id, f])); + const persisted = loadPersistedFrameSessionIds(); frames = enabled.map((res) => { const existing = byId.get(res.id); if (existing) { @@ -102,13 +104,14 @@ } return { res, - sessionId: crypto.randomUUID(), + sessionId: persisted[res.id] ?? defaultSessionIdFor(res.id), src: null, muted: true, history: [], showHistory: false }; }); + persistFrameSessionIds(); } // Falls back to ['base'] while loading so the dropdowns are never empty. @@ -400,6 +403,33 @@ // We're served by the LGS itself, so APIs are same-origin. const lgsBase = `${location.origin}`; const lgsHostPort = location.host; + const SESSION_STORAGE_PREFIX = 'stake-dev-tool:test-sessions:'; + + function frameSessionStorageKey(): string | null { + if (!gameSlug || !gameUrl) return null; + return `${SESSION_STORAGE_PREFIX}${gameSlug}:${gameUrl}`; + } + + function defaultSessionIdFor(resolutionId: string): string { + return `stake-dev-tool:${gameSlug}:${resolutionId}`; + } + + function loadPersistedFrameSessionIds(): Record { + const key = frameSessionStorageKey(); + if (!key) return {}; + try { + return JSON.parse(localStorage.getItem(key) ?? '{}') as Record; + } catch { + return {}; + } + } + + function persistFrameSessionIds() { + const key = frameSessionStorageKey(); + if (!key) return; + const sessionIds = Object.fromEntries(frames.map((f) => [f.res.id, f.sessionId])); + localStorage.setItem(key, JSON.stringify(sessionIds)); + } onMount(async () => { const params = page.url.searchParams; @@ -523,8 +553,9 @@ } } - async function reloadFrame(frame: FrameState, regenerateSession = true) { + async function reloadFrame(frame: FrameState, regenerateSession = false) { if (regenerateSession) frame.sessionId = crypto.randomUUID(); + persistFrameSessionIds(); await prepareSession(frame.sessionId); frame.src = buildGameUrlFor(frame.sessionId); } @@ -546,6 +577,30 @@ } } + async function resetSessions() { + if (!confirm('Reset all saved sessions and reload every frame?')) return; + busy = true; + try { + await sessionsHttp.reset(); + frames.forEach((f) => { + f.sessionId = defaultSessionIdFor(f.res.id); + f.src = null; + f.history = []; + f.showHistory = false; + }); + persistFrameSessionIds(); + for (const f of frames) { + await reloadFrame(f); + await new Promise((r) => setTimeout(r, 800)); + } + toast.success('Sessions reset.'); + } catch (e) { + toast.error(e instanceof Error ? e.message : String(e)); + } finally { + busy = false; + } + } + async function reloadOne(frame: FrameState) { busy = true; try { @@ -673,6 +728,17 @@ Apply & reload all + +
- Last 100 spins, newest first. Resets when the frame is reloaded. + Last 100 spins, newest first. Persists until sessions are reset.
{/if} From de9e4a4b2ab4cbc49c5db6818465bd6ad597a7e6 Mon Sep 17 00:00:00 2001 From: Malasuerte94 Date: Thu, 14 May 2026 11:25:49 +0300 Subject: [PATCH 02/10] fix bet on resume --- crates/lgs-wasm/src/lib.rs | 2 +- crates/lgs/src/routes.rs | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/crates/lgs-wasm/src/lib.rs b/crates/lgs-wasm/src/lib.rs index 33ea9a4..1e62355 100644 --- a/crates/lgs-wasm/src/lib.rs +++ b/crates/lgs-wasm/src/lib.rs @@ -435,7 +435,7 @@ impl PreviewEngine { self.bet_id += 1; id }, - amount: total_cost, + amount, payout, payout_multiplier: pick.payout_multiplier as f64 / 100.0, active: true, diff --git a/crates/lgs/src/routes.rs b/crates/lgs/src/routes.rs index 35b5a29..44b6f4e 100644 --- a/crates/lgs/src/routes.rs +++ b/crates/lgs/src/routes.rs @@ -169,7 +169,11 @@ async fn play( let round = Round { bet_id: state.sessions.next_bet_id(), - amount: total_cost, + // The RGS charges bonus-buy modes by `amount * mode_cost`, but the + // round contract reports the player's selected base stake. This keeps + // resumed `/authenticate` rounds from reopening bonus buys at the + // inflated charged amount. + amount, payout: result.payout, payout_multiplier: result.payout_multiplier as f64 / 100.0, // Active until the client calls /end-round, which credits the payout. From 570435a896bf6c90686be51fc1f87de132d1b200 Mon Sep 17 00:00:00 2001 From: Malasuerte94 Date: Thu, 14 May 2026 11:33:44 +0300 Subject: [PATCH 03/10] Bet ammount to replay --- ui/src/routes/test/+page.svelte | 73 ++++++++++++++++++++++++--------- 1 file changed, 53 insertions(+), 20 deletions(-) diff --git a/ui/src/routes/test/+page.svelte b/ui/src/routes/test/+page.svelte index 0200e9f..c443d6f 100644 --- a/ui/src/routes/test/+page.svelte +++ b/ui/src/routes/test/+page.svelte @@ -123,6 +123,7 @@ let replayMode = $state('base'); let replayEventId = $state(null); + let replayAmount = $state(1); let savedRounds = $state([]); let showSavedRounds = $state(true); @@ -329,17 +330,23 @@ toast.error('Enter a valid event id to replay.'); return; } + if (replayAmount < 0.01 || replayAmount > 1000) { + toast.error('Replay bet amount must be between 0.01 and 1000.'); + return; + } const url = replayUrl(gameUrl, gameSlug, lgsHostPort, { mode: replayMode, eventId: replayEventId, currency, - amount: Math.round(balance * API_MULTIPLIER), + amount: Math.round(replayAmount * API_MULTIPLIER), lang: language, device, social }); frame.src = url; - toast.success(`Replay launched on ${frame.res.label}: ${replayMode} #${replayEventId}`); + toast.success( + `Replay launched on ${frame.res.label}: ${replayMode} #${replayEventId} at ${replayAmount} ${currency}` + ); } // One persistent SSE connection per frame (keyed by sessionId). The server @@ -994,27 +1001,53 @@ {#if showReplay} -
+
+
+
+ + +
+
+ + +
+
- - +
+ + +
- {p.gameSlug} + {profileGameSlug(p)} {#if ready === false} math missing @@ -1650,12 +1696,12 @@
- +
From af094383930cd5739e5b0a03f624891a1488efde Mon Sep 17 00:00:00 2001 From: Malasuerte94 Date: Fri, 15 May 2026 16:37:32 +0300 Subject: [PATCH 06/10] Add colapsible side bar / Improved Notable Rounds Speed & Dimensions --- crates/lgs/src/math_engine.rs | 141 +++++++++++++++++++++++--------- ui/src/lib/api.ts | 3 +- ui/src/routes/test/+page.svelte | 111 +++++++++++++++++++------ 3 files changed, 191 insertions(+), 64 deletions(-) diff --git a/crates/lgs/src/math_engine.rs b/crates/lgs/src/math_engine.rs index ad29fe0..7b7a4d6 100644 --- a/crates/lgs/src/math_engine.rs +++ b/crates/lgs/src/math_engine.rs @@ -216,8 +216,11 @@ impl MathEngine { let cfg = self.load_config(game).await?; let mut out = Vec::with_capacity(cfg.modes.len()); for mode in &cfg.modes { - let assets = self.load_assets(game, mode).await?; - if let Some(stats) = compute_bet_stats(&assets.sampler) { + let weights_bytes = self.read_file(game, &mode.weights).await?; + let weights_text = String::from_utf8(weights_bytes) + .map_err(|e| AppError::Parse(format!("weights utf8: {e}")))?; + let sampler = parse_weights(&weights_text)?; + if let Some(stats) = compute_bet_stats(&sampler) { out.push(ModeBetStats { mode: mode.name.clone(), stats, @@ -259,18 +262,13 @@ pub struct NotableBet { pub payout_multiplier: u32, } -/// Three "interesting" bet ids picked from a mode's lookup table: -/// - `min`: a no-win round (payoutMultiplier = 0 if any exist, else the -/// smallest payout) -/// - `avg`: the round whose payoutMultiplier is closest to the -/// weight-weighted average of *winning* multipliers (i.e. the typical -/// look of a winning spin) -/// - `max`: the highest payoutMultiplier in the table -#[derive(Debug, Clone, Copy, Serialize)] +#[derive(Debug, Clone, Serialize)] pub struct BetStats { - pub min: NotableBet, - pub avg: NotableBet, - pub max: NotableBet, + pub zero: Vec, + pub low: Vec, + pub medium: Vec, + pub big: Vec, + pub max: Vec, } #[derive(Debug, Clone, Serialize)] @@ -291,46 +289,53 @@ fn compute_bet_stats(sampler: &WeightSampler) -> Option { return None; } - let min_entry = sampler + let mut zeroes: Vec<&WeightEntry> = sampler .entries .iter() - .find(|e| e.payout_multiplier == 0) - .or_else(|| sampler.entries.iter().min_by_key(|e| e.payout_multiplier))?; - - let max_entry = sampler.entries.iter().max_by_key(|e| e.payout_multiplier)?; + .filter(|e| e.payout_multiplier == 0) + .collect(); + zeroes.sort_by_key(|e| (std::cmp::Reverse(e.weight), e.event_id)); // Weighted mean of winning payoutMultipliers — represents the EV of a - // winning spin. We then pick the entry whose pm is closest to that mean. - let winners: Vec<&WeightEntry> = sampler + let mut winners: Vec<&WeightEntry> = sampler .entries .iter() .filter(|e| e.payout_multiplier > 0) .collect(); - - let avg_entry = if winners.is_empty() { - // No winners at all: avg falls back to min (degenerate but coherent). - min_entry - } else { - let total_w: u128 = winners.iter().map(|e| e.weight as u128).sum(); - let weighted_sum: u128 = winners - .iter() - .map(|e| e.weight as u128 * e.payout_multiplier as u128) - .sum(); - let avg_pm = (weighted_sum / total_w.max(1)) as u32; - winners - .iter() - .min_by_key(|e| e.payout_multiplier.abs_diff(avg_pm)) - .copied() - .unwrap_or(min_entry) - }; + winners.sort_by_key(|e| (e.payout_multiplier, e.event_id)); Some(BetStats { - min: notable_from(min_entry), - avg: notable_from(avg_entry), - max: notable_from(max_entry), + zero: zeroes.into_iter().take(1).map(notable_from).collect(), + low: winners.iter().take(2).map(|e| notable_from(e)).collect(), + medium: notable_near_percentile(&winners, 1, 2, 2), + big: notable_near_percentile(&winners, 4, 5, 2), + max: winners + .iter() + .rev() + .take(2) + .map(|e| notable_from(e)) + .collect(), }) } +fn notable_near_percentile( + sorted_winners: &[&WeightEntry], + numerator: usize, + denominator: usize, + count: usize, +) -> Vec { + if sorted_winners.is_empty() || denominator == 0 { + return Vec::new(); + } + let target_idx = ((sorted_winners.len() - 1) * numerator) / denominator; + let target = sorted_winners[target_idx].payout_multiplier; + let mut entries = sorted_winners.to_vec(); + entries.sort_by_key(|e| (e.payout_multiplier.abs_diff(target), e.event_id)); + entries.truncate(count); + entries.sort_by_key(|e| (e.payout_multiplier, e.event_id)); + entries.into_iter().map(notable_from).collect() +} + pub struct ReplayResult { pub payout_multiplier: u32, pub cost_multiplier: u64, @@ -542,4 +547,60 @@ mod tests { assert_eq!(raw.get(), r#"[{"bonus":true}]"#); } + + #[test] + fn notable_buckets_cover_zero_low_medium_big_and_max() { + let sampler = WeightSampler { + entries: vec![ + WeightEntry { + event_id: 1, + weight: 10, + payout_multiplier: 0, + }, + WeightEntry { + event_id: 2, + weight: 1, + payout_multiplier: 10, + }, + WeightEntry { + event_id: 3, + weight: 1, + payout_multiplier: 20, + }, + WeightEntry { + event_id: 4, + weight: 1, + payout_multiplier: 100, + }, + WeightEntry { + event_id: 5, + weight: 1, + payout_multiplier: 200, + }, + WeightEntry { + event_id: 6, + weight: 1, + payout_multiplier: 500, + }, + WeightEntry { + event_id: 7, + weight: 1, + payout_multiplier: 1000, + }, + ], + cum_weights: vec![], + total_weight: 0, + }; + + let stats = compute_bet_stats(&sampler).expect("stats"); + + assert_eq!(stats.zero.len(), 1); + assert_eq!(stats.low.len(), 2); + assert_eq!(stats.medium.len(), 2); + assert_eq!(stats.big.len(), 2); + assert_eq!(stats.max.len(), 2); + assert_eq!(stats.zero[0].event_id, 1); + assert_eq!(stats.low[0].event_id, 2); + assert_eq!(stats.max[0].event_id, 7); + } } diff --git a/ui/src/lib/api.ts b/ui/src/lib/api.ts index 4d66a4b..b5da255 100644 --- a/ui/src/lib/api.ts +++ b/ui/src/lib/api.ts @@ -294,7 +294,8 @@ export const sessionsHttp = { // ---- Notable bets per mode (computed from the lookup table) ---- export type NotableBet = { eventId: number; payoutMultiplier: number }; -export type BetStats = { min: NotableBet; avg: NotableBet; max: NotableBet }; +export type NotableBucket = 'zero' | 'low' | 'medium' | 'big' | 'max'; +export type BetStats = Record; export type ModeBetStats = { mode: string; stats: BetStats }; export const betStatsHttp = { diff --git a/ui/src/routes/test/+page.svelte b/ui/src/routes/test/+page.svelte index c443d6f..c02affe 100644 --- a/ui/src/routes/test/+page.svelte +++ b/ui/src/routes/test/+page.svelte @@ -16,7 +16,8 @@ type ResolutionPreset, type EventEntry, type SavedRound, - type ModeBetStats + type ModeBetStats, + type NotableBucket } from '$lib/api'; import { Button } from '$lib/components/ui/button'; @@ -37,6 +38,8 @@ import VolumeOffIcon from '@lucide/svelte/icons/volume-x'; import ExternalLinkIcon from '@lucide/svelte/icons/external-link'; import ChevronDownIcon from '@lucide/svelte/icons/chevron-down'; + import ChevronLeftIcon from '@lucide/svelte/icons/chevron-left'; + import ChevronRightIcon from '@lucide/svelte/icons/chevron-right'; import StarIcon from '@lucide/svelte/icons/star'; import StarOffIcon from '@lucide/svelte/icons/star-off'; import TrashIcon from '@lucide/svelte/icons/trash-2'; @@ -64,6 +67,7 @@ let gameUrl = $state(''); let gameSlug = $state(''); + let sidebarCollapsed = $state(false); let balance = $state(10000); let currency = $state('USD'); @@ -135,6 +139,13 @@ let notableLoading = $state(false); let notableLoaded = $state(false); let showNotable = $state(false); + const notableBuckets: Array<{ kind: NotableBucket; label: string; color: string }> = [ + { kind: 'zero', label: 'zero', color: 'text-muted-foreground' }, + { kind: 'low', label: 'low', color: 'text-sky-400' }, + { kind: 'medium', label: 'med', color: 'text-amber-400' }, + { kind: 'big', label: 'big', color: 'text-orange-400' }, + { kind: 'max', label: 'max', color: 'text-emerald-400' } + ]; async function reloadSavedRounds() { if (!gameSlug) return; @@ -179,10 +190,19 @@ } /** Bookmark a notable bet with the auto-set description. */ - async function bookmarkNotable(mode: string, eventId: number, kind: 'min' | 'avg' | 'max') { + async function bookmarkNotable(mode: string, eventId: number, kind: NotableBucket) { if (!gameSlug) return; if (isBookmarked(mode, eventId)) return; - const description = kind === 'min' ? 'min' : kind === 'avg' ? 'average win' : 'max win'; + const description = + kind === 'zero' + ? 'zero win' + : kind === 'low' + ? 'low win' + : kind === 'medium' + ? 'medium win' + : kind === 'big' + ? 'big win' + : 'max win'; try { await savedRoundsHttp.create(gameSlug, mode, eventId, description); await reloadSavedRounds(); @@ -662,15 +682,61 @@
-
+
+ {/if} From e2ecc2ee1a3a75ff357c059dd0c51da1198ddaa0 Mon Sep 17 00:00:00 2001 From: Malasuerte94 Date: Thu, 14 May 2026 10:48:21 +0300 Subject: [PATCH 07/10] feat: add session memoty in sqlite False positive --- crates/lgs/src/session.rs | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/crates/lgs/src/session.rs b/crates/lgs/src/session.rs index 46fd404..5e4bdb8 100644 --- a/crates/lgs/src/session.rs +++ b/crates/lgs/src/session.rs @@ -1,6 +1,7 @@ use crate::config::{self, intern_currency}; use crate::types::{EventEntry, Round, Session}; use anyhow::{Context, Result, anyhow}; +use anyhow::{Context, Result, anyhow}; use dashmap::DashMap; use parking_lot::Mutex; use rusqlite::{Connection, Row, params}; @@ -8,6 +9,12 @@ use serde::{Deserialize, Serialize}; use serde_json::value::RawValue; use std::path::{Path, PathBuf}; use std::sync::Arc; +use parking_lot::Mutex; +use rusqlite::{Connection, Row, params}; +use serde::{Deserialize, Serialize}; +use serde_json::value::RawValue; +use std::path::{Path, PathBuf}; +use std::sync::Arc; use std::sync::atomic::{AtomicU64, Ordering}; use std::time::{SystemTime, UNIX_EPOCH}; use tokio::sync::broadcast; @@ -482,6 +489,20 @@ fn i64_to_u32(value: i64) -> Option { u32::try_from(value).ok() } +const SUPPORTED_CURRENCIES: &[&str] = &[ + "USD", "CAD", "JPY", "EUR", "RUB", "CNY", "PHP", "INR", "IDR", "KRW", "BRL", "MXN", "DKK", + "PLN", "VND", "TRY", "CLP", "ARS", "PEN", "NGN", "SAR", "ILS", "AED", "TWD", "NOK", "KWD", + "JOD", "CRC", "TND", "SGD", "MYR", "OMR", "QAR", "BHD", "XGC", "XSC", +]; + +fn intern_currency(c: &str) -> &'static str { + SUPPORTED_CURRENCIES + .iter() + .copied() + .find(|s| s.eq_ignore_ascii_case(c)) + .unwrap_or(config::CURRENCY) +} + #[cfg(test)] mod tests { use super::*; From 817085efbbdd03fa7b30588ea49027a3b623d5ea Mon Sep 17 00:00:00 2001 From: Malasuerte94 Date: Fri, 15 May 2026 16:23:04 +0300 Subject: [PATCH 08/10] Fix problem with the session decode, fix game name seding in request and display on page --- crates/lgs-wasm/src/lib.rs | 35 +++++++-------- crates/lgs/src/math_engine.rs | 84 ++++++++++++++++++++++++++--------- ui/src/routes/+page.svelte | 82 ++++++++++++++++++++++++++-------- 3 files changed, 141 insertions(+), 60 deletions(-) diff --git a/crates/lgs-wasm/src/lib.rs b/crates/lgs-wasm/src/lib.rs index 1e62355..4fd09e1 100644 --- a/crates/lgs-wasm/src/lib.rs +++ b/crates/lgs-wasm/src/lib.rs @@ -558,17 +558,15 @@ fn decompress_and_index(compressed: &[u8]) -> Result { .map_err(|e| format!("zstd decode: {e}"))?; let mut id_to_range = HashMap::with_capacity(buffer.len() / 512 + 1); - let mut line_start = 0usize; - let mut i = 0usize; - while i < buffer.len() { - if buffer[i] == b'\n' { - index_line(&buffer, line_start, i, &mut id_to_range); - line_start = i + 1; - } - i += 1; - } - if line_start < buffer.len() { - index_line(&buffer, line_start, buffer.len(), &mut id_to_range); + let mut stream = + serde_json::Deserializer::from_slice(&buffer).into_iter::(); + while let Some(item) = { + let start = stream.byte_offset(); + stream.next().map(|item| (start, item)) + } { + let (start, item) = item; + item.map_err(|e| format!("books json stream at byte {start}: {e}"))?; + index_record(&buffer, start, stream.byte_offset(), &mut id_to_range); } Ok(BooksIndex { buffer, @@ -576,20 +574,17 @@ fn decompress_and_index(compressed: &[u8]) -> Result { }) } -fn index_line( +fn index_record( buffer: &[u8], - line_start: usize, - mut line_end: usize, + record_start: usize, + record_end: usize, id_to_range: &mut HashMap, ) { - if line_end > line_start && buffer[line_end - 1] == b'\r' { - line_end -= 1; - } - if line_end <= line_start { + if record_end <= record_start { return; } - if let Some(id) = read_id_field(&buffer[line_start..line_end]) { - id_to_range.insert(id, (line_start as u32, line_end as u32)); + if let Some(id) = read_id_field(&buffer[record_start..record_end]) { + id_to_range.insert(id, (record_start as u32, record_end as u32)); } } diff --git a/crates/lgs/src/math_engine.rs b/crates/lgs/src/math_engine.rs index 405e033..ad29fe0 100644 --- a/crates/lgs/src/math_engine.rs +++ b/crates/lgs/src/math_engine.rs @@ -47,7 +47,18 @@ impl MathEngine { } fn file_path(&self, game: &str, file: &str) -> PathBuf { - PathBuf::from(&self.cfg.math_dir).join(game).join(file) + let root = PathBuf::from(&self.cfg.math_dir); + let nested = root.join(game).join(file); + if nested.exists() { + return nested; + } + + let flat = root.join(file); + if flat.exists() { + return flat; + } + + nested } async fn read_file(&self, game: &str, file: &str) -> AppResult> { @@ -386,18 +397,15 @@ fn decompress_and_index(compressed: &[u8]) -> AppResult { let buffer = zstd::decode_all(compressed).map_err(|e| AppError::Zstd(e.to_string()))?; let mut id_to_range = HashMap::with_capacity(buffer.len() / 512 + 1); - let mut line_start = 0usize; - let mut i = 0usize; - while i < buffer.len() { - if buffer[i] == b'\n' { - index_line(&buffer, line_start, i, &mut id_to_range); - line_start = i + 1; - } - i += 1; - } - // Trailing line without a newline terminator. - if line_start < buffer.len() { - index_line(&buffer, line_start, buffer.len(), &mut id_to_range); + let mut stream = + serde_json::Deserializer::from_slice(&buffer).into_iter::(); + while let Some(item) = { + let start = stream.byte_offset(); + stream.next().map(|item| (start, item)) + } { + let (start, item) = item; + item.map_err(|e| AppError::Parse(format!("books json stream at byte {start}: {e}")))?; + index_record(&buffer, start, stream.byte_offset(), &mut id_to_range); } Ok(BooksIndex { @@ -406,20 +414,17 @@ fn decompress_and_index(compressed: &[u8]) -> AppResult { }) } -fn index_line( +fn index_record( buffer: &[u8], - line_start: usize, - mut line_end: usize, + record_start: usize, + record_end: usize, id_to_range: &mut HashMap, ) { - if line_end > line_start && buffer[line_end - 1] == b'\r' { - line_end -= 1; - } - if line_end <= line_start { + if record_end <= record_start { return; } - if let Some(id) = read_id_field(&buffer[line_start..line_end]) { - id_to_range.insert(id, (line_start as u32, line_end as u32)); + if let Some(id) = read_id_field(&buffer[record_start..record_end]) { + id_to_range.insert(id, (record_start as u32, record_end as u32)); } } @@ -503,3 +508,38 @@ fn read_event(idx: &BooksIndex, event_id: u32) -> AppResult> { }; Ok(Arc::from(raw)) } + +#[cfg(test)] +mod tests { + use super::*; + + fn compressed_books(bytes: &[u8]) -> Vec { + zstd::encode_all(bytes, 0).expect("compress test books") + } + + #[test] + fn indexes_newline_delimited_books() { + let compressed = compressed_books( + br#"{"id":1,"events":[{"symbol":"A"}]} +{"id":2,"events":[{"symbol":"B"}]} +"#, + ); + + let books = decompress_and_index(&compressed).expect("index books"); + let raw = read_event(&books, 2).expect("read event"); + + assert_eq!(raw.get(), r#"[{"symbol":"B"}]"#); + } + + #[test] + fn indexes_adjacent_books_without_newlines() { + let compressed = compressed_books( + br#"{"id":10,"events":[{"bonus":false}]}{"id":11,"events":[{"bonus":true}]}"#, + ); + + let books = decompress_and_index(&compressed).expect("index books"); + let raw = read_event(&books, 11).expect("read event"); + + assert_eq!(raw.get(), r#"[{"bonus":true}]"#); + } +} diff --git a/ui/src/routes/+page.svelte b/ui/src/routes/+page.svelte index c5a2cc2..695c3fd 100644 --- a/ui/src/routes/+page.svelte +++ b/ui/src/routes/+page.svelte @@ -180,7 +180,7 @@ status = await lgs.status(); caState = await ca.status(); - savedProfiles = await profilesApi.list(); + await loadProfiles(true); const s = await settingsApi.get(); resolutions = s.resolutions; refreshProfileReadiness().catch(() => {}); @@ -242,6 +242,47 @@ return `${head}…${tail}`; } + const TECHNICAL_FOLDER_SLUGS = new Set(['publish_files', 'publish-files', 'build', 'dist']); + + function slugifyGameName(value: string): string { + return value + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); + } + + function profileGameSlug(p: Pick, inspectedSlug?: string): string { + const nameSlug = slugifyGameName(p.name); + if (!p.gameSlug) return nameSlug || inspectedSlug || ''; + if (p.gameSlug === inspectedSlug && nameSlug) return nameSlug; + if (TECHNICAL_FOLDER_SLUGS.has(p.gameSlug) && nameSlug) return nameSlug; + return p.gameSlug; + } + + async function loadProfiles(repair = false) { + let profiles = await profilesApi.list(); + if (repair) { + let changed = false; + for (const p of profiles) { + const repairedSlug = profileGameSlug(p); + if (repairedSlug && repairedSlug !== p.gameSlug) { + await profilesApi.save({ + id: p.id, + name: p.name, + gamePath: p.gamePath, + gameUrl: p.gameUrl, + gameSlug: repairedSlug, + resolutions: p.resolutions + }); + changed = true; + } + } + if (changed) profiles = await profilesApi.list(); + } + savedProfiles = profiles; + } + function formatRelative(ts: number | undefined): string { if (!ts) return '—'; const diff = Date.now() - ts; @@ -290,7 +331,9 @@ const p = savedProfiles.find((x) => x.id === activeProfileId) ?? savedProfiles[0]; if (!p) throw new Error('Add a profile first.'); const inspected = await lgs.inspect(p.gamePath); - await ensureLgsRunning(inspected.mathDir); + const gameSlug = profileGameSlug(p, inspected.slug); + const mathRoot = gameSlug === inspected.slug ? inspected.mathDir : p.gamePath; + await ensureLgsRunning(mathRoot); toast.success(`LGS listening on ${status.bound_addr}`); } }); @@ -420,11 +463,11 @@ name: d.name.trim(), gamePath: d.game!.gamePath, gameUrl: d.gameUrl.trim(), - gameSlug: d.game!.slug, + gameSlug: slugifyGameName(d.name) || d.game!.slug, resolutions }); activeProfileId = saved.id; - savedProfiles = await profilesApi.list(); + await loadProfiles(); closeDraft(); toast.success(`Profile "${saved.name}" ${d.mode === 'edit' ? 'updated' : 'saved'}`); }); @@ -456,7 +499,7 @@ // `allCatalogs` (stamping team_id on profiles whose id appears in a // team's catalogue). Re-read profiles so the UI picks up those new // team_ids and moves the cards to their real group. - savedProfiles = await profilesApi.list(); + await loadProfiles(); } else { catalog = []; } @@ -493,15 +536,16 @@ await withBusy(async () => { toast.info(`Adding "${p.name}" to "${team.name}" catalogue…`); await teamsApi.pushProfile(team.id, p.id); - toast.info(`Uploading ${p.gameSlug} math (can take several minutes)…`); - const r = await teamsApi.pushMath(team.id, p.gameSlug, p.gamePath); + const gameSlug = profileGameSlug(p); + toast.info(`Uploading ${gameSlug} math (can take several minutes)…`); + const r = await teamsApi.pushMath(team.id, gameSlug, p.gamePath); const mb = (r.bytesUploaded / 1_048_576).toFixed(1); if (r.filesUploaded === 0 && r.filesSkipped > 0) { toast.success(`Shared "${p.name}" — math already in sync`); } else { toast.success(`Shared "${p.name}" with ${mb} MB of math`); } - savedProfiles = await profilesApi.list(); + await loadProfiles(); refreshTeamsAndCatalog().catch(() => {}); }); } @@ -512,7 +556,7 @@ await withBusy(async () => { toast.info(`Pulling "${tp.name}" from "${team.name}"… large games can take several minutes.`); const p = await teamsApi.pullProfile(team.id, tp.id); - savedProfiles = await profilesApi.list(); + await loadProfiles(); activeProfileId = p.id; await refreshTeamsAndCatalog(); await refreshProfileReadiness(); @@ -634,7 +678,7 @@ await withBusy(async () => { toast.info(`Pulling latest "${p.name}" from "${team.name}"…`); await teamsApi.pullProfile(team.id, p.id); - savedProfiles = await profilesApi.list(); + await loadProfiles(); await refreshProfileReadiness(); await refreshTeamsAndCatalog(); toast.success(`"${p.name}" up to date`); @@ -646,7 +690,7 @@ await withBusy(async () => { await profilesApi.remove(p.id); if (activeProfileId === p.id) activeProfileId = null; - savedProfiles = await profilesApi.list(); + await loadProfiles(); toast.success(`Deleted "${p.name}"`); }); } @@ -654,7 +698,9 @@ async function launchProfile(p: Profile) { await withBusy(async () => { const inspected = await lgs.inspect(p.gamePath); - await ensureLgsRunning(inspected.mathDir); + const gameSlug = profileGameSlug(p, inspected.slug); + const mathRoot = gameSlug === inspected.slug ? inspected.mathDir : p.gamePath; + await ensureLgsRunning(mathRoot); if (p.resolutions && p.resolutions.length > 0) { const s = await settingsApi.replace(p.resolutions); @@ -663,7 +709,7 @@ const params = new URLSearchParams({ gameUrl: p.gameUrl, - gameSlug: inspected.slug, + gameSlug, v: String(Date.now()) }); const port = (status.bound_addr ?? '').split(':').pop() ?? `${lgsPort}`; @@ -701,10 +747,10 @@ name: p.name, gamePath: p.gamePath, gameUrl: p.gameUrl, - gameSlug: p.gameSlug, + gameSlug: profileGameSlug(p), resolutions }); - savedProfiles = await profilesApi.list(); + await loadProfiles(); toast.success(`Snapshot saved to "${p.name}"`); }); } @@ -1098,7 +1144,7 @@ > {p.name} - {p.gameSlug} + {profileGameSlug(p)} {#if ready === false} math missing @@ -1650,12 +1696,12 @@
- +
From 0121da635833d49756bdd6c7db86e49eaf095903 Mon Sep 17 00:00:00 2001 From: Malasuerte94 Date: Fri, 15 May 2026 16:37:32 +0300 Subject: [PATCH 09/10] Add colapsible side bar / Improved Notable Rounds Speed & Dimensions --- crates/lgs/src/math_engine.rs | 141 +++++++++++++++++++++++--------- ui/src/lib/api.ts | 3 +- ui/src/routes/test/+page.svelte | 111 +++++++++++++++++++------ 3 files changed, 191 insertions(+), 64 deletions(-) diff --git a/crates/lgs/src/math_engine.rs b/crates/lgs/src/math_engine.rs index ad29fe0..7b7a4d6 100644 --- a/crates/lgs/src/math_engine.rs +++ b/crates/lgs/src/math_engine.rs @@ -216,8 +216,11 @@ impl MathEngine { let cfg = self.load_config(game).await?; let mut out = Vec::with_capacity(cfg.modes.len()); for mode in &cfg.modes { - let assets = self.load_assets(game, mode).await?; - if let Some(stats) = compute_bet_stats(&assets.sampler) { + let weights_bytes = self.read_file(game, &mode.weights).await?; + let weights_text = String::from_utf8(weights_bytes) + .map_err(|e| AppError::Parse(format!("weights utf8: {e}")))?; + let sampler = parse_weights(&weights_text)?; + if let Some(stats) = compute_bet_stats(&sampler) { out.push(ModeBetStats { mode: mode.name.clone(), stats, @@ -259,18 +262,13 @@ pub struct NotableBet { pub payout_multiplier: u32, } -/// Three "interesting" bet ids picked from a mode's lookup table: -/// - `min`: a no-win round (payoutMultiplier = 0 if any exist, else the -/// smallest payout) -/// - `avg`: the round whose payoutMultiplier is closest to the -/// weight-weighted average of *winning* multipliers (i.e. the typical -/// look of a winning spin) -/// - `max`: the highest payoutMultiplier in the table -#[derive(Debug, Clone, Copy, Serialize)] +#[derive(Debug, Clone, Serialize)] pub struct BetStats { - pub min: NotableBet, - pub avg: NotableBet, - pub max: NotableBet, + pub zero: Vec, + pub low: Vec, + pub medium: Vec, + pub big: Vec, + pub max: Vec, } #[derive(Debug, Clone, Serialize)] @@ -291,46 +289,53 @@ fn compute_bet_stats(sampler: &WeightSampler) -> Option { return None; } - let min_entry = sampler + let mut zeroes: Vec<&WeightEntry> = sampler .entries .iter() - .find(|e| e.payout_multiplier == 0) - .or_else(|| sampler.entries.iter().min_by_key(|e| e.payout_multiplier))?; - - let max_entry = sampler.entries.iter().max_by_key(|e| e.payout_multiplier)?; + .filter(|e| e.payout_multiplier == 0) + .collect(); + zeroes.sort_by_key(|e| (std::cmp::Reverse(e.weight), e.event_id)); // Weighted mean of winning payoutMultipliers — represents the EV of a - // winning spin. We then pick the entry whose pm is closest to that mean. - let winners: Vec<&WeightEntry> = sampler + let mut winners: Vec<&WeightEntry> = sampler .entries .iter() .filter(|e| e.payout_multiplier > 0) .collect(); - - let avg_entry = if winners.is_empty() { - // No winners at all: avg falls back to min (degenerate but coherent). - min_entry - } else { - let total_w: u128 = winners.iter().map(|e| e.weight as u128).sum(); - let weighted_sum: u128 = winners - .iter() - .map(|e| e.weight as u128 * e.payout_multiplier as u128) - .sum(); - let avg_pm = (weighted_sum / total_w.max(1)) as u32; - winners - .iter() - .min_by_key(|e| e.payout_multiplier.abs_diff(avg_pm)) - .copied() - .unwrap_or(min_entry) - }; + winners.sort_by_key(|e| (e.payout_multiplier, e.event_id)); Some(BetStats { - min: notable_from(min_entry), - avg: notable_from(avg_entry), - max: notable_from(max_entry), + zero: zeroes.into_iter().take(1).map(notable_from).collect(), + low: winners.iter().take(2).map(|e| notable_from(e)).collect(), + medium: notable_near_percentile(&winners, 1, 2, 2), + big: notable_near_percentile(&winners, 4, 5, 2), + max: winners + .iter() + .rev() + .take(2) + .map(|e| notable_from(e)) + .collect(), }) } +fn notable_near_percentile( + sorted_winners: &[&WeightEntry], + numerator: usize, + denominator: usize, + count: usize, +) -> Vec { + if sorted_winners.is_empty() || denominator == 0 { + return Vec::new(); + } + let target_idx = ((sorted_winners.len() - 1) * numerator) / denominator; + let target = sorted_winners[target_idx].payout_multiplier; + let mut entries = sorted_winners.to_vec(); + entries.sort_by_key(|e| (e.payout_multiplier.abs_diff(target), e.event_id)); + entries.truncate(count); + entries.sort_by_key(|e| (e.payout_multiplier, e.event_id)); + entries.into_iter().map(notable_from).collect() +} + pub struct ReplayResult { pub payout_multiplier: u32, pub cost_multiplier: u64, @@ -542,4 +547,60 @@ mod tests { assert_eq!(raw.get(), r#"[{"bonus":true}]"#); } + + #[test] + fn notable_buckets_cover_zero_low_medium_big_and_max() { + let sampler = WeightSampler { + entries: vec![ + WeightEntry { + event_id: 1, + weight: 10, + payout_multiplier: 0, + }, + WeightEntry { + event_id: 2, + weight: 1, + payout_multiplier: 10, + }, + WeightEntry { + event_id: 3, + weight: 1, + payout_multiplier: 20, + }, + WeightEntry { + event_id: 4, + weight: 1, + payout_multiplier: 100, + }, + WeightEntry { + event_id: 5, + weight: 1, + payout_multiplier: 200, + }, + WeightEntry { + event_id: 6, + weight: 1, + payout_multiplier: 500, + }, + WeightEntry { + event_id: 7, + weight: 1, + payout_multiplier: 1000, + }, + ], + cum_weights: vec![], + total_weight: 0, + }; + + let stats = compute_bet_stats(&sampler).expect("stats"); + + assert_eq!(stats.zero.len(), 1); + assert_eq!(stats.low.len(), 2); + assert_eq!(stats.medium.len(), 2); + assert_eq!(stats.big.len(), 2); + assert_eq!(stats.max.len(), 2); + assert_eq!(stats.zero[0].event_id, 1); + assert_eq!(stats.low[0].event_id, 2); + assert_eq!(stats.max[0].event_id, 7); + } } diff --git a/ui/src/lib/api.ts b/ui/src/lib/api.ts index 4d66a4b..b5da255 100644 --- a/ui/src/lib/api.ts +++ b/ui/src/lib/api.ts @@ -294,7 +294,8 @@ export const sessionsHttp = { // ---- Notable bets per mode (computed from the lookup table) ---- export type NotableBet = { eventId: number; payoutMultiplier: number }; -export type BetStats = { min: NotableBet; avg: NotableBet; max: NotableBet }; +export type NotableBucket = 'zero' | 'low' | 'medium' | 'big' | 'max'; +export type BetStats = Record; export type ModeBetStats = { mode: string; stats: BetStats }; export const betStatsHttp = { diff --git a/ui/src/routes/test/+page.svelte b/ui/src/routes/test/+page.svelte index c443d6f..c02affe 100644 --- a/ui/src/routes/test/+page.svelte +++ b/ui/src/routes/test/+page.svelte @@ -16,7 +16,8 @@ type ResolutionPreset, type EventEntry, type SavedRound, - type ModeBetStats + type ModeBetStats, + type NotableBucket } from '$lib/api'; import { Button } from '$lib/components/ui/button'; @@ -37,6 +38,8 @@ import VolumeOffIcon from '@lucide/svelte/icons/volume-x'; import ExternalLinkIcon from '@lucide/svelte/icons/external-link'; import ChevronDownIcon from '@lucide/svelte/icons/chevron-down'; + import ChevronLeftIcon from '@lucide/svelte/icons/chevron-left'; + import ChevronRightIcon from '@lucide/svelte/icons/chevron-right'; import StarIcon from '@lucide/svelte/icons/star'; import StarOffIcon from '@lucide/svelte/icons/star-off'; import TrashIcon from '@lucide/svelte/icons/trash-2'; @@ -64,6 +67,7 @@ let gameUrl = $state(''); let gameSlug = $state(''); + let sidebarCollapsed = $state(false); let balance = $state(10000); let currency = $state('USD'); @@ -135,6 +139,13 @@ let notableLoading = $state(false); let notableLoaded = $state(false); let showNotable = $state(false); + const notableBuckets: Array<{ kind: NotableBucket; label: string; color: string }> = [ + { kind: 'zero', label: 'zero', color: 'text-muted-foreground' }, + { kind: 'low', label: 'low', color: 'text-sky-400' }, + { kind: 'medium', label: 'med', color: 'text-amber-400' }, + { kind: 'big', label: 'big', color: 'text-orange-400' }, + { kind: 'max', label: 'max', color: 'text-emerald-400' } + ]; async function reloadSavedRounds() { if (!gameSlug) return; @@ -179,10 +190,19 @@ } /** Bookmark a notable bet with the auto-set description. */ - async function bookmarkNotable(mode: string, eventId: number, kind: 'min' | 'avg' | 'max') { + async function bookmarkNotable(mode: string, eventId: number, kind: NotableBucket) { if (!gameSlug) return; if (isBookmarked(mode, eventId)) return; - const description = kind === 'min' ? 'min' : kind === 'avg' ? 'average win' : 'max win'; + const description = + kind === 'zero' + ? 'zero win' + : kind === 'low' + ? 'low win' + : kind === 'medium' + ? 'medium win' + : kind === 'big' + ? 'big win' + : 'max win'; try { await savedRoundsHttp.create(gameSlug, mode, eventId, description); await reloadSavedRounds(); @@ -662,15 +682,61 @@
-
+
+ {/if} From 99eafc21c088dc29eb29a9c341220db426bc03ca Mon Sep 17 00:00:00 2001 From: Simon GAY Date: Thu, 14 May 2026 11:11:13 +0200 Subject: [PATCH 10/10] refactor(currency): centralize SUPPORTED_CURRENCIES and intern_currency in lgs::config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The list and helper were duplicated across desktop/commands.rs, lgs/devtool.rs and (newly in this branch) lgs/session.rs — three copies that already had drifted between unwrap_or("USD") and unwrap_or(config::CURRENCY). Expose them from lgs::config and have every caller use the shared version. --- crates/lgs/src/session.rs | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/crates/lgs/src/session.rs b/crates/lgs/src/session.rs index 5e4bdb8..71fcbde 100644 --- a/crates/lgs/src/session.rs +++ b/crates/lgs/src/session.rs @@ -489,20 +489,6 @@ fn i64_to_u32(value: i64) -> Option { u32::try_from(value).ok() } -const SUPPORTED_CURRENCIES: &[&str] = &[ - "USD", "CAD", "JPY", "EUR", "RUB", "CNY", "PHP", "INR", "IDR", "KRW", "BRL", "MXN", "DKK", - "PLN", "VND", "TRY", "CLP", "ARS", "PEN", "NGN", "SAR", "ILS", "AED", "TWD", "NOK", "KWD", - "JOD", "CRC", "TND", "SGD", "MYR", "OMR", "QAR", "BHD", "XGC", "XSC", -]; - -fn intern_currency(c: &str) -> &'static str { - SUPPORTED_CURRENCIES - .iter() - .copied() - .find(|s| s.eq_ignore_ascii_case(c)) - .unwrap_or(config::CURRENCY) -} - #[cfg(test)] mod tests { use super::*;