SMP 0.24.0: /pay received counts toward the daily selling limit

Selling up to the cap on alts and paying it all to a main bypassed the daily sell
limit (reported by pointofpressure). A /pay now checks the receiver's remaining
daily_sell_coins and is refused past it; a payment made counts toward it. Trades
and auction sales are unaffected.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Ludovic MarieandClaude Opus 5.5 committed 2026-11-01 19:30:11 +01:00
1 parent 9ce6a3f9a0
commit 964325b819
6 files changed
+96 -5

No files matched your search

+1 -1
View File
@@ -4,7 +4,7 @@ version = 4
[[package]]
name = "horizon-mod-smp"
version = "0.23.0"
version = "0.24.0"
dependencies = [
"serde",
"serde_json",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "horizon-mod-smp"
version = "0.23.0"
version = "0.24.0"
edition = "2024"
license = "MIT"
publish = false
+3
View File
@@ -73,6 +73,9 @@ money and the room before anything moves.
copper, diamond, emerald, coal, redstone, lapis, wheat, wood and food chains at each start.
- Each player sells at most `daily_sell_coins` (2,000 coins) and `daily_sell_items` (640 of each item) a UTC day,
`/worth` and the shop show what is left, and `market.json` keeps the day's count across restarts.
- Money received with `/pay` counts toward the receiver's same `daily_sell_coins`: selling up to the limit on alts
and paying it all to a main is refused once the main's limit is reached ("can only receive N more today").
Trades (`/trade`) and auction house sales are not counted.
- Amounts are checked: no price above 1,000,000,000,000, no sum that overflows.
- The coinflip and the lottery give the winner the stakes (less `fee_percent`, 0 by default): they move money
between players and never make any.
+36 -2
View File
@@ -41,7 +41,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.23.0";
pub const VERSION: &str = "0.24.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).
@@ -434,6 +434,20 @@ const COMMANDS: [&str; 19] = [
"lottery", "treasury", "taxes", "ah", "bank", "jobs", "casino",
];
/// `/pay <player> <amount>`'s receiver (account, name) and amount, as
/// `Economy::pay` reads them; None for another command.
fn pay_target(e: &Economy, line: &str) -> Option<(u128, String, i64)> {
let [cmd, who, amount] = line.split_whitespace().collect::<Vec<_>>()[..] else {
return None;
};
if cmd != "pay" {
return None;
}
let amount = smp_economy::parse_amount(amount)?;
let (to, account) = e.find(who)?;
Some((to, account.name.clone(), amount))
}
/// The market's (sell, buy) for one item, as the contracts read it.
fn market_price(market: &Market, id: &str) -> Option<(i64, i64)> {
let p = market.price(id);
@@ -670,7 +684,27 @@ pub(in crate::game) fn perform(
let price = |id: &str| market_price(&smp.market, id);
smp.jobs.as_mut()?.command(e, &me, trades, &mut bag, &price, line)
})
.or_else(|| e.command(&me, &on, line))
.or_else(|| {
// A payment counts toward the receiver's daily selling
// limit (alts can't sell for a main past it).
let paid = pay_target(e, line);
if let Some((to, name, amount)) = &paid
&& let Err(left) = smp.market.may_receive(*to, *amount, now)
{
return Some(vec![Out::Error(format!(
"{name} can only receive {} more today: payments count toward the same daily limit as selling to the market.",
e.money(left)
))]);
}
let before = paid.as_ref().map(|(to, ..)| e.balance(*to));
let out = e.command(&me, &on, line);
if let (Some((to, _, amount)), Some(b)) = (paid, before)
&& e.balance(to) == b + amount
{
smp.market.count_received(to, now, amount);
}
out
})
.or_else(|| smp.market.command(e, &me, trades, &mut bag, line))
.or_else(|| {
let (out, a) = smp.trades.command(e, &me, trades, &online, now, line)?;
+1 -1
View File
@@ -1,6 +1,6 @@
[mod]
id = "smp"
version = "0.23.0"
version = "0.24.0"
priority = 100
# The commit of horizon main it was tested on. It uses only the native hook points (hooks 62 in
+54
View File
@@ -358,6 +358,32 @@ impl Market {
.then(|| cap.saturating_sub(self.sold(uuid, now).items.get(id).copied().unwrap_or(0)))
}
/// Whether `uuid` may be paid `amount` by another player now (`/pay`):
/// money received counts toward the same daily limit as selling to the
/// market, so selling on alts and paying it all to a main doesn't get
/// past it. Err: what they may still receive today.
pub fn may_receive(&self, uuid: u128, amount: i64, now: i64) -> Result<(), i64> {
match self.coins_left(uuid, now) {
Some(left) if amount > left => Err(left),
_ => Ok(()),
}
}
/// Counts `coins` paid to `uuid` (`/pay`) against today's limit.
pub fn count_received(&mut self, uuid: u128, now: i64, coins: i64) {
let day = now.div_euclid(DAY_MS);
let s = self.ledger.sold.entry(uuid_string(uuid)).or_default();
if s.day != day {
*s = Sold {
day,
..Sold::default()
};
}
s.coins = s.coins.saturating_add(coins.max(0));
self.ledger.sold.retain(|_, s| s.day == day);
self.dirty = true;
}
/// Counts a sale against today's limits.
fn count_sale(&mut self, uuid: u128, now: i64, id: &str, n: u32, coins: i64) {
let day = now.div_euclid(DAY_MS);
@@ -739,6 +765,34 @@ mod tests {
}
}
#[test]
fn money_received_by_pay_counts_toward_the_daily_selling_limit() {
let config = Config {
daily_sell_coins: 250,
..Config::default()
};
let (_e, mut m, _me, _dir) = setup_with("received", config);
let now = 1_793_577_600_000;
// An alt pays the main 200: fine, and it counts.
assert_eq!(m.may_receive(ALEX, 200, now), Ok(()));
m.count_received(ALEX, now, 200);
assert_eq!(m.coins_left(ALEX, now), Some(50));
// A second alt's 100 is refused: 50 left today, for sales or payments.
assert_eq!(m.may_receive(ALEX, 100, now), Err(50));
assert_eq!(m.may_receive(ALEX, 50, now), Ok(()));
// The next day the limit is whole again.
assert_eq!(m.may_receive(ALEX, 250, now + DAY_MS), Ok(()));
// No limit configured: anything goes.
let (_e, m, _me, _dir) = setup_with(
"unlimited",
Config {
daily_sell_coins: 0,
..Config::default()
},
);
assert_eq!(m.may_receive(ALEX, 1_000_000, now), Ok(()));
}
#[test]
fn selling_takes_the_items_then_pays_and_logs_both() {
let (mut e, mut m, me, dir) = setup("sell");