SMP 0.28.0: the casino's daily win cap (10,000 net a day, configurable)

A player whose net casino winnings today reach casino.daily_win_cap
(10,000, same as the loss cap) is refused new bets until 00:00 UTC, with
a message saying why. The round that crosses the cap is paid in full; a
blackjack double inside a hand already dealt is still allowed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Ludovic MarieandClaude Opus 5.5 committed 2026-11-01 22:43:50 +01:00
1 parent 5ba617927f
commit 31d77513fe
7 files changed
+61 -6

No files matched your search

+1 -1
View File
@@ -4,7 +4,7 @@ version = 4
[[package]]
name = "horizon-mod-smp"
version = "0.27.0"
version = "0.28.0"
dependencies = [
"serde",
"serde_json",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "horizon-mod-smp"
version = "0.27.0"
version = "0.28.0"
edition = "2024"
license = "MIT"
publish = false
+2 -1
View File
@@ -202,7 +202,8 @@ jackpot:
first two cards, no split; about 1% house edge with basic strategy.
Every draw is made by the server (ChaCha20 keyed from the system) before the animation starts. Bets go from `min_bet`
10 to `max_bet` 1000; a player may lose at most `daily_loss_cap` (10,000) a day (UTC, all games together), and
10 to `max_bet` 1000; a player may lose at most `daily_loss_cap` (10,000) a day (UTC, all games together) and win at most
`daily_win_cap` (10,000) net (once reached, no new bet until 00:00 UTC; the round that crosses it is paid in full), and
`cooldown_ms` (2000) passes between the end of a round and their next bet. A bet is held like a coinflip stake;
closing the menu or leaving never loses a round: a spin or a pull ends and is paid, a blackjack hand stands, a crash
bet is cashed out when its player leaves in flight (or given back before take-off). Wins are paid from the casino's
+1 -1
View File
@@ -43,7 +43,7 @@ use std::{path::PathBuf, sync::Mutex};
/// The mod's id, name and version.
pub const ID: &str = "smp";
pub const NAME: &str = "SMP";
pub const VERSION: &str = "0.27.0";
pub const VERSION: &str = "0.28.0";
/// `config.json` in `world/horizon-smp/`, written with its defaults the
/// first time, and again with the keys a newer version adds (values kept).
+1 -1
View File
@@ -1,6 +1,6 @@
[mod]
id = "smp"
version = "0.27.0"
version = "0.28.0"
priority = 100
# The commit of horizon it was tested on: it needs horizon#6106 (merged at 975c07d): native_inventory,
+54 -1
View File
@@ -48,6 +48,9 @@ pub struct Config {
/// What one player may lose in a day (UTC), all games together: bets
/// staked minus what they paid back.
pub daily_loss_cap: i64,
/// What one player may win in a day (UTC), all games together: paid back
/// minus bets staked. Once reached, no new bet until the day ends.
pub daily_win_cap: i64,
/// Between the end of a player's round and their next bet.
pub cooldown_ms: i64,
/// What the pot keeps for paying wins; above it, the Treasury's.
@@ -69,6 +72,7 @@ impl Default for Config {
min_bet: 10,
max_bet: 1000,
daily_loss_cap: 10_000,
daily_win_cap: 10_000,
cooldown_ms: 2000,
float: 20_000,
announce_profit: 5000,
@@ -358,6 +362,10 @@ impl Day {
pub fn lost(&self) -> i64 {
(self.wagered - self.paid).max(0)
}
pub fn won(&self) -> i64 {
(self.paid - self.wagered).max(0)
}
}
/// A win of the day.
@@ -510,6 +518,19 @@ impl Casino {
(self.config.daily_loss_cap - self.day(uuid, now).lost()).max(0)
}
/// Err once `uuid`'s winnings today reached the daily win cap.
fn win_cap(&self, economy: &Economy, uuid: u128, now: i64) -> Result<(), String> {
let won = self.day(uuid, now).won();
if won >= self.config.daily_win_cap {
return Err(format!(
"Daily win limit: you have won {} today, the most a day ({}). The casino opens again for you at 00:00 UTC.",
economy.money(won),
economy.money(self.config.daily_win_cap)
));
}
Ok(())
}
fn open_for_play(&self) -> Result<(), String> {
if !self.gambling || !self.config.enabled {
return Err("The casino is closed on this server.".into());
@@ -546,6 +567,7 @@ impl Casino {
wait as f64 / 1000.0
));
}
self.win_cap(economy, uuid, now)?;
let left = self.loss_left(uuid, now);
if amount > left {
return Err(format!(
@@ -792,10 +814,11 @@ impl Casino {
let c = &self.config;
let mut lines = vec![
format!(
"Casino: bets {} to {}; you may lose at most {} a day; {} s between rounds.",
"Casino: bets {} to {}; you may lose at most {} and win at most {} a day; {} s between rounds.",
economy.money(c.min_bet),
economy.money(c.max_bet),
economy.money(c.daily_loss_cap),
economy.money(c.daily_win_cap),
c.cooldown_ms as f64 / 1000.0
),
"Every draw is made by the server (ChaCha20 seeded by the system), before the animation starts.".into(),
@@ -1699,6 +1722,7 @@ pub(crate) mod tests {
let (mut e, mut c, dir) = setup("cost", 1_000_000);
c.config.cooldown_ms = 0;
c.config.daily_loss_cap = i64::MAX / 4;
c.config.daily_win_cap = i64::MAX / 4;
let players: Vec<u128> = (100..120).collect();
for (i, u) in players.iter().enumerate() {
e.seen(*u, &format!("P{i}"));
@@ -1801,6 +1825,35 @@ pub(crate) mod tests {
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn the_daily_win_cap_stops_new_bets() {
let (mut e, mut c, dir) = setup("wincap", 50_000);
c.config.daily_win_cap = 1000;
c.config.cooldown_ms = 0;
// Won 700 net: still under the cap.
let s = c.bet(&mut e, A, "Alex", Game::Roulette, 700, 0).unwrap();
c.settle(&mut e, &s, 1400, "won", 0);
assert_eq!(c.day(A, 0).won(), 700);
// Won 700 more: 1400 net, past the cap; the round that crossed it is paid in full.
let s = c.bet(&mut e, A, "Alex", Game::Roulette, 700, 0).unwrap();
c.settle(&mut e, &s, 1400, "won", 0);
assert_eq!(e.balance(A), 10_000 + 1400);
let err = c.bet(&mut e, A, "Alex", Game::Slots, 10, 0).unwrap_err();
assert!(
err.contains("Daily win limit") && err.contains("1,400"),
"{err}"
);
// Nothing was held for the refused bet.
assert_eq!((e.balance(A), e.held(A)), (11_400, 0));
// Another player isn't affected; a new day opens it again.
c.bet(&mut e, B, "Steve", Game::Roulette, 10, 0).unwrap();
assert!(
c.bet(&mut e, A, "Alex", Game::Roulette, 10, DAY_MS + 1)
.is_ok()
);
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn wins_come_from_the_pot_and_are_capped_by_it() {
let (mut e, mut c, dir) = setup("pot", 1000);
@@ -504,6 +504,7 @@ mod tests {
fn many_spins_keep_about_the_house_edge() {
let (mut e, mut c, dir) = setup("edge", 1_000_000_000);
c.config.daily_loss_cap = i64::MAX / 4;
c.config.daily_win_cap = i64::MAX / 4;
c.config.cooldown_ms = 0;
let mut rng = Rng::from_seed([3; 32]);
let mut now = 0;