mirror of
https://github.com/horizon-rust/horizon-mods.git
synced 2026-11-07 11:05:58 -10:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a53d9d286c | ||
|
|
b22a9c42a3 | ||
|
|
6ab145fce5 | ||
|
|
c240394cde | ||
|
|
d57056b937 |
No files matched your search
@@ -0,0 +1,24 @@
|
||||
id = "crossversion"
|
||||
kind = "native-mod"
|
||||
name = "Cross Version"
|
||||
summary = "Join servers on older Minecraft versions: picks the version per server, runs ViaProxy locally, and logs in with an account added to the proxy."
|
||||
description = "**Cross-version** lets Horizon join servers running an older Minecraft version. On a join it reads what version the server speaks, and if it isn't 26.3 it starts ViaProxy on a loopback port so the server is spoken to in its own version — a 26.3 server is never touched. The server list marks those servers joinable (green bars, version in green above the MOTD), Edit Server and Direct Connect carry a click-to-change version switch for servers whose status lies, and online-mode works by logging in with a Microsoft account you add to ViaProxy once from its settings page. Horizon itself never signs anything, so a joined server can't use your session elsewhere. It downloads ViaProxy (GPL-3.0, hash-pinned) on first use and needs Java 17+; nothing else."
|
||||
authors = ["JustKarkat"]
|
||||
owners = ["JustKarkat"]
|
||||
license = "MIT"
|
||||
source = "https://github.com/JustKarkat/horizon-crossversion/"
|
||||
icon = "https://github.com/JustKarkat/horizon-crossversion/blob/master/icon.png?raw"
|
||||
key = "RWTcPJHJYjPW0pV3lPwr45crCu0wJxW5HbDLc3fr3Eom1TzxO++kHOo4"
|
||||
|
||||
[[versions]]
|
||||
version = "0.7.0"
|
||||
published = "2026-11-02"
|
||||
game = "26.3"
|
||||
file = "crossversion-0.7.0.hnmod"
|
||||
sha256 = "281f45baeea2b65b091d5464c57eeba7b3d3be47eaddcbec6acf33975cf7ec40"
|
||||
signature = """
|
||||
untrusted comment: signature from minisign secret key
|
||||
RUTcPJHJYjPW0sZHADCefonT57e7aWgGLjF1DWTY6wS9TgmVVYfztD8u1sozK/UoTsXdbHjijRvSdmt5HHdowtIi4PFO0CT9EAU=
|
||||
trusted comment: timestamp:1793647010\tfile:crossversion-0.7.0.hnmod\thashed
|
||||
CZX329b7UHVbmCZAgb+Qfo4B1UMGXLH733SnAZh0sgqjrwm1j7uLYI8kHO44qwO0Np1NG1gpvqwzsiuPAMGKDg=="""
|
||||
changelog = "A version switch on Edit Server and Direct Connect: choose what version ViaProxy tells that server it is (auto, 1.8, 1.9, 1.12.2, 1.16.5, 1.20.1, 1.21.1, 1.21.4, 26.3), kept per address, for servers whose status reports the wrong version. Online-mode still logs in through the account added to ViaProxy; the client never signs."
|
||||
Generated
+1
-1
@@ -4,7 +4,7 @@ version = 4
|
||||
|
||||
[[package]]
|
||||
name = "horizon-mod-smp"
|
||||
version = "0.29.0"
|
||||
version = "0.30.1"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "horizon-mod-smp"
|
||||
version = "0.29.0"
|
||||
version = "0.30.1"
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
publish = false
|
||||
|
||||
+42
-99
@@ -8,7 +8,7 @@ Players install nothing: it speaks only what a stock Horizon client already show
|
||||
lines, vanilla chest menus the server fills with ordinary items (the trade menu); later sounds, titles, the action
|
||||
bar, boss bars and the sidebar).
|
||||
|
||||
This version: the economy with its Treasury and taxes, the auction house, the bank, the server market and its `/shop` menu, player trades, the coinflip, the lottery, places (spawn, homes, warps,
|
||||
This version: the economy, the auction house, the server market and its `/shop` menu, player trades, the coinflip, the lottery, places (spawn, homes, warps,
|
||||
teleport requests, the rules), land claims, and the scoreboard (a sidebar with your balance, playtime and the richest players, the Tab list's
|
||||
header and footer).
|
||||
|
||||
@@ -17,19 +17,14 @@ header and footer).
|
||||
| `/balance [player]`, `/bal` | your balance, or another player's |
|
||||
| `/pay <player> <amount>` | gives money to a player who has an account (anyone who has played since the mod was on) |
|
||||
| `/baltop [page]` | the richest players, 10 a page |
|
||||
| `/eco take <player> <amount> [reason]`, `/money take ...` | operators of level 3: a cheat's gains go into the Treasury, logged with the reason. Nobody gives or sets money (`/eco give` and `/eco set` say so) |
|
||||
| `/treasury`, `/treasury log` | the Treasury's balance, its income by tax and its spending (today and since it opened), its last movements |
|
||||
| `/treasury pay <player> <amount> <reason>` | operators of level 3: pays a player from the Treasury (an event prize, a bug refund, a grant), never more than it holds; everyone sees it |
|
||||
| `/jobs`, `/jobs deliver <n>` | the day's contracts: deliver items the state asks for, paid from the Treasury |
|
||||
| `/futures`, `/futures long\|short <item> <margin> [leverage]`, `/futures close <n>`, `/futures info <item>`, `/futures list` | perpetual futures on the market's prices: a chest menu of your positions (profit green, loss red, liquidation price), leverage up to 10x |
|
||||
| `/eco stats` | the money supply and where it sits, what the server made and destroyed today, taxes and fees, how evenly money is spread |
|
||||
| `/bank`, `/bank deposit <amount\|all>`, `/bank withdraw <amount\|all>` | your savings, which earn a small interest from the Treasury each night |
|
||||
| `/eco give <player> <amount> <reason>`, `/money give ...` | operators of level 3: makes money for a player (an event prize, a bug refund); everyone reads it with the reason, and the audit log keeps it |
|
||||
| `/eco take <player> <amount> [reason]`, `/money take ...` | operators of level 3: takes a cheat's gains out of the game, logged with the reason |
|
||||
| `/eco stats` | the money supply and where it sits, what the server made and destroyed today, how evenly money is spread |
|
||||
| `/ah` | the auction house: a chest menu of listings by category, cheapest a unit first; click one to buy it or bid; your listings (click to take one back) and your mail |
|
||||
| `/ah sell <price> [count]` | lists the stack in your hand (or part of it) at a fixed price, for `listing_hours` (48); the listing fee (1%, at least 1 coin) goes to the Treasury |
|
||||
| `/ah sell <price> [count]` | lists the stack in your hand (or part of it) at a fixed price, for `listing_hours` (48); listing is free |
|
||||
| `/ah auction <start> [hours]` | auctions the stack in your hand from a starting bid, for 1 to 24 hours (12) |
|
||||
| `/ah bid <listing> <amount>`, `/ah buy <listing>` | bids (the money is held until someone bids more or it ends), buys a fixed-price listing |
|
||||
| `/ah cancel <listing>`, `/ah claim`, `/ah search <words>`, `/ah mine`, `/ah mail` | takes back a listing nobody bid on; gives you your mail; the menu on a search, your listings, your mail |
|
||||
| `/eco taxes`, `/taxes` | the tax code and your own bill on Monday; `/eco taxes pay` pays land tax you owe |
|
||||
| `/shop` | the market as a chest menu: categories, a click buys one, a shift-click 64, a click on a stack of yours sells it, "Sell all" |
|
||||
| `/market [item]` | the moving market as a chest menu: categories, each item's price, its move over 1 h and 24 h and its volume; an item's page buys or sells 1, 16 or 64, sells all you carry, and gives its price chart as a map |
|
||||
| `/worth [item]` | an item's price, the same to buy and to sell (the one in your hand without a name), and what you may still sell today |
|
||||
@@ -70,7 +65,7 @@ money and the room before anything moves.
|
||||
- One price per item, to buy and to sell (since 0.29): a config that still has two (`buy` 400, `sell` 100) gets
|
||||
their geometric mean (200) at load, logged ("one price now"); `buy` or `sell` 0 still closes that side. A trade
|
||||
of N items is priced along the curve it moves the price on (below), so buying N and selling them back gives back
|
||||
exactly what was paid, less the tax: a round trip never earns (tested for 1, 64 and past the minute's band, and
|
||||
exactly what was paid (less whole-coin rounding): a round trip never earns (tested for 1, 64 and past the minute's band, and
|
||||
over thousands of random trades with the drift in between).
|
||||
- No chain of recipes turns what the market sells into something it buys for more: the cheapest way to get each item
|
||||
from the market (bought, or crafted, smelted, cut from bought items, through any number of recipes) is worked out,
|
||||
@@ -86,8 +81,7 @@ money and the room before anything moves.
|
||||
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.
|
||||
- The coinflip and the lottery give the winner all the stakes: they move money between players and never make any.
|
||||
|
||||
Default prices (coins; "buy only": the market sells it but doesn't buy it, "sell only" the other way):
|
||||
|
||||
@@ -142,22 +136,20 @@ when selling: one diamond at 200 costs 201 and sells for 199, 64 cobblestone at
|
||||
exact path of the purchase. All trades together move an item at most `max_minute_move_percent` (5%) a minute from
|
||||
where it started that minute: past that, a purchase is cut short and a sale keeps the rest ("sell them in a
|
||||
minute"). The factor goes back to 1 with a half-life of `half_life_hours` (6) and stays within `min_factor` (0.6)
|
||||
and `max_factor` (1.6), where the rest of a trade goes at that price. A `tax_percent` (2%) of each sale and purchase
|
||||
goes to the Treasury, on its own line of the receipt ("Sold 3 diamond at 198 coins each: 595 coins." then "Tax 2%:
|
||||
-12 coins. You get 583 coins."). Items a player bought from the market in the last `round_trip_hours` (24) can't be
|
||||
and `max_factor` (1.6), where the rest of a trade goes at that price. There is no tax on trades (an older config's
|
||||
`tax_percent` is ignored): the receipt is the price ("Sold 3 diamond at 198 coins each: 595 coins."). Items a player bought from the market in the last `round_trip_hours` (24) can't be
|
||||
sold back to it. (`max_trade_impact_percent` and `max_player_impact_percent` are no longer used: the curve and the
|
||||
minute's band do their work.) History: one point a minute for 24 hours and one an hour for 30 days (the mid price, the
|
||||
items traded and those sold), in `market_prices.json`, written by a thread every 5 minutes and when the world closes.
|
||||
At the first minute of each UTC day, the day's three biggest risers and fallers (at least 1%) and news from real
|
||||
activity ("Diamond supply up 40% today", sales against the day before's, at least `news_percent` and
|
||||
`news_min_volume`) go to everyone's chat and to `web/market.json` for Kira to post. `enabled: false` keeps the
|
||||
fixed prices of 0.23. For other mods (the futures): `Market::spot`, `twap(id, minutes, now)`, `mark_price(id, now)`
|
||||
(the 10-minute TWAP from at least 5 points), `volume(id, now, hours)`, `history(id, Span)`.
|
||||
fixed prices of 0.23. For other mods: `Market::spot`, `volume(id, now, hours)`, `history(id, Span)`.
|
||||
|
||||
**`/market`.** A 6-row chest menu: the shop's categories and the day's movers; a category's items, each with its
|
||||
price, its change over 1 h and 24 h (green ▲, red ▼) and what was traded in 24 h; an item's page with its
|
||||
base price, the tax and what you may still sell today, buttons to buy or sell 1, 16 and 64 (what they come to
|
||||
along the curve, and the tax), sell all of it you carry, and the price chart over 24 hours or 7 days, given as a filled map (128 × 128,
|
||||
base price and what you may still sell today, buttons to buy or sell 1, 16 and 64 (what they come to
|
||||
along the curve), sell all of it you carry, and the price chart over 24 hours or 7 days, given as a filled map (128 × 128,
|
||||
drawn by the server: the curve green when it rose, red when it fell, the price, the change, the low and high). A
|
||||
chart map is drawn again each minute for whoever carries it, and once a minute the open menus follow the prices.
|
||||
`/market <item>` opens the item's page. `/worth` adds the item's moves and volume.
|
||||
@@ -180,14 +172,14 @@ the trade, and swaps everything in one step, or nothing (and both must confirm a
|
||||
adventure players only.
|
||||
|
||||
**Coinflips.** Off by default: the owner turns them on in `config.json` (`gambling`: `enabled`, `min_bet` 10,
|
||||
`max_bet` 1000, `cooldown_seconds` 30, `offer_seconds` 60, `fee_percent` 0). A stake is held from the offer, in
|
||||
`max_bet` 1000, `cooldown_seconds` 30, `offer_seconds` 60). A stake is held from the offer, in
|
||||
`economy.json` with the balances, until the coin falls; an offer cancelled, not taken in time, or whose player
|
||||
left gives it back, and so does the next start for one the server stopped with. The coin is a fresh random key from
|
||||
the system, never the world's random. Only the in-game currency is staked and won.
|
||||
|
||||
**The lottery.** Part of `gambling` (`ticket_price` 10, `max_tickets` 100 a player a draw, `lottery_seconds` 3600
|
||||
from a round's first ticket to its draw). Tickets are held like coinflip stakes; at the draw one ticket is picked at
|
||||
random among all sold (more tickets, more chances), its owner gets the pot (less `fee_percent`), and everyone hears
|
||||
random among all sold (more tickets, more chances), its owner gets the whole pot, and everyone hears
|
||||
it. A round with a single player, or one the server stopped with, gives every ticket back.
|
||||
|
||||
**The casino.** On with `gambling.enabled` (and `casino.enabled`, true by default). Every game is a chest menu any
|
||||
@@ -211,26 +203,25 @@ Every draw is made by the server (ChaCha20 keyed from the system) before the ani
|
||||
`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
|
||||
pot (`economy.json`'s pool `casino`), never with new money: a win larger than the pot is paid up to it. Once an hour
|
||||
the pot above `float` (20,000) goes to the Treasury (`casino_profit`); below it, the Treasury tops it up with what it
|
||||
holds (`casino_float`). A win of `jackpot_multiple` (50) times the stake, or of `announce_profit` (5000) coins, is
|
||||
pot (`economy.json`'s pool `casino`): a win larger than the pot is paid up to it. A new casino's pot gets `float`
|
||||
(20,000) once, made for it (`casino_float`; an older pot only what it lacks of it), then keeps every lost bet: the
|
||||
house edge stays in the pot, out of the players' hands. A win of `jackpot_multiple` (50) times the stake, or of `announce_profit` (5000) coins, is
|
||||
told to everyone. Settings in `config.json`'s `casino`, also `crash_edge_percent` (4) and `crash_max` (100).
|
||||
|
||||
**Places.** A teleport waits `warmup_seconds` (3) in `config.json`'s `places`; a player who moves or is hurt meanwhile
|
||||
stays where they are, and `cooldown_seconds` (10) passes between two of one player's teleports. `/tpa` takes the
|
||||
stays where they are, and `cooldown_seconds` (10) passes between two of one player's teleports. Teleports are free (an older config's
|
||||
`places.fees` is ignored). `/tpa` takes the
|
||||
asker to where the other player is when the warmup ends. Homes grow with the time played on the server since the mod
|
||||
was on (`homes`: `[hours, homes]`, by default 1 home, 2 from 5 hours, 3 from 20, 5 from 100). `rules` holds `/rules`'
|
||||
lines. Teleports are vanilla's `tp`, run as the server (`execute in <level> run tp`), so they cross levels and land
|
||||
as any teleport does. Teleports cost a fee to the Treasury (`places.fees`: `spawn` 0, `home` 5, `warp` 10, `back` 10,
|
||||
`tpa` 10, paid by the one who moves), logged as `travel_fee`. It is checked when the command runs and paid when the
|
||||
teleport happens: one called off (moved, hurt, the money spent meanwhile) costs nothing and leaves `/back` and the
|
||||
cooldown as they were. The spawn stays free so a lost or new player can always get back.
|
||||
as any teleport does.
|
||||
|
||||
**Claims.** In a claimed chunk, only its owner, the players they trust and operators of level 3 break blocks or right
|
||||
click blocks: doors, chests, levers, buttons, placing a block, a bucket or flint on a block. Anyone else's click does
|
||||
nothing, and the action bar says whose land it is. The chunks a player may hold grow with the time played here
|
||||
(`claims.chunks`: `[hours, chunks]`, by default 4, 16 from 5 hours, 36 from 20, 64 from 100); `claims.enabled`
|
||||
turns them off. The world stays out too: no water or lava flows, no fire spreads or burns, and no piston pushes into
|
||||
turns them off. `claims.free_radius` (0, none) keeps the land within that many blocks of 0 0, in every dimension, free
|
||||
for all: no one claims a chunk that touches it, and claims already there are dropped at start (each logged). The world stays out too: no water or lava flows, no fire spreads or burns, and no piston pushes into
|
||||
a claim from land that isn't its owner's (or a player's they trust), and explosions break no claimed block, whoever
|
||||
lit them. Buckets are guarded where they fill or empty, and hitting the claim's item frames, armour stands,
|
||||
paintings, boats, minecarts and animals does nothing for others (`claims.protected_entities`, entity ids; monsters
|
||||
@@ -264,82 +255,34 @@ limited. All of it is looked at once a second.
|
||||
leave the seller's inventory at once; what doesn't sell, what an auction's winner gets, and what didn't fit in a
|
||||
buyer's inventory wait in the player's mail (`/ah claim`, or the menu's mail page; as much as fits, the rest stays).
|
||||
A purchase takes the whole listing: the listing leaves first, the items move, then the money (the seller gets the
|
||||
price less VAT, which goes to the Treasury); of two players who buy at once (the game runs every click on one
|
||||
whole price); of two players who buy at once (the game runs every click on one
|
||||
thread), the second finds it gone. Bids wait in the `auction` pool: a higher bid gives the last one back at once,
|
||||
raising one's own only holds the difference, and a bid in the last `snipe_seconds` (60) moves the end back to
|
||||
60 seconds away. When an auction ends, the seller is paid (less VAT) and the items go to the winner's mail. Sellers
|
||||
60 seconds away. When an auction ends, the seller is paid the winning bid and the items go to the winner's mail. Sellers
|
||||
and outbid players who are offline find a note when they come back. Everything (listings, bids, mail, notes) is
|
||||
written in `economy.json` with the balances, in one step: a crash can't keep the money and lose the items, or the
|
||||
reverse. Only plain items for now (not worn, named or enchanted); whole stacks with their enchantments wait for
|
||||
Horizon's `native_give_stack` to be stamped. Survival and adventure players only. `config.json`'s `auction`:
|
||||
`enabled`, `listing_fee_percent` (1), `max_listings` (8 a player), `listing_hours` (48), `auction_hours` (12),
|
||||
`enabled`, `max_listings` (8 a player), `listing_hours` (48), `auction_hours` (12),
|
||||
`auction_hours_max` (24), `bid_step_percent` (5: the smallest raise), `snipe_seconds` (60). Ended listings are
|
||||
looked at once a second (a pass over the open listings).
|
||||
|
||||
**The Treasury and taxes.** Nobody can make money, operators included: money enters the game only through the daily
|
||||
reward and what the market buys from players (later, paid contracts, from the Treasury). Every tax, fee and house edge
|
||||
goes into the Treasury, a pool in `economy.json` that belongs to no player (not in `/baltop` or the sidebar), and
|
||||
operators pay only from it (`/treasury pay`, with a reason everyone sees, logged as `grant`). A refund for a bug is
|
||||
such a payment, never new money. `/treasury` shows its balance, its income by kind and its spending; `/treasury log`
|
||||
its last 50 movements. The tax code (`config.json`'s `taxes`):
|
||||
- VAT (`vat_percent`, 5): on sales between players (the auction house, next), paid by the seller from the price.
|
||||
- Land tax, every Monday 00:00 UTC: `land_tax_per_chunk` (5 coins) a week per claimed chunk past the first
|
||||
`land_free_chunks` (4). What a player can't pay is owed: no new claim until it's paid (`/eco taxes pay`, or the
|
||||
next Monday's collection). Nothing is ever unclaimed for it.
|
||||
- Wealth tax, every Monday: `wealth_brackets` `[from, percent]`, by default 1% of a balance's part above 50,000,
|
||||
2% above 250,000 and 3% above 1,000,000 a week, on the balance and bank savings together (paid from the
|
||||
balance). On 2026-11-01 the richest player held 11,804 coins: nobody pays it
|
||||
yet; it keeps fortunes from growing without bound as the economy does.
|
||||
- Games of chance: the coinflip's and the lottery's `fee_percent` goes to the Treasury.
|
||||
The week's collection is looked at once a second (one comparison) and runs once a week; a restart never collects
|
||||
twice (the week collected is kept with the balances). `enabled: false` turns the weekly taxes off.
|
||||
**Money in and out.** Money enters the game through what the market buys from players, the daily reward, the
|
||||
casino's float (once) and operators' `/eco give` (with a reason everyone reads, logged as `eco_give`). It leaves
|
||||
through what the market sells and operators' `/eco take`. There are no fees, taxes, bank, contracts or futures: no
|
||||
VAT, land or wealth tax, no tax on market trades, no teleport, auction or coinflip fee. A world from before 0.30 is
|
||||
cleaned up at its first start: bank savings and the margins of open futures positions go back to their owners
|
||||
(`bank_closed`, `futures_closed`), and the Treasury, its insurance fund, contracts and land tax owed are dropped.
|
||||
`config.json`'s old `taxes`, `bank`, `jobs` and `futures` sections and the `fee_percent` and `listing_fee_percent`
|
||||
keys are ignored.
|
||||
|
||||
**The bank.** `/bank` shows your savings; `/bank deposit <amount|all>` and `/bank withdraw <amount|all>` move coins
|
||||
between your wallet and the bank (the `bank` pool), free and at any time. Interest (`bank.daily_basis_points`, 10:
|
||||
0.1% a day) is paid at 00:00 UTC from the Treasury, on the lowest amount saved since the last payment (a deposit made
|
||||
just before midnight earns nothing that night), on at most `max_interest_on` (10,000) coins of an account; a player
|
||||
saves at most `max_deposit` (25,000). One night's interest for everyone is at most `max_treasury_percent` (5) of the
|
||||
Treasury, shared out pro rata when that's short: the bank never makes money, it hands back part of the taxes to
|
||||
those who keep coins out of circulation. Interest stays saved (logged as `bank_interest`); deposits and withdrawals
|
||||
are `bank_deposit` and `bank_withdraw`. No loans: their collateral (items) can't be held safely here, and a loan
|
||||
without it is free money for an alt that never pays back. Savings are kept in `economy.json` (`ext.bank`).
|
||||
|
||||
**Contracts.** Each day at 00:00 UTC the state posts `jobs.contracts_per_day` (4) contracts drawn from `jobs.items`
|
||||
(farm, mine and mob produce the market buys): "deliver 64 wheat for 128 coins", announced to everyone. `/jobs` lists
|
||||
them; `/jobs deliver <n>` takes `batch` (64) plain items from the inventory (they leave the game) and pays at once from
|
||||
the Treasury, logged as `contract <count> <item>`. A delivery pays the market's sell price plus `premium_percent`
|
||||
(30), and never more than `ask_percent` (90) of what the market asks for the item, checked again at delivery as
|
||||
prices move: buying from the market to deliver never pays. Each contract takes `deliveries` (8) for the whole
|
||||
server, a player makes at most `max_per_player` (2) a day, survival or adventure only, and one day's contracts cost
|
||||
at most `budget_percent` (10) of the Treasury when posted (an empty Treasury posts none). Never AFK income: only a
|
||||
command with the items pays. The board is kept in `economy.json` (`ext.jobs`): a restart neither posts twice nor
|
||||
loses a delivery.
|
||||
|
||||
**Futures.** `/futures long|short <item> <margin> [leverage]` opens a perpetual position on an item's market price:
|
||||
its size is margin × leverage at the mark, in items. The mark is the market's 10-minute average of the mid
|
||||
(`Market::mark_price`/`twap`), never the last trade, so one player can't move it in one go; an item needs 5 minutes of
|
||||
prices first. Leverage: 2x for any traded item, +1x per `volume_per_leverage` (500) items the market traded in 24 h,
|
||||
up to `max_leverage` (10x). The liquidation price is shown when it opens and in the menu. Each minute (the market's
|
||||
price timer, never per tick) positions are marked: one whose equity falls to `maintenance_percent` (5%) of its size
|
||||
is liquidated, online or not, and its whole margin goes to the insurance fund (the player is told when back; a
|
||||
margin of `announce_margin` (1,000) or more is told to everyone). A player never loses more than the margin: a move
|
||||
past it is the fund's (counted as `gaps`). The house is every trade's other side through the insurance fund (part of
|
||||
the Treasury): profits come out of it, losses go in, and a position that would raise the house's net exposure past
|
||||
what the fund could pay in a `stress_percent` (30%) move is refused. Fees `fee_percent` (0.2%) of the size, to open
|
||||
and to close, go to the Treasury. Funding, each hour: the side with more open interest pays the fund up to
|
||||
`funding_percent_hour` (0.05%) of its size, in proportion to the imbalance, from its margins. Caps: `max_player_item`
|
||||
(20,000 coins of size on one item) and `max_player_total` (50,000), open interest on an item at most
|
||||
`max_item_volume_days` (3) days of its market volume. Positions and their margins (the `futures` pool) are in
|
||||
`economy.json` (`ext.futures`), written with the money: a restart keeps them and never liquidates twice.
|
||||
|
||||
**The report.** `/eco stats` (anyone) shows the money supply (wallets, savings and money held, the Treasury, the
|
||||
insurance fund, each pool), what the server made today (market sales, the daily reward) and destroyed (market
|
||||
purchases, operators' `/eco take`), the day's taxes and fees, and how evenly money is spread (Gini over players
|
||||
**The report.** `/eco stats` (anyone) shows the money supply (wallets and money held, each pool, the casino's pot),
|
||||
what the server made today (market sales, the daily reward, `/eco give`) and destroyed (market purchases, operators'
|
||||
`/eco take`), and how evenly money is spread (Gini over players
|
||||
with money, the richest ten's share). At 00:00 UTC everyone reads the day before in one line (`report.daily_report`).
|
||||
A snapshot of each day is kept 60 days (`report.history_days`) in `economy.json` (`ext.report`). Once a minute
|
||||
`world/horizon-smp/web/economy.json` (`report.web_json`) is written off the tick thread for the site's /hub:
|
||||
`{"schema": 1, "generated": <ms>, "currency", "today", "pools", "minted_today", "burned_today", "treasury": {balance,
|
||||
insurance, income, spent, income_today, spent_today, log}, "taxes": {rates, last_collection}, "days": [...],
|
||||
`{"schema": 1, "generated": <ms>, "currency", "today", "pools", "minted_today", "burned_today", "days": [...],
|
||||
"daily_report": "<yesterday's line>"}`. The server's Discord relay posts only chat, joins, leaves and deaths: the
|
||||
daily line reaches Discord through whoever reads that file (Kira).
|
||||
|
||||
@@ -356,18 +299,18 @@ folder is untouched until the mod first runs on it; it changes no block and no p
|
||||
| File | Holds |
|
||||
|---|---|
|
||||
| `config.json` | `currency` (default `coins`), `starting_balance` (default 0), `market`: `enabled`, `items` (each item's `buy`, what the market asks, and `sell`, what it pays, 0 closing that side), `daily_sell_coins` (2000) and `daily_sell_items` (640), 0 for no limit; `shop`: `enabled`, `title`, `categories` (`name`, `icon`, `items`); `web`: `url` (Kira's `/smp/ingest`) and `token` (empty: nothing is sent), where the pages of `web/` go; written with the defaults the first time, and again when a newer version adds keys (your values kept) |
|
||||
| `economy.json` | balances by player UUID, with the name last seen; `held`: coinflip stakes and lottery tickets being decided; `pools`: the Treasury, its insurance fund and other system pools; `treasury`: its income and spending by kind and its last movements; `ext`: other parts' state written in the same step as the money (`tax`: the week collected, land tax owed; `auction`: the listings, bids, mail and notes) |
|
||||
| `economy.json` | balances by player UUID, with the name last seen; `held`: coinflip stakes and lottery tickets being decided; `pools`: the casino's pot and the auction house's bids; `ext`: other parts' state written in the same step as the money (`auction`: the listings, bids, mail and notes) |
|
||||
| `places.json` | the spawn, the warps, each player's homes, and the seconds each player has played (written once a minute) |
|
||||
| `nicks.json` | each player's nickname and when they last changed it |
|
||||
| `staff.log` | one line per `/invsee` or `/endersee` menu in which something moved: who, whose, what was taken and put |
|
||||
| `market.json` | what each player sold today (coins and items), for the daily limits |
|
||||
| `market_prices.json` | each item's price factor and history (minutes for 24 h, hours for 30 days: `[time, mid, traded, sold]`), what each player bought in the last day (no selling it back), the day's summary |
|
||||
| `web/market.json` | for the website (`/hub/market`, through Kira), written by a thread each minute prices move: `schema` 1, `generated`, `currency`, `tax_percent`, `taxes_collected`, `daily_summary` (`day`, `lines`), `items` (`id`, `name`, `category`, `price` (the one price; `buy` and `sell` repeat it on the sides the market trades, for older readers), `mid`, `base`, `change_1h`, `change_24h`, `volume_24h`, `day`: 5-minute points, `month`: hours, each `{step, t, price, volume}`). When `web.token` is set, the same thread POSTs each page of `web/` that changed (gzipped, `Authorization: Bearer <token>`) to `<web.url>/<name>`: Kira keeps the last one and serves it to the site; the server needs no inbound access and Kira no key to it |
|
||||
| `web/market.json` | for the website (`/hub/market`, through Kira), written by a thread each minute prices move: `schema` 1, `generated`, `currency`, `tax_percent` (always 0, for older readers), `daily_summary` (`day`, `lines`), `items` (`id`, `name`, `category`, `price` (the one price; `buy` and `sell` repeat it on the sides the market trades, for older readers), `mid`, `base`, `change_1h`, `change_24h`, `volume_24h`, `day`: 5-minute points, `month`: hours, each `{step, t, price, volume}`). When `web.token` is set, the same thread POSTs each page of `web/` that changed (gzipped, `Authorization: Bearer <token>`) to `<web.url>/<name>`: Kira keeps the last one and serves it to the site; the server needs no inbound access and Kira no key to it |
|
||||
| `presence.json` | the players who have joined (no welcome again), and the day each last got the daily reward |
|
||||
| `board.json` | the players who hid the sidebar (`/sidebar off`) |
|
||||
| `casino.json` | the day's ledger: each player's bets, wins and rounds today (the daily loss cap), the day's biggest wins, each game's totals, the pot's last sweep |
|
||||
| `claims.json` | each claimed chunk (`<dimension> <chunk x> <chunk z>`) with its owner, and whom each owner trusts |
|
||||
| `audit.log` | one JSON line per change, written before the change: `time` (ms), `kind` (`open`, `pay`, `grant`, `land_tax`...), `amount`, `from`/`to` with their balance before, `by` for an operator, `to_pool`/`from_pool` with `pool_before` and `reason` for a pool's movement; a trade's line (`trade`: both players, their items and money) comes before its swap, `trade_failed` after one that didn't go through |
|
||||
| `audit.log` | one JSON line per change, written before the change: `time` (ms), `kind` (`open`, `pay`, `grant`, `ah_listing_fee`...), `amount`, `from`/`to` with their balance before, `by` for an operator, `to_pool`/`from_pool` with `pool_before` and `reason` for a pool's movement; a trade's line (`trade`: both players, their items and money) comes before its swap, `trade_failed` after one that didn't go through |
|
||||
|
||||
`economy.json` is written whole to a temporary file, flushed and renamed over the old one, at most once a server tick
|
||||
and when the world closes: a crash leaves the old file or the new one. If it can't be read, the economy stays off (the
|
||||
@@ -385,8 +328,8 @@ action bar (API 21), `native_block_spread`, `native_piston_move`, `native_explos
|
||||
|
||||
| File | What it is |
|
||||
|---|---|
|
||||
| `mixins/smp/horizon-mixin.toml` | The bundle: twenty-eight modules and the HEAD hooks |
|
||||
| `mixins/smp/smp_store.rs`, `smp_economy.rs`, `smp_market.rs`, `smp_recipes.rs`, `smp_shop.rs`, `smp_nick.rs`, `smp_trade.rs`, `smp_games.rs`, `smp_lottery.rs`, `smp_places.rs`, `smp_claims.rs`, `smp_board.rs`, `smp_presence.rs`, `smp_tax.rs`, `smp_auction.rs`, `smp_bank.rs`, `smp_report.rs`, `smp_jobs.rs`, `smp_market_prices.rs`, `smp_market_menu.rs`, `smp_market_chart.rs`, `smp_invsee.rs`, `smp_futures.rs` | The rules, no game types (a trade's stacks are a type parameter): added to the game as `game::smp_store`, `game::smp_economy`, `game::smp_market`, `game::smp_recipes`, `game::smp_shop`, `game::smp_nick`, `game::smp_trade`, `game::smp_games`, `game::smp_lottery`, `game::smp_places`, `game::smp_claims`, `game::smp_board`, `game::smp_presence`, `game::smp_tax`, `game::smp_auction`, `game::smp_bank`, `game::smp_report`, `game::smp_jobs`, `game::smp_market_prices`, `game::smp_market_menu`, `game::smp_market_chart`, `game::smp_invsee`, `game::smp_futures`; also this crate's modules (`src/lib.rs`), for their tests |
|
||||
| `mixins/smp/horizon-mixin.toml` | The bundle: twenty-seven modules and the HEAD hooks |
|
||||
| `mixins/smp/smp_store.rs`, `smp_economy.rs`, `smp_market.rs`, `smp_recipes.rs`, `smp_shop.rs`, `smp_nick.rs`, `smp_trade.rs`, `smp_games.rs`, `smp_lottery.rs`, `smp_places.rs`, `smp_claims.rs`, `smp_board.rs`, `smp_presence.rs`, `smp_auction.rs`, `smp_report.rs`, `smp_market_prices.rs`, `smp_market_menu.rs`, `smp_market_chart.rs`, `smp_invsee.rs` | The rules, no game types (a trade's stacks are a type parameter): added to the game as `game::smp_store`, `game::smp_economy`, `game::smp_market`, `game::smp_recipes`, `game::smp_shop`, `game::smp_nick`, `game::smp_trade`, `game::smp_games`, `game::smp_lottery`, `game::smp_places`, `game::smp_claims`, `game::smp_board`, `game::smp_presence`, `game::smp_auction`, `game::smp_report`, `game::smp_market_prices`, `game::smp_market_menu`, `game::smp_market_chart`, `game::smp_invsee`; also this crate's modules (`src/lib.rs`), for their tests |
|
||||
| `mixins/smp/glue.rs` | The state for the open world and the edges to the game's types (`game::smp_glue`) |
|
||||
| `mixins/smp/{register,command,tick,lifecycle,menu_clicked,menu_closed,destroy_block,right_click_block,block_spread,piston_move,explosion_detonate,fill_bucket,attack_entity}.rs` | One small helper per hook |
|
||||
| `mixins/smp/smp_casino.rs`, `smp_casino_roulette.rs`, `smp_casino_slots.rs`, `smp_casino_crash.rs`, `smp_casino_blackjack.rs` | The casino's rules, menus and effects (`game::smp_casino*`) |
|
||||
|
||||
@@ -132,7 +132,7 @@ pub(in crate::game) fn clicked(
|
||||
}
|
||||
|
||||
/// The casino's share of the server's tick: frames, rounds that end, the
|
||||
/// pot's hourly sweep. The console commands, for after the lock.
|
||||
/// pot's float (once). The console commands, for after the lock.
|
||||
pub(in crate::game) fn tick(
|
||||
game: &mut Game<'_>,
|
||||
casino: &mut Casino,
|
||||
|
||||
+26
-324
@@ -10,9 +10,6 @@
|
||||
use crate::game::{
|
||||
Game,
|
||||
smp_auction::{self, AuctionHouse},
|
||||
smp_bank::{self, Bank},
|
||||
smp_futures::{self, Futures},
|
||||
smp_jobs::{self, Jobs},
|
||||
smp_report::{self, Report},
|
||||
native_server::{NativeCommand, NativeServerPlayer, NativeSidebar},
|
||||
smp_board::{self, Board, Line},
|
||||
@@ -30,7 +27,6 @@ use crate::game::{
|
||||
smp_nick::{self, Names as _, Nicks},
|
||||
smp_shop::{self, Shop},
|
||||
smp_store,
|
||||
smp_tax::{self, Taxes},
|
||||
smp_trade::{self, Act, Cell, Trades},
|
||||
};
|
||||
use horizon_client::{
|
||||
@@ -43,7 +39,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.29.0";
|
||||
pub const VERSION: &str = "0.30.1";
|
||||
|
||||
/// `config.json` in `world/horizon-smp/`, written with its defaults the
|
||||
/// first time, and again with the keys a newer version adds (values kept).
|
||||
@@ -61,12 +57,8 @@ pub struct Config {
|
||||
pub board: smp_board::Config,
|
||||
pub presence: smp_presence::Config,
|
||||
pub nicks: smp_nick::Config,
|
||||
pub taxes: smp_tax::Config,
|
||||
pub auction: smp_auction::Config,
|
||||
pub bank: smp_bank::Config,
|
||||
pub report: smp_report::Config,
|
||||
pub jobs: smp_jobs::Config,
|
||||
pub futures: smp_futures::Config,
|
||||
pub web: WebPush,
|
||||
}
|
||||
|
||||
@@ -95,18 +87,10 @@ pub struct Smp {
|
||||
dir: PathBuf,
|
||||
/// An error when `economy.json` can't be read: the commands say so.
|
||||
economy: Result<Economy, String>,
|
||||
/// The tax code and its weekly collection (None with the economy off).
|
||||
taxes: Option<Taxes>,
|
||||
/// The auction house (None with the economy off).
|
||||
auction: Option<AuctionHouse>,
|
||||
/// Savings and their interest (None with the economy off).
|
||||
bank: Option<Bank>,
|
||||
/// `/eco stats`, the daily report, `web/economy.json`.
|
||||
report: Option<Report>,
|
||||
/// The day's contracts (None with the economy off).
|
||||
jobs: Option<Jobs>,
|
||||
/// Positions on the market's prices (None with the economy off).
|
||||
futures: Option<Futures>,
|
||||
market: Market,
|
||||
shop_config: smp_shop::Config,
|
||||
/// The `/shop` menus open.
|
||||
@@ -194,23 +178,20 @@ fn open(dir: PathBuf) -> Smp {
|
||||
if let Err(e) = &economy {
|
||||
log::error!("[smp] the economy is off: {e}");
|
||||
}
|
||||
let taxes = economy.as_ref().ok().map(|e| Taxes::open(e, config.taxes));
|
||||
let auction = economy.as_ref().ok().map(|e| AuctionHouse::open(e, config.auction));
|
||||
let bank = economy.as_ref().ok().map(|e| Bank::open(e, config.bank));
|
||||
let report = economy.as_ref().ok().map(|e| Report::open(e, config.report));
|
||||
let jobs = economy.as_ref().ok().map(|e| Jobs::open(e, config.jobs));
|
||||
let futures = economy.as_ref().ok().map(|e| {
|
||||
let mut f = Futures::open(e, config.futures);
|
||||
f.rebase(&market.rebased);
|
||||
f
|
||||
});
|
||||
let places = Places::open(&dir, config.places);
|
||||
if let Err(e) = &places {
|
||||
log::error!("[smp] homes and warps are off: {e}");
|
||||
}
|
||||
let claims = Claims::open(&dir, config.claims);
|
||||
if let Err(e) = &claims {
|
||||
log::error!("[smp] claims are off: {e}");
|
||||
let mut claims = Claims::open(&dir, config.claims);
|
||||
match &mut claims {
|
||||
Ok(c) => {
|
||||
for line in c.clear_free_zone() {
|
||||
log::warn!("[smp] {line}");
|
||||
}
|
||||
}
|
||||
Err(e) => log::error!("[smp] claims are off: {e}"),
|
||||
}
|
||||
let board = Board::open(&dir, config.board);
|
||||
if let Err(e) = &board {
|
||||
@@ -244,12 +225,8 @@ fn open(dir: PathBuf) -> Smp {
|
||||
Smp {
|
||||
dir,
|
||||
economy,
|
||||
taxes,
|
||||
auction,
|
||||
bank,
|
||||
report,
|
||||
jobs,
|
||||
futures,
|
||||
market,
|
||||
shop_config: config.shop,
|
||||
shop: Shop::default(),
|
||||
@@ -288,27 +265,10 @@ fn state(dir: PathBuf) -> std::sync::MutexGuard<'static, Option<Smp>> {
|
||||
}
|
||||
|
||||
fn save(smp: &mut Smp) {
|
||||
// The tax state is written with the balances, in the same file.
|
||||
if let (Some(t), Ok(e)) = (&mut smp.taxes, &mut smp.economy) {
|
||||
t.store(e);
|
||||
}
|
||||
// And the bank's accounts.
|
||||
if let (Some(b), Ok(e)) = (&mut smp.bank, &mut smp.economy) {
|
||||
b.store(e);
|
||||
}
|
||||
// The report's state is written with the balances, in the same file.
|
||||
if let (Some(r), Ok(e)) = (&mut smp.report, &mut smp.economy) {
|
||||
r.store(e);
|
||||
}
|
||||
if let (Some(j), Ok(e)) = (&mut smp.jobs, &mut smp.economy) {
|
||||
j.store(e);
|
||||
}
|
||||
// And the futures' positions (their margins are in the `futures` pool).
|
||||
if let (Some(f), Ok(e)) = (&mut smp.futures, &mut smp.economy) {
|
||||
f.store(e);
|
||||
for p in f.problems.drain(..) {
|
||||
log::error!("[smp] {p}");
|
||||
}
|
||||
}
|
||||
// So are the auction house's listings, bids and mail.
|
||||
if let (Some(a), Ok(e)) = (&mut smp.auction, &mut smp.economy) {
|
||||
a.store(e);
|
||||
@@ -414,12 +374,6 @@ pub fn commands(commands: &mut Vec<NativeCommand>) {
|
||||
"afk",
|
||||
"nick <name:word>",
|
||||
"realname <nick:word>",
|
||||
"treasury",
|
||||
"treasury log",
|
||||
"taxes",
|
||||
"taxes pay",
|
||||
"eco taxes",
|
||||
"eco taxes pay",
|
||||
"ah",
|
||||
"ah search <words:text>",
|
||||
"ah sell <price:integer> [count:integer]",
|
||||
@@ -430,19 +384,8 @@ pub fn commands(commands: &mut Vec<NativeCommand>) {
|
||||
"ah claim",
|
||||
"ah mine",
|
||||
"ah mail",
|
||||
"bank",
|
||||
"bank deposit <amount:word>",
|
||||
"bank withdraw <amount:word>",
|
||||
"eco stats",
|
||||
"money stats",
|
||||
"jobs",
|
||||
"jobs deliver <contract:integer>",
|
||||
"futures",
|
||||
"futures list",
|
||||
"futures info <item:item>",
|
||||
"futures long <item:item> <margin:integer> [leverage:word]",
|
||||
"futures short <item:item> <margin:integer> [leverage:word]",
|
||||
"futures close <position:integer>",
|
||||
] {
|
||||
commands.push(NativeCommand::new(usage));
|
||||
}
|
||||
@@ -451,13 +394,13 @@ pub fn commands(commands: &mut Vec<NativeCommand>) {
|
||||
"setwarp <name:word>",
|
||||
"delwarp <name:word>",
|
||||
"nick <name:word> <nick:word>",
|
||||
"treasury pay <player:word> <amount:integer> <reason:text>",
|
||||
] {
|
||||
commands.push(NativeCommand::op(usage, 3));
|
||||
}
|
||||
// Operators take a cheat's gains into the Treasury; nobody gives or
|
||||
// sets money (grants come from the Treasury: `/treasury pay`).
|
||||
// Operators make money (an event prize, a bug refund: with a reason
|
||||
// everyone reads) and take a cheat's gains away.
|
||||
for name in ["eco", "money"] {
|
||||
commands.push(NativeCommand::op(&format!("{name} give <player:word> <amount:integer> <reason:text>"), 3));
|
||||
commands.push(NativeCommand::op(&format!("{name} take <player:word> <amount:integer> [reason:text]"), 3));
|
||||
}
|
||||
}
|
||||
@@ -486,9 +429,9 @@ impl Bag for GameBag<'_, '_> {
|
||||
}
|
||||
}
|
||||
|
||||
const COMMANDS: [&str; 21] = [
|
||||
const COMMANDS: [&str; 16] = [
|
||||
"balance", "bal", "pay", "baltop", "eco", "money", "worth", "sell", "buy", "shop", "market", "trade", "coinflip",
|
||||
"lottery", "treasury", "taxes", "ah", "bank", "jobs", "casino", "futures",
|
||||
"lottery", "ah", "casino",
|
||||
];
|
||||
|
||||
/// `/pay <player> <amount>`'s receiver (account, name) and amount, as
|
||||
@@ -505,27 +448,6 @@ fn pay_target(e: &Economy, line: &str) -> Option<(u128, String, i64)> {
|
||||
Some((to, account.name.clone(), amount))
|
||||
}
|
||||
|
||||
/// The market's prices as the futures read them: the mark is the 10-minute
|
||||
/// average of the mid (never the last trade), once the market has at least
|
||||
/// 5 minutes of it; the volume is the last 24 hours'.
|
||||
struct MarketPrices<'a>(&'a Market);
|
||||
|
||||
impl smp_futures::Prices for MarketPrices<'_> {
|
||||
fn mark(&self, id: &str, now: i64) -> Option<f64> {
|
||||
self.0.mark_price(id, now)?;
|
||||
self.0.twap(id, 10, now)
|
||||
}
|
||||
fn volume(&self, id: &str, now: i64) -> u64 {
|
||||
self.0.volume(id, now, 24)
|
||||
}
|
||||
}
|
||||
|
||||
/// 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);
|
||||
(p.sell > 0 || p.buy > 0).then_some((p.sell, p.buy))
|
||||
}
|
||||
|
||||
/// The players on, as the places see them.
|
||||
fn bodies(players: &[NativeServerPlayer]) -> Vec<Body> {
|
||||
players
|
||||
@@ -616,7 +538,7 @@ pub(in crate::game) fn perform(
|
||||
}
|
||||
}
|
||||
// A nickname where a command names a player: the player's name.
|
||||
let names_a_player = matches!(word, "pay" | "tpa" | "trade" | "playtime" | "tpaccept" | "tpdeny" | "eco" | "money" | "treasury");
|
||||
let names_a_player = matches!(word, "pay" | "tpa" | "trade" | "playtime" | "tpaccept" | "tpdeny" | "eco" | "money");
|
||||
let resolved = smp.nicks.as_ref().ok().filter(|_| names_a_player).and_then(|n| {
|
||||
let accounts = smp.economy.as_ref().map(|e| e.accounts()).unwrap_or_default();
|
||||
let is_name = |w: &str| players.iter().any(|p| p.name.eq_ignore_ascii_case(w)) || accounts.iter().any(|a| a.1.eq_ignore_ascii_case(w));
|
||||
@@ -687,12 +609,7 @@ pub(in crate::game) fn perform(
|
||||
on.or_else(|| economy?.name_of(u).map(str::to_owned))
|
||||
.unwrap_or_else(|| smp_store::uuid_string(u))
|
||||
};
|
||||
let owed = match (&smp.taxes, economy) {
|
||||
(Some(t), Some(e)) if line.split_whitespace().eq(["claim"]) => t.may_claim(e, me.uuid).err(),
|
||||
_ => None,
|
||||
};
|
||||
match (&mut smp.claims, at) {
|
||||
(Ok(_), Some(_)) if owed.is_some() => vec![Out::Error(owed.expect("checked"))],
|
||||
(Ok(c), Some(at)) => c.command(&me, &at, played, &find, &name_of, line).unwrap_or_default(),
|
||||
(Ok(_), None) => vec![Out::Error("You are not in the world.".into())],
|
||||
(Err(_), _) => vec![Out::Error(
|
||||
@@ -700,18 +617,7 @@ pub(in crate::game) fn perform(
|
||||
)],
|
||||
}
|
||||
} else if smp_places::COMMANDS.contains(&word) {
|
||||
// A teleport's fee is paid when it happens; refused now when the
|
||||
// player can't pay it.
|
||||
let short = match (&smp.places, &smp.economy) {
|
||||
(Ok(p), Ok(e)) if p.fee(line) > e.balance(me.uuid) => Some(format!(
|
||||
"This teleport costs {} (to the Treasury); you have {}.",
|
||||
e.money(p.fee(line)),
|
||||
e.money(e.balance(me.uuid))
|
||||
)),
|
||||
_ => None,
|
||||
};
|
||||
match &mut smp.places {
|
||||
Ok(_) if short.is_some() => vec![Out::Error(short.expect("checked"))],
|
||||
Ok(p) => p.command(&me, &bodies(&players), now, line).unwrap_or_default(),
|
||||
Err(_) => vec![Out::Error(
|
||||
"Homes and warps are off: their file can't be read (see the server log).".into(),
|
||||
@@ -731,35 +637,15 @@ pub(in crate::game) fn perform(
|
||||
}
|
||||
Err(_) => vec![Out::Error("The economy is off: its file can't be read (see the server log).".into())],
|
||||
}
|
||||
} else if word == "futures" {
|
||||
futures_command(game, smp, &me, now, line)
|
||||
} else {
|
||||
match &mut smp.economy {
|
||||
Ok(e) => {
|
||||
e.seen(me.uuid, &me.name);
|
||||
let on: Vec<(u128, String)> = players.iter().map(|p| (p.uuid, p.name.clone())).collect();
|
||||
let mut bag = GameBag(game);
|
||||
let my_chunks = smp.claims.as_ref().map_or(0, |c| c.held(me.uuid));
|
||||
// The wealth tax counts bank savings (`/eco taxes` shows the bill).
|
||||
if let (Some(t), Some(b)) = (&mut smp.taxes, &smp.bank)
|
||||
&& matches!(word, "eco" | "taxes")
|
||||
{
|
||||
t.savings = b.savings();
|
||||
}
|
||||
let taxes = smp.taxes.as_mut();
|
||||
let bank = smp.bank.as_mut();
|
||||
let report = smp.report.as_ref();
|
||||
taxes
|
||||
.and_then(|t| t.command(e, &me, my_chunks, line))
|
||||
.or_else(|| {
|
||||
let savings = bank.as_ref().map(|b| b.savings()).filter(|_| line.ends_with("stats")).unwrap_or_default();
|
||||
report.and_then(|r| r.command(e, &savings, line))
|
||||
})
|
||||
.or_else(|| bank.and_then(|b| b.command(e, &me, line)))
|
||||
.or_else(|| {
|
||||
let price = |id: &str| market_price(&smp.market, id);
|
||||
smp.jobs.as_mut()?.command(e, &me, trades, &mut bag, &price, line)
|
||||
})
|
||||
report
|
||||
.and_then(|r| r.command(e, line))
|
||||
.or_else(|| {
|
||||
// A payment counts toward the receiver's daily selling
|
||||
// limit (alts can't sell for a main past it).
|
||||
@@ -1077,10 +963,6 @@ pub(in crate::game) fn menu_clicked(game: &mut Game<'_>, uuid: u128, window: i32
|
||||
shop_clicked(game, smp, uuid, slot, mode == 1);
|
||||
return;
|
||||
}
|
||||
if smp.futures.as_ref().is_some_and(|f| f.views.get(&uuid).is_some_and(|v| v.0 == Some(window))) {
|
||||
futures_clicked(game, smp, uuid, slot);
|
||||
return;
|
||||
}
|
||||
if smp.market_menus.views.get(&uuid).is_some_and(|v| v.0 == Some(window)) {
|
||||
market_clicked(game, smp, uuid, slot);
|
||||
return;
|
||||
@@ -1139,12 +1021,6 @@ pub(in crate::game) fn menu_closed(game: &mut Game<'_>, uuid: u128, window: i32)
|
||||
smp.shop.views.remove(&uuid);
|
||||
return;
|
||||
}
|
||||
if let Some(f) = &mut smp.futures
|
||||
&& f.views.get(&uuid).is_some_and(|v| v.0 == Some(window))
|
||||
{
|
||||
f.views.remove(&uuid);
|
||||
return;
|
||||
}
|
||||
if smp.market_menus.views.get(&uuid).is_some_and(|v| v.0 == Some(window)) {
|
||||
smp.market_menus.views.remove(&uuid);
|
||||
return;
|
||||
@@ -1436,7 +1312,6 @@ fn market_items(smp: &Smp, uuid: u128, now: i64) -> Vec<Option<ItemStack>> {
|
||||
use smp_market_menu::{Cell as C, Chart};
|
||||
let economy = smp.economy.as_ref().ok();
|
||||
let money = |n: i64| economy.map_or_else(|| n.to_string(), |e| e.money(n));
|
||||
let tax = |n: i64| smp.market.tax_on(n);
|
||||
let name = |id: &str| smp_market::title_name(id);
|
||||
smp.market_menus
|
||||
.layout(&smp.shop_config, &smp.market.config, uuid)
|
||||
@@ -1457,7 +1332,6 @@ fn market_items(smp: &Smp, uuid: u128, now: i64) -> Vec<Option<ItemStack>> {
|
||||
let mut lines = price_lines(smp, &id, now);
|
||||
let base = smp.market.base(&id);
|
||||
lines.push((format!("Base price: {}", money(base.buy.max(base.sell))), "gray"));
|
||||
lines.push((format!("Tax: {}% to the Treasury", smp.market.config.dynamic.tax_percent), "gray"));
|
||||
if let Some(left) = smp.market.items_left(uuid, now, &id) {
|
||||
lines.push((format!("You may still sell {left} today"), "gray"));
|
||||
}
|
||||
@@ -1466,19 +1340,13 @@ fn market_items(smp: &Smp, uuid: u128, now: i64) -> Vec<Option<ItemStack>> {
|
||||
C::Buy(id, n) => {
|
||||
// What these n cost now, the price moving as they go.
|
||||
let cost = smp.market.value(&id, n, true).0;
|
||||
let mut b = with_lore(
|
||||
button("minecraft:lime_concrete", &format!("Buy {n} for {}", money(cost)), "green"),
|
||||
&[(format!("Tax {}%: +{}", smp.market.config.dynamic.tax_percent, money(tax(cost))), "gray")],
|
||||
);
|
||||
let mut b = button("minecraft:lime_concrete", &format!("Buy {n} for {}", money(cost)), "green");
|
||||
b.count = n;
|
||||
b
|
||||
}
|
||||
C::Sell(id, n) => {
|
||||
let gets = smp.market.value(&id, n, false).0;
|
||||
let mut b = with_lore(
|
||||
button("minecraft:red_concrete", &format!("Sell {n} for {}", money(gets)), "red"),
|
||||
&[(format!("Tax {}%: −{}", smp.market.config.dynamic.tax_percent, money(tax(gets))), "gray")],
|
||||
);
|
||||
let mut b = button("minecraft:red_concrete", &format!("Sell {n} for {}", money(gets)), "red");
|
||||
b.count = n;
|
||||
b
|
||||
}
|
||||
@@ -1787,10 +1655,9 @@ fn ah_command(game: &mut Game<'_>, smp: &mut Smp, me: &Player, trades: bool, now
|
||||
return vec![Out::Error("The economy is off: its file can't be read (see the server log).".into())];
|
||||
};
|
||||
e.seen(me.uuid, &me.name);
|
||||
let vat = smp.taxes.as_ref().map_or(0, |t| t.config.vat_percent);
|
||||
let reply = {
|
||||
let mut bag = GameBag(game);
|
||||
a.command(e, me, trades, &mut bag, vat, now, line)
|
||||
a.command(e, me, trades, &mut bag, now, line)
|
||||
};
|
||||
match reply {
|
||||
Some(smp_auction::Reply::Out(out)) => out,
|
||||
@@ -1900,7 +1767,6 @@ fn ah_clicked(game: &mut Game<'_>, smp: &mut Smp, uuid: u128, slot: i32) {
|
||||
};
|
||||
let now = chrono::Utc::now().timestamp_millis();
|
||||
let cats = smp.shop_config.categories.clone();
|
||||
let vat = smp.taxes.as_ref().map_or(0, |t| t.config.vat_percent);
|
||||
let (Ok(e), Some(a)) = (&mut smp.economy, &mut smp.auction) else {
|
||||
return;
|
||||
};
|
||||
@@ -1925,7 +1791,7 @@ fn ah_clicked(game: &mut Game<'_>, smp: &mut Smp, uuid: u128, slot: i32) {
|
||||
return;
|
||||
}
|
||||
Click::Redraw => Vec::new(),
|
||||
Click::Buy(id) => a.buy(e, &me, &mut bag, id, vat, now),
|
||||
Click::Buy(id) => a.buy(e, &me, &mut bag, id, now),
|
||||
Click::Bid(id) => a.bid(e, &me, id, None, now),
|
||||
Click::Withdraw(id) => {
|
||||
let mut out = a.withdraw(&me, id);
|
||||
@@ -1949,112 +1815,6 @@ fn ah_clicked(game: &mut Game<'_>, smp: &mut Smp, uuid: u128, slot: i32) {
|
||||
tell(game, uuid, out);
|
||||
}
|
||||
|
||||
/// `/futures ...`: a command, or (with no argument) the menu.
|
||||
fn futures_command(game: &mut Game<'_>, smp: &mut Smp, me: &Player, now: i64, line: &str) -> Vec<Out> {
|
||||
let (Ok(e), Some(f)) = (&mut smp.economy, &mut smp.futures) else {
|
||||
return vec![Out::Error("The economy is off: its file can't be read (see the server log).".into())];
|
||||
};
|
||||
e.seen(me.uuid, &me.name);
|
||||
let out = f.command(e, &MarketPrices(&smp.market), me, now, line).unwrap_or_default();
|
||||
if !out.is_empty() || !line.split_whitespace().eq(["futures"]) {
|
||||
return out;
|
||||
}
|
||||
if smp.trades.trade_of(me.uuid).is_some() {
|
||||
return vec![Out::Error("Finish or cancel your trade first.".into())];
|
||||
}
|
||||
f.views.insert(me.uuid, (None, None));
|
||||
let items = futures_items(smp, me.uuid, now);
|
||||
let window = game.native_open_menu(me.uuid, smp_futures::ROWS, Text::literal("Futures"), items);
|
||||
let f = smp.futures.as_mut().expect("checked");
|
||||
match window {
|
||||
Some(w) => {
|
||||
f.views.insert(me.uuid, (Some(w), None));
|
||||
Vec::new()
|
||||
}
|
||||
None => {
|
||||
f.views.remove(&me.uuid);
|
||||
vec![Out::Error("The futures menu couldn't open.".into())]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The futures menu: each position with its profit (green) or loss (red)
|
||||
/// and liquidation price; the insurance fund; help.
|
||||
fn futures_items(smp: &Smp, uuid: u128, now: i64) -> Vec<Option<ItemStack>> {
|
||||
let (Some(f), Ok(e)) = (&smp.futures, &smp.economy) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let prices = MarketPrices(&smp.market);
|
||||
f.layout(uuid)
|
||||
.into_iter()
|
||||
.map(|cell| {
|
||||
use smp_futures::{Cell as C, Prices as _};
|
||||
Some(match cell {
|
||||
C::Empty => return None,
|
||||
C::Position(id, armed) => {
|
||||
let p = f.position(id)?;
|
||||
let mark = prices.mark(&p.item, now);
|
||||
let up = mark.is_none_or(|m| p.pnl(m) >= 0.0);
|
||||
let line = f.describe(p, mark);
|
||||
if armed {
|
||||
button(&p.item, &format!("Click again to close: {line}"), "yellow")
|
||||
} else {
|
||||
button(&p.item, &line, if up { "green" } else { "red" })
|
||||
}
|
||||
}
|
||||
C::Fund => button(
|
||||
"minecraft:gold_block",
|
||||
&format!(
|
||||
"Insurance fund: {} · fees so far {} · {} liquidations",
|
||||
e.money(e.pool(smp_economy::INSURANCE)),
|
||||
e.money(f.data.fees),
|
||||
f.data.liquidations
|
||||
),
|
||||
"gold",
|
||||
),
|
||||
C::Help => button("minecraft:book", smp_futures::HELP_TEXT, "white"),
|
||||
C::Close => button("minecraft:barrier", "Close", "red"),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A click in the futures menu: arm a position, close it, or close.
|
||||
fn futures_clicked(game: &mut Game<'_>, smp: &mut Smp, uuid: u128, slot: i32) {
|
||||
let Some(p) = game.native_server_players().into_iter().find(|p| p.uuid == uuid) else {
|
||||
return;
|
||||
};
|
||||
let Ok(slot) = usize::try_from(slot) else {
|
||||
return;
|
||||
};
|
||||
if slot >= smp_futures::SIZE {
|
||||
return;
|
||||
}
|
||||
let me = Player {
|
||||
uuid,
|
||||
name: p.name.clone(),
|
||||
level: p.permission,
|
||||
};
|
||||
let now = chrono::Utc::now().timestamp_millis();
|
||||
let (Ok(e), Some(f)) = (&mut smp.economy, &mut smp.futures) else {
|
||||
return;
|
||||
};
|
||||
e.now = now;
|
||||
let out = match f.click(uuid, slot) {
|
||||
smp_futures::Click::Nothing => return,
|
||||
smp_futures::Click::Close => {
|
||||
f.views.remove(&uuid);
|
||||
game.native_close_menu(uuid);
|
||||
return;
|
||||
}
|
||||
smp_futures::Click::Redraw => Vec::new(),
|
||||
smp_futures::Click::Shut(id) => f.close(e, &MarketPrices(&smp.market), &me, id, now),
|
||||
};
|
||||
let items = futures_items(smp, uuid, now);
|
||||
game.native_set_menu_items(uuid, items);
|
||||
tell(game, uuid, out);
|
||||
}
|
||||
|
||||
/// What a menu click answers, to the player who clicked (no command's
|
||||
/// feedback there).
|
||||
fn tell(game: &mut Game<'_>, uuid: u128, out: Vec<Out>) {
|
||||
@@ -2118,17 +1878,6 @@ pub(in crate::game) fn tick(game: &mut Game<'_>) {
|
||||
let (o, go) = p.tick(&bodies(&players), now);
|
||||
out.extend(o);
|
||||
for t in go {
|
||||
// The fee, to the Treasury, only for a teleport made; without
|
||||
// the money (spent during the warmup) it is called off.
|
||||
let economy = smp.economy.as_mut().ok().filter(|_| t.fee > 0);
|
||||
if let Some(e) = &economy
|
||||
&& e.balance(t.uuid) < t.fee
|
||||
{
|
||||
p.call_off(&t);
|
||||
let why = format!("Teleport called off: it costs {}.", e.money(t.fee));
|
||||
out.push(Out::Tell(t.uuid, why));
|
||||
continue;
|
||||
}
|
||||
// `.3`: a whole number would be a block's centre to `tp`.
|
||||
let line = format!(
|
||||
"execute in {} run tp {} {:.3} {:.3} {:.3}",
|
||||
@@ -2140,18 +1889,9 @@ pub(in crate::game) fn tick(game: &mut Game<'_>) {
|
||||
out.push(Out::Tell(t.uuid, "The teleport failed.".into()));
|
||||
continue;
|
||||
}
|
||||
let mut done = t.done.clone();
|
||||
if let Some(e) = economy {
|
||||
match e.to_pool("travel_fee", t.uuid, smp_economy::TREASURY, t.fee, &t.what) {
|
||||
Ok(()) => done = format!("{done} Fee: {}.", e.money(t.fee)),
|
||||
Err(err) => log::warn!("[smp] travel fee: {err}"),
|
||||
}
|
||||
}
|
||||
out.push(Out::Tell(t.uuid, done));
|
||||
out.push(Out::Tell(t.uuid, t.done.clone()));
|
||||
}
|
||||
}
|
||||
// Futures menus to draw again after the minute's marking.
|
||||
let mut redraw: Vec<u128> = Vec::new();
|
||||
if let Ok(e) = &mut smp.economy
|
||||
&& n % 20 == 0
|
||||
{
|
||||
@@ -2174,41 +1914,17 @@ pub(in crate::game) fn tick(game: &mut Game<'_>) {
|
||||
// The auction house: notes for who came on, listings that ended.
|
||||
if let Some(a) = &mut smp.auction {
|
||||
let on: Vec<u128> = players.iter().map(|p| p.uuid).collect();
|
||||
let vat = smp.taxes.as_ref().map_or(0, |t| t.config.vat_percent);
|
||||
out.extend(a.tick(e, &on, vat, now));
|
||||
}
|
||||
// Futures: marked once a minute (liquidations, funding each hour);
|
||||
// the open menus are drawn again then, with the new profits.
|
||||
if let Some(f) = &mut smp.futures {
|
||||
let on: Vec<u128> = players.iter().map(|p| p.uuid).collect();
|
||||
let minute = f.data.minute;
|
||||
out.extend(f.tick(e, &MarketPrices(&smp.market), &on, now));
|
||||
if f.data.minute != minute {
|
||||
redraw = f.views.iter().filter(|v| v.1.0.is_some()).map(|v| *v.0).collect();
|
||||
}
|
||||
}
|
||||
// The day's contracts: an O(1) check each second, posted once a day.
|
||||
if let Some(j) = &mut smp.jobs {
|
||||
let price = |id: &str| market_price(&smp.market, id);
|
||||
out.extend(j.tick(e, &price, now));
|
||||
}
|
||||
// The bank's interest: an O(1) check each second, the work once a day.
|
||||
if let Some(b) = &mut smp.bank {
|
||||
out.extend(b.tick(e, now));
|
||||
out.extend(a.tick(e, &on, now));
|
||||
}
|
||||
// The daily report and, once a minute, web/economy.json (written off
|
||||
// the tick thread).
|
||||
if let Some(r) = &mut smp.report
|
||||
&& r.due(now)
|
||||
{
|
||||
let savings = smp.bank.as_ref().map(|b| b.savings()).unwrap_or_default();
|
||||
let (o, web) = r.tick(e, &savings, now);
|
||||
let (o, web) = r.tick(e, now);
|
||||
out.extend(o);
|
||||
if web {
|
||||
let taxes = smp.taxes.as_ref().map_or(serde_json::Value::Null, |t| {
|
||||
serde_json::json!({"rates": t.config, "last_collection": t.state.last})
|
||||
});
|
||||
let doc = r.web(e, &savings, taxes, now);
|
||||
let doc = r.web(e, now);
|
||||
let path = smp.dir.join("web").join("economy.json");
|
||||
std::thread::spawn(move || {
|
||||
let made = path.parent().map_or(Ok(()), std::fs::create_dir_all);
|
||||
@@ -2218,16 +1934,6 @@ pub(in crate::game) fn tick(game: &mut Game<'_>) {
|
||||
});
|
||||
}
|
||||
}
|
||||
// Monday's taxes: an O(1) check each second, the work once a week.
|
||||
if let Some(t) = &mut smp.taxes
|
||||
&& t.due(now)
|
||||
{
|
||||
if let Some(b) = &smp.bank {
|
||||
t.savings = b.savings();
|
||||
}
|
||||
let chunks = smp.claims.as_ref().map(|c| c.counts()).unwrap_or_default();
|
||||
out.extend(t.collect(e, &chunks, now));
|
||||
}
|
||||
}
|
||||
// The market's minute: prices move, open menus and carried charts
|
||||
// follow, the files are written by threads; once a day, the movers.
|
||||
@@ -2250,10 +1956,6 @@ pub(in crate::game) fn tick(game: &mut Game<'_>) {
|
||||
}
|
||||
}
|
||||
}
|
||||
for uuid in redraw {
|
||||
let items = futures_items(smp, uuid, now);
|
||||
game.native_set_menu_items(uuid, items);
|
||||
}
|
||||
// Each trade's offers against the inventories now: a player gone or
|
||||
// dead ends it; a stack that changed leaves its offer.
|
||||
let sides: Vec<(u128, String)> = smp
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[mod]
|
||||
id = "smp"
|
||||
version = "0.29.0"
|
||||
version = "0.30.1"
|
||||
priority = 100
|
||||
|
||||
# The commit of horizon it was tested on: it needs horizon#6106 (merged at 975c07d): native_inventory,
|
||||
@@ -62,36 +62,12 @@ file = "crates/horizon-client/src/game.rs"
|
||||
name = "smp_report"
|
||||
source = "smp_report.rs"
|
||||
|
||||
[[module]]
|
||||
id = "jobs"
|
||||
file = "crates/horizon-client/src/game.rs"
|
||||
name = "smp_jobs"
|
||||
source = "smp_jobs.rs"
|
||||
|
||||
[[module]]
|
||||
id = "futures"
|
||||
file = "crates/horizon-client/src/game.rs"
|
||||
name = "smp_futures"
|
||||
source = "smp_futures.rs"
|
||||
|
||||
[[module]]
|
||||
id = "bank"
|
||||
file = "crates/horizon-client/src/game.rs"
|
||||
name = "smp_bank"
|
||||
source = "smp_bank.rs"
|
||||
|
||||
[[module]]
|
||||
id = "auction"
|
||||
file = "crates/horizon-client/src/game.rs"
|
||||
name = "smp_auction"
|
||||
source = "smp_auction.rs"
|
||||
|
||||
[[module]]
|
||||
id = "tax"
|
||||
file = "crates/horizon-client/src/game.rs"
|
||||
name = "smp_tax"
|
||||
source = "smp_tax.rs"
|
||||
|
||||
[[module]]
|
||||
id = "trade"
|
||||
file = "crates/horizon-client/src/game.rs"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// The auction house (`/ah`): players sell to players. A listing is a fixed
|
||||
// price or a timed auction; listing costs a fee and each sale pays VAT, both
|
||||
// to the Treasury. Listed items leave the seller's inventory at once; what
|
||||
// price or a timed auction; listing is free, and a sale pays the seller the
|
||||
// whole price. Listed items leave the seller's inventory at once; what
|
||||
// doesn't sell (and what an auction's winner gets) waits in the player's mail
|
||||
// (`/ah claim`, or the menu's mail page). Bids are held in the `auction`
|
||||
// pool until the auction ends or someone bids more. A bid in the last
|
||||
@@ -19,7 +19,7 @@
|
||||
// menu from `Cell`s. Added to the game as `game::smp_auction`; also a module
|
||||
// of this crate, for its tests.
|
||||
|
||||
use super::smp_economy::{DAY_MS, Economy, MAX_AMOUNT, Out, Player, TREASURY, parse_amount};
|
||||
use super::smp_economy::{DAY_MS, Economy, MAX_AMOUNT, Out, Player, parse_amount};
|
||||
use super::smp_market::{Bag, item_name, valid_id};
|
||||
use super::smp_shop::{Category, title};
|
||||
use super::smp_store::{parse_uuid, uuid_string};
|
||||
@@ -57,9 +57,6 @@ const CATEGORY_SLOTS: [usize; 21] = [
|
||||
#[serde(default)]
|
||||
pub struct Config {
|
||||
pub enabled: bool,
|
||||
/// Paid when listing, in percent of the price (or starting bid), at
|
||||
/// least 1 coin; to the Treasury, never given back.
|
||||
pub listing_fee_percent: i64,
|
||||
/// Listings one player may have at once.
|
||||
pub max_listings: usize,
|
||||
/// How long a fixed-price listing stays.
|
||||
@@ -77,7 +74,6 @@ impl Default for Config {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
listing_fee_percent: 1,
|
||||
max_listings: 8,
|
||||
listing_hours: 48,
|
||||
auction_hours: 12,
|
||||
@@ -143,8 +139,6 @@ pub struct Today {
|
||||
pub day: i64,
|
||||
pub sales: u32,
|
||||
pub volume: i64,
|
||||
pub fees: i64,
|
||||
pub vat: i64,
|
||||
}
|
||||
|
||||
/// What `economy.json`'s `ext.auction` holds.
|
||||
@@ -240,6 +234,19 @@ pub struct AuctionHouse {
|
||||
pub problems: Vec<String>,
|
||||
}
|
||||
|
||||
/// `2d 5h`, `3h 05m`, `12m`: the time left on a listing.
|
||||
fn span(ms: i64) -> String {
|
||||
let m = ms.max(0) / 60_000;
|
||||
let (d, h, m) = (m / 1440, m / 60 % 24, m % 60);
|
||||
if d > 0 {
|
||||
format!("{d}d {h}h")
|
||||
} else if h > 0 {
|
||||
format!("{h}h {m:02}m")
|
||||
} else {
|
||||
format!("{m}m")
|
||||
}
|
||||
}
|
||||
|
||||
/// `price * percent / 100`, without overflow.
|
||||
fn percent(price: i64, percent: i64) -> i64 {
|
||||
(i128::from(price) * i128::from(percent.clamp(0, 100)) / 100) as i64
|
||||
@@ -377,11 +384,10 @@ impl AuctionHouse {
|
||||
&mut self.data.today
|
||||
}
|
||||
|
||||
fn record_sale(&mut self, l: &Listing, price: i64, vat: i64, now: i64) {
|
||||
fn record_sale(&mut self, l: &Listing, price: i64, now: i64) {
|
||||
let t = self.count_today(now);
|
||||
t.sales += 1;
|
||||
t.volume = t.volume.saturating_add(price);
|
||||
t.vat = t.vat.saturating_add(vat);
|
||||
self.data.sales.push(Sale {
|
||||
time: now,
|
||||
item: l.item.clone(),
|
||||
@@ -399,24 +405,17 @@ impl AuctionHouse {
|
||||
format!("{} {}", l.count, item_name(&l.item))
|
||||
}
|
||||
|
||||
/// Pays the seller `price` (from the buyer, or from the bids' pool),
|
||||
/// then the VAT from it to the Treasury: what the VAT was.
|
||||
/// Pays the seller `price` (from the buyer, or from the bids' pool).
|
||||
fn pay_seller(
|
||||
economy: &mut Economy,
|
||||
from: Result<u128, ()>,
|
||||
seller: u128,
|
||||
price: i64,
|
||||
vat_percent: i64,
|
||||
) -> Result<i64, String> {
|
||||
) -> Result<(), String> {
|
||||
match from {
|
||||
Ok(buyer) => economy.transfer("ah_sale", buyer, seller, price)?,
|
||||
Err(()) => economy.from_pool("ah_sale", POOL, seller, price, "")?,
|
||||
Ok(buyer) => economy.transfer("ah_sale", buyer, seller, price),
|
||||
Err(()) => economy.from_pool("ah_sale", POOL, seller, price, ""),
|
||||
}
|
||||
let vat = percent(price, vat_percent);
|
||||
if vat > 0 {
|
||||
economy.to_pool("vat", seller, TREASURY, vat, "auction house sale")?;
|
||||
}
|
||||
Ok(vat)
|
||||
}
|
||||
|
||||
/// Runs `/ah ...`; None for another command. `bag`: the acting player's
|
||||
@@ -428,7 +427,6 @@ impl AuctionHouse {
|
||||
me: &Player,
|
||||
trades: bool,
|
||||
bag: &mut dyn Bag,
|
||||
vat_percent: i64,
|
||||
now: i64,
|
||||
line: &str,
|
||||
) -> Option<Reply> {
|
||||
@@ -485,7 +483,7 @@ impl AuctionHouse {
|
||||
_ => err("Usage: /ah bid <listing> <amount>"),
|
||||
},
|
||||
["buy", id] => match id.parse::<u64>() {
|
||||
Ok(id) => out(self.buy(economy, me, bag, id, vat_percent, now)),
|
||||
Ok(id) => out(self.buy(economy, me, bag, id, now)),
|
||||
Err(_) => err("Usage: /ah buy <listing>"),
|
||||
},
|
||||
["cancel", id] => match id.parse::<u64>() {
|
||||
@@ -549,10 +547,6 @@ impl AuctionHouse {
|
||||
if count == 0 || count > held {
|
||||
return err(format!("You hold {held} {}.", item_name(&item)));
|
||||
}
|
||||
let fee = percent(price, self.config.listing_fee_percent).max(1);
|
||||
if economy.balance(me.uuid) < fee {
|
||||
return err(format!("Listing costs {}.", economy.money(fee)));
|
||||
}
|
||||
let taken = bag.take(&item, count);
|
||||
if taken != count {
|
||||
bag.give(&item, taken);
|
||||
@@ -565,10 +559,7 @@ impl AuctionHouse {
|
||||
"kind": "ah_list", "id": id, "seller": mine, "item": &item, "count": count,
|
||||
"price": price, "auction": hours.is_some(), "ends": ends,
|
||||
});
|
||||
if let Err(e) = economy
|
||||
.record(entry)
|
||||
.and_then(|()| economy.to_pool("ah_listing_fee", me.uuid, TREASURY, fee, ""))
|
||||
{
|
||||
if let Err(e) = economy.record(entry) {
|
||||
bag.give(&item, taken);
|
||||
return err(e);
|
||||
}
|
||||
@@ -586,22 +577,19 @@ impl AuctionHouse {
|
||||
};
|
||||
let line = match hours {
|
||||
None => format!(
|
||||
"Listed #{id}: {} for {} ({:.2} each), {}h. Fee: {}.",
|
||||
"Listed #{id}: {} for {} ({:.2} each), {}h.",
|
||||
Self::what(&l),
|
||||
economy.money(price),
|
||||
price as f64 / f64::from(count),
|
||||
self.config.listing_hours,
|
||||
economy.money(fee)
|
||||
self.config.listing_hours
|
||||
),
|
||||
Some(h) => format!(
|
||||
"Auction #{id}: {} from {}, ends in {h}h. Fee: {}.",
|
||||
"Auction #{id}: {} from {}, ends in {h}h.",
|
||||
Self::what(&l),
|
||||
economy.money(price),
|
||||
economy.money(fee)
|
||||
economy.money(price)
|
||||
),
|
||||
};
|
||||
self.data.listings.push(l);
|
||||
self.count_today(now).fees += fee;
|
||||
self.dirty = true;
|
||||
vec![Out::Reply(line)]
|
||||
}
|
||||
@@ -623,7 +611,6 @@ impl AuctionHouse {
|
||||
me: &Player,
|
||||
bag: &mut dyn Bag,
|
||||
id: u64,
|
||||
vat_percent: i64,
|
||||
now: i64,
|
||||
) -> Vec<Out> {
|
||||
let err = |s: String| vec![Out::Error(s)];
|
||||
@@ -670,14 +657,10 @@ impl AuctionHouse {
|
||||
return err("The items couldn't be given: nothing was bought.".into());
|
||||
}
|
||||
self.post(&uuid_string(me.uuid), &l.item, l.count - given, "bought");
|
||||
let vat = match Self::pay_seller(economy, Ok(me.uuid), seller, l.price, vat_percent) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
self.problems.push(format!("auction house sale #{id}: {e}"));
|
||||
0
|
||||
}
|
||||
};
|
||||
self.record_sale(&l, l.price, vat, now);
|
||||
if let Err(e) = Self::pay_seller(economy, Ok(me.uuid), seller, l.price) {
|
||||
self.problems.push(format!("auction house sale #{id}: {e}"));
|
||||
}
|
||||
self.record_sale(&l, l.price, now);
|
||||
let mut out = vec![Out::Reply(format!(
|
||||
"Bought {} from {} for {}.{}",
|
||||
Self::what(&l),
|
||||
@@ -690,11 +673,10 @@ impl AuctionHouse {
|
||||
}
|
||||
))];
|
||||
let line = format!(
|
||||
"{} bought your {} for {} (VAT {}).",
|
||||
"{} bought your {} for {}.",
|
||||
me.name,
|
||||
Self::what(&l),
|
||||
economy.money(l.price - vat),
|
||||
economy.money(vat)
|
||||
economy.money(l.price)
|
||||
);
|
||||
self.notify(&mut out, &l.seller, line);
|
||||
out
|
||||
@@ -778,7 +760,7 @@ impl AuctionHouse {
|
||||
"You bid {} on #{id} ({}), ends in {}.{}",
|
||||
economy.money(amount),
|
||||
Self::what(&l),
|
||||
super::smp_tax::span(l.ends - now),
|
||||
span(l.ends - now),
|
||||
if extended {
|
||||
" A late bid: the end moved back."
|
||||
} else {
|
||||
@@ -804,10 +786,7 @@ impl AuctionHouse {
|
||||
}
|
||||
let l = self.data.listings.remove(i);
|
||||
self.post(&l.seller, &l.item, l.count, "withdrawn");
|
||||
vec![Out::Reply(format!(
|
||||
"Took #{id} ({}) back. The fee stays paid.",
|
||||
Self::what(&l)
|
||||
))]
|
||||
vec![Out::Reply(format!("Took #{id} ({}) back.", Self::what(&l)))]
|
||||
}
|
||||
|
||||
/// Gives `uuid` their mail (one parcel, or all of it), as much as fits;
|
||||
@@ -857,13 +836,7 @@ impl AuctionHouse {
|
||||
/// Once a second: notes to the players who came on, and the listings
|
||||
/// that ended (fixed ones back to their seller, auctions to their
|
||||
/// winner). Only a comparison while nothing ended.
|
||||
pub fn tick(
|
||||
&mut self,
|
||||
economy: &mut Economy,
|
||||
online: &[u128],
|
||||
vat_percent: i64,
|
||||
now: i64,
|
||||
) -> Vec<Out> {
|
||||
pub fn tick(&mut self, economy: &mut Economy, online: &[u128], now: i64) -> Vec<Out> {
|
||||
self.online = online.iter().copied().collect();
|
||||
let mut out = Vec::new();
|
||||
if !self.data.notes.is_empty() {
|
||||
@@ -895,16 +868,12 @@ impl AuctionHouse {
|
||||
continue;
|
||||
};
|
||||
let seller = parse_uuid(&l.seller).unwrap_or_default();
|
||||
let vat = match Self::pay_seller(economy, Err(()), seller, b.amount, vat_percent) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
self.problems
|
||||
.push(format!("auction #{} payment: {e}", l.id));
|
||||
0
|
||||
}
|
||||
};
|
||||
if let Err(e) = Self::pay_seller(economy, Err(()), seller, b.amount) {
|
||||
self.problems
|
||||
.push(format!("auction #{} payment: {e}", l.id));
|
||||
}
|
||||
self.post(&b.uuid, &l.item, l.count, "won");
|
||||
self.record_sale(&l, b.amount, vat, now);
|
||||
self.record_sale(&l, b.amount, now);
|
||||
let line = format!(
|
||||
"You won #{} ({}) for {}: the items are in your mail (/ah claim).",
|
||||
l.id,
|
||||
@@ -913,12 +882,11 @@ impl AuctionHouse {
|
||||
);
|
||||
self.notify(&mut out, &b.uuid, line);
|
||||
let line = format!(
|
||||
"Auction #{} ({}) sold to {} for {} (VAT {}).",
|
||||
"Auction #{} ({}) sold to {} for {}.",
|
||||
l.id,
|
||||
Self::what(&l),
|
||||
b.name,
|
||||
economy.money(b.amount - vat),
|
||||
economy.money(vat)
|
||||
economy.money(b.amount)
|
||||
);
|
||||
self.notify(&mut out, &l.seller.clone(), line);
|
||||
}
|
||||
@@ -1098,7 +1066,7 @@ impl AuctionHouse {
|
||||
/// A listing's name on its button.
|
||||
pub fn label(&self, l: &Listing, now: i64, money: &dyn Fn(i64) -> String) -> String {
|
||||
let each = l.amount() as f64 / f64::from(l.count.max(1));
|
||||
let left = super::smp_tax::span(l.ends - now);
|
||||
let left = span(l.ends - now);
|
||||
let head = format!("#{} {} {}", l.id, l.count, title(&l.item));
|
||||
if l.auction {
|
||||
let bid = match &l.bid {
|
||||
@@ -1163,7 +1131,6 @@ mod tests {
|
||||
const STEVE: u128 = 2;
|
||||
const NOTCH: u128 = 3;
|
||||
const T0: i64 = 1_793_577_600_000;
|
||||
const VAT: i64 = 5;
|
||||
|
||||
/// A player's inventory: plain stacks by id, 36 slots of 64.
|
||||
#[derive(Default)]
|
||||
@@ -1250,7 +1217,7 @@ mod tests {
|
||||
|
||||
/// Every coin of the game: balances and pools.
|
||||
fn total(e: &Economy) -> i64 {
|
||||
e.balance(ALEX) + e.balance(STEVE) + e.balance(NOTCH) + e.pool(TREASURY) + e.pool(POOL)
|
||||
e.balance(ALEX) + e.balance(STEVE) + e.balance(NOTCH) + e.pool(POOL)
|
||||
}
|
||||
|
||||
fn run(
|
||||
@@ -1261,14 +1228,14 @@ mod tests {
|
||||
now: i64,
|
||||
line: &str,
|
||||
) -> Vec<Out> {
|
||||
match ah.command(e, me, true, bag, VAT, now, line) {
|
||||
match ah.command(e, me, true, bag, now, line) {
|
||||
Some(Reply::Out(o)) => o,
|
||||
other => panic!("{line}: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fixed_sale_moves_items_once_and_pays_fee_and_vat_to_the_treasury() {
|
||||
fn a_fixed_sale_moves_items_once_and_pays_the_seller_the_whole_price() {
|
||||
let (mut e, mut ah, _dir) = setup("fixed");
|
||||
let mut alex = holding("diamond", 10);
|
||||
let out = run(
|
||||
@@ -1287,13 +1254,13 @@ mod tests {
|
||||
alex.items["minecraft:diamond"], 0,
|
||||
"the items left the inventory"
|
||||
);
|
||||
assert_eq!((e.balance(ALEX), e.pool(TREASURY)), (990, 10), "1% fee");
|
||||
assert_eq!(e.balance(ALEX), 1000, "listing is free");
|
||||
// Steve and Notch click "buy" in the same tick: one gets it.
|
||||
let mut steve = empty();
|
||||
let mut notch = empty();
|
||||
ah.online = [ALEX, STEVE, NOTCH].into();
|
||||
let a = ah.buy(&mut e, &p(STEVE, "Steve"), &mut steve, 1, VAT, T0 + 1);
|
||||
let b = ah.buy(&mut e, &p(NOTCH, "Notch"), &mut notch, 1, VAT, T0 + 1);
|
||||
let a = ah.buy(&mut e, &p(STEVE, "Steve"), &mut steve, 1, T0 + 1);
|
||||
let b = ah.buy(&mut e, &p(NOTCH, "Notch"), &mut notch, 1, T0 + 1);
|
||||
assert!(
|
||||
matches!(&a[0], Out::Reply(s) if s.starts_with("Bought 10 diamond from Alex")),
|
||||
"{a:?}"
|
||||
@@ -1311,19 +1278,16 @@ mod tests {
|
||||
),
|
||||
(10, None)
|
||||
);
|
||||
// Alex gets 1,000 less 5% VAT; Steve paid 1,000.
|
||||
// Alex gets the whole 1,000 Steve paid.
|
||||
assert_eq!(
|
||||
(e.balance(ALEX), e.balance(STEVE), e.balance(NOTCH)),
|
||||
(990 + 950, 0, 1000)
|
||||
(1000 + 1000, 0, 1000)
|
||||
);
|
||||
assert_eq!(e.pool(TREASURY), 10 + 50);
|
||||
assert_eq!(total(&e), 3000, "nothing made, nothing lost");
|
||||
assert!(a.contains(&Out::Tell(
|
||||
ALEX,
|
||||
"Steve bought your 10 diamond for 950 coins (VAT 50 coins).".into()
|
||||
"Steve bought your 10 diamond for 1,000 coins.".into()
|
||||
)));
|
||||
let t = e.treasury();
|
||||
assert_eq!((t.income["vat"], t.income["ah_listing_fee"]), (50, 10));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1347,16 +1311,16 @@ mod tests {
|
||||
);
|
||||
// Creative players can't list or buy.
|
||||
assert!(matches!(
|
||||
ah.command(&mut e, &alex, false, &mut bag, VAT, T0, "ah sell 10"),
|
||||
ah.command(&mut e, &alex, false, &mut bag, T0, "ah sell 10"),
|
||||
Some(Reply::Out(o)) if matches!(&o[0], Out::Error(_))
|
||||
));
|
||||
run(&mut ah, &mut e, &alex, &mut bag, T0, "ah sell 5000 5");
|
||||
// Own listing, too poor, no room, a failed give: nothing moves.
|
||||
assert!(
|
||||
matches!(&ah.buy(&mut e, &alex, &mut empty(), 1, VAT, T0)[0], Out::Error(s) if s.starts_with("That's your own"))
|
||||
matches!(&ah.buy(&mut e, &alex, &mut empty(), 1, T0)[0], Out::Error(s) if s.starts_with("That's your own"))
|
||||
);
|
||||
assert!(
|
||||
matches!(&ah.buy(&mut e, &p(STEVE, "Steve"), &mut empty(), 1, VAT, T0)[0], Out::Error(s) if s.starts_with("You only have"))
|
||||
matches!(&ah.buy(&mut e, &p(STEVE, "Steve"), &mut empty(), 1, T0)[0], Out::Error(s) if s.starts_with("You only have"))
|
||||
);
|
||||
e.adjust("sell", STEVE, 10_000, "").unwrap();
|
||||
let mut full = Inv {
|
||||
@@ -1364,7 +1328,7 @@ mod tests {
|
||||
..Inv::default()
|
||||
};
|
||||
assert!(
|
||||
matches!(&ah.buy(&mut e, &p(STEVE, "Steve"), &mut full, 1, VAT, T0)[0], Out::Error(s) if s.starts_with("Make room"))
|
||||
matches!(&ah.buy(&mut e, &p(STEVE, "Steve"), &mut full, 1, T0)[0], Out::Error(s) if s.starts_with("Make room"))
|
||||
);
|
||||
let mut broken = Inv {
|
||||
slots_free: 5,
|
||||
@@ -1372,7 +1336,7 @@ mod tests {
|
||||
..Inv::default()
|
||||
};
|
||||
assert!(matches!(
|
||||
&ah.buy(&mut e, &p(STEVE, "Steve"), &mut broken, 1, VAT, T0)[0],
|
||||
&ah.buy(&mut e, &p(STEVE, "Steve"), &mut broken, 1, T0)[0],
|
||||
Out::Error(_)
|
||||
));
|
||||
assert!(ah.listing(1).is_some(), "the listing stays for sale");
|
||||
@@ -1437,8 +1401,7 @@ mod tests {
|
||||
);
|
||||
assert_eq!(ah.listing(1).unwrap().ends, late + 60_000);
|
||||
assert!(
|
||||
ah.tick(&mut e, &[STEVE, NOTCH], VAT, late + 59_000)
|
||||
.is_empty(),
|
||||
ah.tick(&mut e, &[STEVE, NOTCH], late + 59_000).is_empty(),
|
||||
"not over yet"
|
||||
);
|
||||
// A restart mid-auction: everything comes back from economy.json.
|
||||
@@ -1447,7 +1410,7 @@ mod tests {
|
||||
let mut e = Economy::open(&dir, EcoConfig::default()).unwrap();
|
||||
let mut ah = AuctionHouse::open(&e, Config::default());
|
||||
assert_eq!((e.pool(POOL), ah.listing(1).unwrap().amount()), (400, 400));
|
||||
let out = ah.tick(&mut e, &[STEVE, NOTCH], VAT, late + 60_000);
|
||||
let out = ah.tick(&mut e, &[STEVE, NOTCH], late + 60_000);
|
||||
assert!(out.contains(&Out::Tell(
|
||||
STEVE,
|
||||
"You won #1 (3 emerald) for 400 coins: the items are in your mail (/ah claim).".into()
|
||||
@@ -1455,20 +1418,17 @@ mod tests {
|
||||
assert_eq!(e.pool(POOL), 0);
|
||||
assert_eq!(
|
||||
e.balance(ALEX),
|
||||
999 + 400 - 20,
|
||||
"paid while offline, less 5% VAT (fee: 1)"
|
||||
);
|
||||
assert_eq!(
|
||||
e.balance(STEVE) + e.balance(NOTCH) + e.balance(ALEX) + e.pool(TREASURY),
|
||||
3000
|
||||
1000 + 400,
|
||||
"paid while offline, the whole bid"
|
||||
);
|
||||
assert_eq!(e.balance(STEVE) + e.balance(NOTCH) + e.balance(ALEX), 3000);
|
||||
// Alex reads the note when back.
|
||||
let out = ah.tick(&mut e, &[ALEX], VAT, late + 61_000);
|
||||
let out = ah.tick(&mut e, &[ALEX], late + 61_000);
|
||||
assert_eq!(
|
||||
out,
|
||||
[Out::Tell(
|
||||
ALEX,
|
||||
"Auction #1 (3 emerald) sold to Steve for 380 coins (VAT 20 coins).".into()
|
||||
"Auction #1 (3 emerald) sold to Steve for 400 coins.".into()
|
||||
)]
|
||||
);
|
||||
// Steve claims: the items, once.
|
||||
@@ -1496,7 +1456,7 @@ mod tests {
|
||||
run(&mut ah, &mut e, &alex, &mut more, T0, "ah auction 50 1");
|
||||
ah.online = [ALEX].into();
|
||||
// The auction (1 h) ends with no bid; the fixed one (48 h) later.
|
||||
let out = ah.tick(&mut e, &[ALEX], VAT, T0 + 3_600_000);
|
||||
let out = ah.tick(&mut e, &[ALEX], T0 + 3_600_000);
|
||||
assert_eq!(
|
||||
out,
|
||||
[Out::Tell(
|
||||
@@ -1504,7 +1464,7 @@ mod tests {
|
||||
"#2 (64 wheat) ended with no bids: the items are in your mail (/ah claim).".into()
|
||||
)]
|
||||
);
|
||||
ah.tick(&mut e, &[ALEX], VAT, T0 + 48 * 3_600_000);
|
||||
ah.tick(&mut e, &[ALEX], T0 + 48 * 3_600_000);
|
||||
assert_eq!(ah.data.listings.len(), 0);
|
||||
assert_eq!(ah.mail_of(ALEX).len(), 2);
|
||||
// Room for one stack only: one parcel comes, the other waits.
|
||||
|
||||
@@ -1,442 +0,0 @@
|
||||
// The bank (`/bank`): savings that earn a small interest, paid once a day
|
||||
// from the Treasury (taxes and fees), never minted. A player deposits coins
|
||||
// (they wait in the `bank` pool) and takes them back at any time, free.
|
||||
// Interest is paid at 00:00 UTC on the lowest amount the account held since
|
||||
// the last payment, so a deposit made just before midnight earns nothing
|
||||
// that night; only the first `max_interest_on` coins of each account earn,
|
||||
// and one day's interest for everyone is at most `max_treasury_percent` of
|
||||
// the Treasury (shared out pro rata when it's short). No loans: a loan's
|
||||
// collateral (items) can't be held safely here, and a loan without it is
|
||||
// free money for an alt that never pays back.
|
||||
//
|
||||
// The accounts live in `economy.json` (`ext.bank`), the coins in its pool:
|
||||
// money and records are written together. No game types. Added to the game
|
||||
// as `game::smp_bank`; also a module of this crate, for its tests.
|
||||
|
||||
use super::smp_economy::{DAY_MS, Economy, MAX_AMOUNT, Out, Player, TREASURY, parse_amount};
|
||||
use super::smp_store::{parse_uuid, uuid_string};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
|
||||
/// The pool savings wait in.
|
||||
pub const POOL: &str = "bank";
|
||||
|
||||
/// `config.json`'s `bank`.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Config {
|
||||
pub enabled: bool,
|
||||
/// Interest a day, in hundredths of a percent (10: 0.1% a day).
|
||||
pub daily_basis_points: i64,
|
||||
/// Coins of an account that earn interest; more may be saved.
|
||||
pub max_interest_on: i64,
|
||||
/// What a player may save in all.
|
||||
pub max_deposit: i64,
|
||||
/// One day's interest for everyone is at most this share of the
|
||||
/// Treasury.
|
||||
pub max_treasury_percent: i64,
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
daily_basis_points: 10,
|
||||
max_interest_on: 10_000,
|
||||
max_deposit: 25_000,
|
||||
max_treasury_percent: 5,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Saving {
|
||||
pub amount: i64,
|
||||
/// The lowest `amount` since the last payment: what earns.
|
||||
pub low: i64,
|
||||
pub earned: i64,
|
||||
}
|
||||
|
||||
/// `economy.json`'s `ext.bank`.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Data {
|
||||
pub accounts: BTreeMap<String, Saving>,
|
||||
/// The day (since 1970, UTC) interest was last paid.
|
||||
pub day: i64,
|
||||
pub paid: i64,
|
||||
/// The last payment: what it was, and whether the Treasury's cap cut it.
|
||||
pub last: i64,
|
||||
pub last_cut: bool,
|
||||
}
|
||||
|
||||
pub struct Bank {
|
||||
pub config: Config,
|
||||
pub data: Data,
|
||||
dirty: bool,
|
||||
}
|
||||
|
||||
impl Bank {
|
||||
pub fn open(economy: &Economy, config: Config) -> Self {
|
||||
let mut data = economy
|
||||
.ext("bank")
|
||||
.and_then(|v| serde_json::from_value::<Data>(v.clone()).ok())
|
||||
.unwrap_or_default();
|
||||
if data.day == 0 {
|
||||
data.day = economy.now.div_euclid(DAY_MS);
|
||||
}
|
||||
Self {
|
||||
config,
|
||||
data,
|
||||
dirty: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn store(&mut self, economy: &mut Economy) {
|
||||
if self.dirty {
|
||||
economy.set_ext("bank", serde_json::to_value(&self.data).unwrap_or_default());
|
||||
self.dirty = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// What each player has saved (the wealth tax counts it).
|
||||
pub fn savings(&self) -> HashMap<u128, i64> {
|
||||
self.data
|
||||
.accounts
|
||||
.iter()
|
||||
.filter_map(|(k, s)| Some((parse_uuid(k)?, s.amount)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn saved(&self, uuid: u128) -> i64 {
|
||||
self.data
|
||||
.accounts
|
||||
.get(&uuid_string(uuid))
|
||||
.map_or(0, |s| s.amount)
|
||||
}
|
||||
|
||||
/// `/bank ...`; None for another command.
|
||||
pub fn command(&mut self, economy: &mut Economy, me: &Player, line: &str) -> Option<Vec<Out>> {
|
||||
let words: Vec<&str> = line.split_whitespace().collect();
|
||||
if words.first() != Some(&"bank") {
|
||||
return None;
|
||||
}
|
||||
if !self.config.enabled {
|
||||
return Some(vec![Out::Error(
|
||||
"The bank is closed on this server.".into(),
|
||||
)]);
|
||||
}
|
||||
Some(match words[1..] {
|
||||
[] => self.show(economy, me),
|
||||
["deposit", amount] => self.deposit(economy, me, amount),
|
||||
["withdraw", amount] => self.withdraw(economy, me, amount),
|
||||
_ => vec![Out::Error(
|
||||
"Usage: /bank, /bank deposit <amount|all>, /bank withdraw <amount|all>".into(),
|
||||
)],
|
||||
})
|
||||
}
|
||||
|
||||
fn rate(&self) -> String {
|
||||
let bp = self.config.daily_basis_points;
|
||||
format!("{}.{:02}% a day", bp / 100, bp % 100)
|
||||
}
|
||||
|
||||
fn show(&self, economy: &Economy, me: &Player) -> Vec<Out> {
|
||||
let s = self
|
||||
.data
|
||||
.accounts
|
||||
.get(&uuid_string(me.uuid))
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let next =
|
||||
(s.low.min(self.config.max_interest_on) * self.config.daily_basis_points) / 10_000;
|
||||
vec![
|
||||
Out::Reply(format!(
|
||||
"Saved: {} (earned so far {}). Wallet: {}.",
|
||||
economy.money(s.amount),
|
||||
economy.money(s.earned),
|
||||
economy.money(economy.balance(me.uuid))
|
||||
)),
|
||||
Out::Reply(format!(
|
||||
"Interest: {} on up to {}, paid at 00:00 UTC on the lowest amount saved since the last payment, from the Treasury (at most {}% of it a day). Tonight: about {}.",
|
||||
self.rate(),
|
||||
economy.money(self.config.max_interest_on),
|
||||
self.config.max_treasury_percent,
|
||||
economy.money(next)
|
||||
)),
|
||||
Out::Reply(format!(
|
||||
"/bank deposit <amount|all> (up to {} saved), /bank withdraw <amount|all>: free, at any time. No loans.",
|
||||
economy.money(self.config.max_deposit)
|
||||
)),
|
||||
]
|
||||
}
|
||||
|
||||
fn amount(word: &str, all: i64) -> Option<i64> {
|
||||
if word == "all" {
|
||||
(all > 0).then_some(all)
|
||||
} else {
|
||||
parse_amount(word)
|
||||
}
|
||||
}
|
||||
|
||||
fn deposit(&mut self, economy: &mut Economy, me: &Player, word: &str) -> Vec<Out> {
|
||||
let key = uuid_string(me.uuid);
|
||||
let saved = self.saved(me.uuid);
|
||||
let room = (self.config.max_deposit - saved).max(0);
|
||||
let wallet = economy.balance(me.uuid);
|
||||
let Some(amount) = Self::amount(word, wallet.min(room)) else {
|
||||
return vec![Out::Error(if room == 0 {
|
||||
format!(
|
||||
"You have saved the most there is: {}.",
|
||||
economy.money(self.config.max_deposit)
|
||||
)
|
||||
} else {
|
||||
"The amount must be a positive whole number, or all.".into()
|
||||
})];
|
||||
};
|
||||
if amount > room {
|
||||
return vec![Out::Error(format!(
|
||||
"You may save {} more (at most {} in all).",
|
||||
economy.money(room),
|
||||
economy.money(self.config.max_deposit)
|
||||
))];
|
||||
}
|
||||
if let Err(e) = economy.to_pool("bank_deposit", me.uuid, POOL, amount, "") {
|
||||
return vec![Out::Error(e)];
|
||||
}
|
||||
// `low` stays: what was there all day earns, not the new coins.
|
||||
let s = self.data.accounts.entry(key).or_default();
|
||||
s.amount += amount;
|
||||
self.dirty = true;
|
||||
vec![Out::Reply(format!(
|
||||
"Saved {}: {} in the bank. It earns from tomorrow's payment on.",
|
||||
economy.money(amount),
|
||||
economy.money(s.amount)
|
||||
))]
|
||||
}
|
||||
|
||||
fn withdraw(&mut self, economy: &mut Economy, me: &Player, word: &str) -> Vec<Out> {
|
||||
let key = uuid_string(me.uuid);
|
||||
let saved = self.saved(me.uuid);
|
||||
if saved == 0 {
|
||||
return vec![Out::Error("You have nothing in the bank.".into())];
|
||||
}
|
||||
let Some(amount) = Self::amount(word, saved) else {
|
||||
return vec![Out::Error(if saved == 0 {
|
||||
"You have nothing in the bank.".into()
|
||||
} else {
|
||||
"The amount must be a positive whole number, or all.".into()
|
||||
})];
|
||||
};
|
||||
if amount > saved {
|
||||
return vec![Out::Error(format!(
|
||||
"You have {} in the bank.",
|
||||
economy.money(saved)
|
||||
))];
|
||||
}
|
||||
if let Err(e) = economy.from_pool("bank_withdraw", POOL, me.uuid, amount, "") {
|
||||
return vec![Out::Error(e)];
|
||||
}
|
||||
let s = self.data.accounts.get_mut(&key).expect("saved > 0");
|
||||
s.amount -= amount;
|
||||
s.low = s.low.min(s.amount);
|
||||
let left = s.amount;
|
||||
if left == 0 && s.earned == 0 {
|
||||
self.data.accounts.remove(&key);
|
||||
}
|
||||
self.dirty = true;
|
||||
vec![Out::Reply(format!(
|
||||
"Took {} out: {} left in the bank.",
|
||||
economy.money(amount),
|
||||
economy.money(left)
|
||||
))]
|
||||
}
|
||||
|
||||
/// Once a second from the glue; the work once a day: interest from the
|
||||
/// Treasury into each account (it stays saved, and earns from then on).
|
||||
pub fn tick(&mut self, economy: &mut Economy, now: i64) -> Vec<Out> {
|
||||
let today = now.div_euclid(DAY_MS);
|
||||
if today <= self.data.day {
|
||||
return Vec::new();
|
||||
}
|
||||
self.data.day = today;
|
||||
self.dirty = true;
|
||||
let bp = self.config.daily_basis_points.max(0);
|
||||
let due: Vec<(String, i64)> = self
|
||||
.data
|
||||
.accounts
|
||||
.iter()
|
||||
.map(|(k, s)| {
|
||||
(
|
||||
k.clone(),
|
||||
(s.low.clamp(0, self.config.max_interest_on) as i128 * bp as i128 / 10_000)
|
||||
as i64,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let total: i64 = due.iter().map(|d| d.1).sum();
|
||||
let cap = economy.pool(TREASURY).max(0) as i128
|
||||
* self.config.max_treasury_percent.clamp(0, 100) as i128
|
||||
/ 100;
|
||||
let cut = i128::from(total) > cap;
|
||||
let mut paid = 0;
|
||||
let mut out = Vec::new();
|
||||
for (key, d) in due {
|
||||
let pay = if cut {
|
||||
(i128::from(d) * cap / i128::from(total.max(1))) as i64
|
||||
} else {
|
||||
d
|
||||
};
|
||||
let pay = pay.min(MAX_AMOUNT);
|
||||
if pay > 0
|
||||
&& economy
|
||||
.pool_move("bank_interest", TREASURY, POOL, pay, "")
|
||||
.is_ok()
|
||||
{
|
||||
paid += pay;
|
||||
let s = self.data.accounts.get_mut(&key).expect("listed");
|
||||
s.amount += pay;
|
||||
s.earned += pay;
|
||||
if let Some(u) = parse_uuid(&key) {
|
||||
out.push(Out::Tell(
|
||||
u,
|
||||
format!(
|
||||
"Bank interest: {} (saved: {}).",
|
||||
economy.money(pay),
|
||||
economy.money(s.amount)
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
// From now on, what's there all day earns.
|
||||
for s in self.data.accounts.values_mut() {
|
||||
s.low = s.amount;
|
||||
}
|
||||
self.data.paid += paid;
|
||||
self.data.last = paid;
|
||||
self.data.last_cut = cut;
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::smp_economy::Config as EcoConfig;
|
||||
|
||||
const ALEX: u128 = 1;
|
||||
const ALT: u128 = 2;
|
||||
const T0: i64 = 1_793_577_600_000; // a midnight, UTC
|
||||
|
||||
fn me(uuid: u128) -> Player {
|
||||
Player {
|
||||
uuid,
|
||||
name: format!("P{uuid}"),
|
||||
level: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn setup(name: &str, treasury: i64) -> (Economy, Bank, std::path::PathBuf) {
|
||||
let dir = std::env::temp_dir().join(format!("smp-bank-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let mut e = Economy::open(&dir, EcoConfig::default()).unwrap();
|
||||
e.now = T0 + 1000;
|
||||
for u in [ALEX, ALT] {
|
||||
e.seen(u, &format!("P{u}"));
|
||||
e.adjust("sell", u, 50_000, "").unwrap();
|
||||
}
|
||||
if treasury > 0 {
|
||||
e.to_pool("seed", ALT, TREASURY, treasury, "").unwrap();
|
||||
}
|
||||
let b = Bank::open(&e, Config::default());
|
||||
(e, b, dir)
|
||||
}
|
||||
|
||||
fn total(e: &Economy) -> i64 {
|
||||
e.balance(ALEX) + e.balance(ALT) + e.pool(TREASURY) + e.pool(POOL)
|
||||
}
|
||||
|
||||
fn say(b: &mut Bank, e: &mut Economy, who: u128, line: &str) -> Vec<Out> {
|
||||
b.command(e, &me(who), line).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_deposit_earns_from_the_next_full_day_and_interest_comes_from_the_treasury() {
|
||||
let (mut e, mut b, _d) = setup("earn", 10_000);
|
||||
let start = total(&e);
|
||||
say(&mut b, &mut e, ALEX, "bank deposit 5000");
|
||||
assert_eq!((e.balance(ALEX), e.pool(POOL)), (45_000, 5_000));
|
||||
// The first midnight: deposited during the day, nothing earned.
|
||||
assert!(b.tick(&mut e, T0 + DAY_MS).is_empty());
|
||||
// The next: 0.1% of 5,000.
|
||||
let out = b.tick(&mut e, T0 + 2 * DAY_MS);
|
||||
assert_eq!(
|
||||
out,
|
||||
[Out::Tell(
|
||||
ALEX,
|
||||
"Bank interest: 5 coins (saved: 5,005 coins).".into()
|
||||
)]
|
||||
);
|
||||
assert_eq!((e.pool(TREASURY), b.saved(ALEX)), (9_995, 5_005));
|
||||
assert!(
|
||||
b.tick(&mut e, T0 + 2 * DAY_MS + 5_000).is_empty(),
|
||||
"once a day"
|
||||
);
|
||||
assert_eq!(total(&e), start, "nothing minted");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deposit_before_midnight_and_withdraw_after_earns_nothing() {
|
||||
let (mut e, mut b, _d) = setup("game", 10_000);
|
||||
b.tick(&mut e, T0 + DAY_MS);
|
||||
say(&mut b, &mut e, ALEX, "bank deposit 10000");
|
||||
b.tick(&mut e, T0 + 2 * DAY_MS); // deposited today: low was 0
|
||||
say(&mut b, &mut e, ALEX, "bank withdraw all");
|
||||
say(&mut b, &mut e, ALEX, "bank deposit 10000");
|
||||
// Took it out during the day: the low was 0 again.
|
||||
assert!(b.tick(&mut e, T0 + 3 * DAY_MS).is_empty());
|
||||
assert_eq!(e.balance(ALEX) + b.saved(ALEX), 50_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caps_hold_and_a_poor_treasury_pays_pro_rata() {
|
||||
let (mut e, mut b, _d) = setup("caps", 100);
|
||||
let err = |o: Vec<Out>| match &o[0] {
|
||||
Out::Error(s) => s.clone(),
|
||||
o => panic!("{o:?}"),
|
||||
};
|
||||
assert!(
|
||||
err(say(&mut b, &mut e, ALEX, "bank deposit 30000"))
|
||||
.starts_with("You may save 25,000 coins more")
|
||||
);
|
||||
assert!(err(say(&mut b, &mut e, ALEX, "bank withdraw 1")).starts_with("You have nothing"));
|
||||
say(&mut b, &mut e, ALEX, "bank deposit all");
|
||||
assert_eq!(b.saved(ALEX), 25_000, "all = up to the cap");
|
||||
say(&mut b, &mut e, ALT, "bank deposit 10000");
|
||||
b.tick(&mut e, T0 + DAY_MS);
|
||||
// Due: 10 each (only 10,000 earns); the Treasury (100) pays 5% = 5 in all.
|
||||
b.tick(&mut e, T0 + 2 * DAY_MS);
|
||||
assert_eq!((b.data.last, b.data.last_cut), (4, true));
|
||||
assert_eq!(e.pool(TREASURY), 96);
|
||||
// An empty Treasury pays nothing; nobody goes below zero.
|
||||
let (mut e, mut b, _d) = setup("empty", 0);
|
||||
say(&mut b, &mut e, ALEX, "bank deposit 5000");
|
||||
b.tick(&mut e, T0 + DAY_MS);
|
||||
assert!(b.tick(&mut e, T0 + 2 * DAY_MS).is_empty());
|
||||
assert_eq!(b.saved(ALEX), 5_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn savings_survive_a_restart_with_the_money() {
|
||||
let (mut e, mut b, dir) = setup("restart", 1_000);
|
||||
say(&mut b, &mut e, ALEX, "bank deposit 700");
|
||||
b.store(&mut e);
|
||||
e.save().unwrap();
|
||||
let e2 = Economy::open(&dir, EcoConfig::default()).unwrap();
|
||||
let b2 = Bank::open(&e2, Config::default());
|
||||
assert_eq!((b2.saved(ALEX), e2.pool(POOL)), (700, 700));
|
||||
assert_eq!(b2.savings()[&ALEX], 700);
|
||||
}
|
||||
}
|
||||
@@ -9,9 +9,9 @@
|
||||
// each player staked and got back today, the day's biggest wins, each game's
|
||||
// totals) and the lobby menu. Winnings are paid from the casino's own pot
|
||||
// (the economy's "casino" pool, which every lost bet fills); a win the pot
|
||||
// can't cover is paid up to what it holds, never with new money. Once an
|
||||
// hour the pot above its float goes to the Treasury: the house edge is the
|
||||
// state's income, a money sink for the players. Each game is a file of its
|
||||
// can't cover is paid up to what it holds. A new casino's pot gets its
|
||||
// float once, made for it; then it keeps the house edge, a money sink for
|
||||
// the players. Each game is a file of its
|
||||
// own (smp_casino_roulette, ...). No game types: the glue (casino_glue.rs)
|
||||
// turns the icons and effects into menus, packets and commands. Added to the
|
||||
// game as `game::smp_casino`; also a module of this crate, for its tests.
|
||||
@@ -20,7 +20,7 @@ use super::smp_casino_blackjack::{self as bj, Blackjack};
|
||||
use super::smp_casino_crash::{self as crash, Crash};
|
||||
use super::smp_casino_roulette::{self as roulette, Roulette};
|
||||
use super::smp_casino_slots::{self as slots, Slots};
|
||||
use super::smp_economy::{Economy, Out, Player, TREASURY};
|
||||
use super::smp_economy::{Economy, Out, Player};
|
||||
use super::smp_store::{read_json, uuid_string, write_json};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
@@ -34,7 +34,6 @@ pub const SIZE: usize = 54;
|
||||
/// The least time between two pictures of an animated menu: 5 a second.
|
||||
pub const FRAME_MS: i64 = 200;
|
||||
const DAY_MS: i64 = 86_400_000;
|
||||
const HOUR_MS: i64 = 3_600_000;
|
||||
/// Wins kept in the day's list.
|
||||
const WINS: usize = 10;
|
||||
|
||||
@@ -53,7 +52,7 @@ pub struct Config {
|
||||
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.
|
||||
/// What a new casino's pot starts with, for paying wins (made once).
|
||||
pub float: i64,
|
||||
/// A win of at least this profit is told to everyone.
|
||||
pub announce_profit: i64,
|
||||
@@ -398,10 +397,13 @@ pub struct Ledger {
|
||||
/// The day's biggest wins (by profit), biggest first.
|
||||
pub wins: Vec<Win>,
|
||||
pub totals: BTreeMap<String, Totals>,
|
||||
/// When the pot last sent its profit to the Treasury (ms).
|
||||
pub last_sweep: i64,
|
||||
}
|
||||
|
||||
/// The mark, in `economy.json`'s `ext`, that the pot got its float: kept with
|
||||
/// the pot's money so one write saves both, and a lost or unreadable
|
||||
/// `casino.json` can't make the float again.
|
||||
const FLOATED: &str = "casino_float";
|
||||
|
||||
/// A bet placed and held, for a game to decide.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Stake {
|
||||
@@ -710,31 +712,21 @@ impl Casino {
|
||||
fx
|
||||
}
|
||||
|
||||
/// Once an hour: the pot above its float goes to the Treasury; below
|
||||
/// it, the Treasury tops it up with what it can spare. Never new money.
|
||||
pub fn sweep(&mut self, economy: &mut Economy, now: i64) -> Option<String> {
|
||||
if now - self.ledger.last_sweep < HOUR_MS {
|
||||
/// Once, ever: a new casino's pot gets its float (what it lacks of
|
||||
/// it), made for it. A world whose pot already holds money (from before
|
||||
/// 0.30) gets only what it lacks.
|
||||
pub fn seed(&mut self, economy: &mut Economy) -> Option<String> {
|
||||
if economy.ext(FLOATED) == Some(&serde_json::Value::Bool(true)) {
|
||||
return None;
|
||||
}
|
||||
self.ledger.last_sweep = now;
|
||||
self.dirty = true;
|
||||
let (pot, float) = (economy.pool(POT), self.config.float.max(0));
|
||||
let result = if pot > float {
|
||||
economy.pool_move(
|
||||
"casino_profit",
|
||||
POT,
|
||||
TREASURY,
|
||||
pot - float,
|
||||
"the casino's pot above its float",
|
||||
)
|
||||
} else {
|
||||
let top_up = (float - pot).min(economy.pool(TREASURY));
|
||||
if top_up <= 0 {
|
||||
return None;
|
||||
}
|
||||
economy.pool_move("casino_float", TREASURY, POT, top_up, "the casino's float")
|
||||
};
|
||||
result.err()
|
||||
economy.set_ext(FLOATED, serde_json::Value::Bool(true));
|
||||
let lacks = self.config.float.max(0) - economy.pool(POT);
|
||||
if lacks <= 0 {
|
||||
return None;
|
||||
}
|
||||
economy
|
||||
.mint_pool("casino_float", POT, lacks, "the casino's float")
|
||||
.err()
|
||||
}
|
||||
|
||||
/// The day's biggest wins, as lines.
|
||||
@@ -823,7 +815,7 @@ impl Casino {
|
||||
),
|
||||
"Every draw is made by the server (ChaCha20 seeded by the system), before the animation starts.".into(),
|
||||
format!(
|
||||
"Wins are paid from the casino's pot ({} now); a win larger than the pot is paid up to it. The house's profit goes to the Treasury.",
|
||||
"Wins are paid from the casino's pot ({} now); a win larger than the pot is paid up to it. The house's profit stays in it.",
|
||||
economy.money(economy.pool(POT))
|
||||
),
|
||||
];
|
||||
@@ -1499,7 +1491,7 @@ impl Casino {
|
||||
}
|
||||
|
||||
/// Each tick while something moves (`animating`), and once a second:
|
||||
/// the frames due, the rounds that end, the hourly sweep.
|
||||
/// the frames due, the rounds that end, the pot's float (once).
|
||||
pub fn tick(&mut self, economy: &mut Economy, now: i64) -> Vec<Fx> {
|
||||
let mut fx = Vec::new();
|
||||
for (spin, n) in self.roulette.ended(now) {
|
||||
@@ -1569,9 +1561,9 @@ impl Casino {
|
||||
});
|
||||
}
|
||||
}
|
||||
if let Some(e) = self.sweep(economy, now) {
|
||||
if let Some(e) = self.seed(economy) {
|
||||
fx.push(Fx::Out(Out::All(format!(
|
||||
"[casino] the pot's sweep failed: {e}"
|
||||
"[casino] the pot's float failed: {e}"
|
||||
))));
|
||||
}
|
||||
fx
|
||||
@@ -1662,20 +1654,14 @@ pub(crate) mod tests {
|
||||
}
|
||||
let (mut c, problem) = Casino::open(&dir, Config::default(), true);
|
||||
assert!(problem.is_none());
|
||||
// Draws the same every run; no sweep unless asked.
|
||||
// Draws the same every run; the pot as set up.
|
||||
c.rng = Rng::from_seed([7; 32]);
|
||||
c.ledger.last_sweep = i64::MAX / 2;
|
||||
e.set_ext(FLOATED, serde_json::Value::Bool(true));
|
||||
(e, c, dir)
|
||||
}
|
||||
|
||||
pub fn money(e: &Economy) -> i64 {
|
||||
e.balance(A)
|
||||
+ e.balance(B)
|
||||
+ e.balance(99)
|
||||
+ e.held(A)
|
||||
+ e.held(B)
|
||||
+ e.pool(POT)
|
||||
+ e.pool(TREASURY)
|
||||
e.balance(A) + e.balance(B) + e.balance(99) + e.held(A) + e.held(B) + e.pool(POT)
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1902,20 +1888,29 @@ pub(crate) mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_sweep_sends_the_profit_to_the_treasury_and_never_mints() {
|
||||
let (mut e, mut c, dir) = setup("sweep", 25_000);
|
||||
let before = money(&e);
|
||||
c.ledger.last_sweep = 0;
|
||||
assert_eq!(c.sweep(&mut e, HOUR_MS), None);
|
||||
assert_eq!((e.pool(POT), e.pool(TREASURY)), (20_000, 5_000));
|
||||
// Not again within the hour.
|
||||
e.pool_move("test", POT, TREASURY, 18_000, "test").unwrap();
|
||||
c.sweep(&mut e, HOUR_MS + 1);
|
||||
assert_eq!(e.pool(POT), 2000);
|
||||
// An hour later the Treasury tops the float up with what it holds.
|
||||
c.sweep(&mut e, 2 * HOUR_MS);
|
||||
assert_eq!((e.pool(POT), e.pool(TREASURY)), (20_000, 5_000));
|
||||
assert_eq!(money(&e), before);
|
||||
fn a_new_pot_gets_its_float_once_and_keeps_the_house_edge() {
|
||||
let (mut e, mut c, dir) = setup("seed", 0);
|
||||
e.set_ext(FLOATED, serde_json::Value::Bool(false));
|
||||
assert_eq!(c.seed(&mut e), None);
|
||||
assert_eq!(e.pool(POT), 20_000);
|
||||
assert_eq!(e.flows().minted["casino_float"], 20_000);
|
||||
// Never again, however low the pot runs.
|
||||
e.pool_move("test", POT, "elsewhere", 19_000, "test")
|
||||
.unwrap();
|
||||
assert_eq!(c.seed(&mut e), None);
|
||||
assert_eq!(e.pool(POT), 1000);
|
||||
// Nor after a restart that lost casino.json: the mark is the economy's.
|
||||
e.save().unwrap();
|
||||
let mut e = Economy::open(&dir, EconomyConfig::default()).unwrap();
|
||||
std::fs::remove_file(dir.join("casino.json")).ok();
|
||||
let (mut c, _) = Casino::open(&dir, Config::default(), true);
|
||||
assert_eq!(c.seed(&mut e), None);
|
||||
assert_eq!(e.pool(POT), 1000, "no second float");
|
||||
// An old pot with money gets only what it lacks.
|
||||
let (mut e, mut c, _) = setup("seed-old", 25_000);
|
||||
e.set_ext(FLOATED, serde_json::Value::Bool(false));
|
||||
c.seed(&mut e);
|
||||
assert_eq!(e.pool(POT), 25_000, "above the float: nothing made");
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,9 @@ pub struct Config {
|
||||
pub chunks: Vec<[i64; 2]>,
|
||||
/// The entities only the owner and trusted players may hit in a claim.
|
||||
pub protected_entities: Vec<String>,
|
||||
/// Blocks around 0 0, in every dimension, where no one may claim (free
|
||||
/// for all); 0 for none. Claims already there are dropped at start.
|
||||
pub free_radius: i64,
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
@@ -36,6 +39,7 @@ impl Default for Config {
|
||||
.iter()
|
||||
.map(|&e| format!("minecraft:{e}"))
|
||||
.collect(),
|
||||
free_radius: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -183,6 +187,43 @@ impl Claims {
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether a chunk touches the free zone around 0 0 (any dimension).
|
||||
fn in_free_zone(&self, cx: i32, cz: i32) -> bool {
|
||||
let r = self.config.free_radius;
|
||||
let near = |c: i32| i64::from(c) * 16 <= r && i64::from(c) * 16 + 15 >= -r;
|
||||
r > 0 && near(cx) && near(cz)
|
||||
}
|
||||
|
||||
/// Drops the claims inside the free zone (a radius set after they were
|
||||
/// made): one line each, for the log.
|
||||
pub fn clear_free_zone(&mut self) -> Vec<String> {
|
||||
let inside: Vec<(String, i32, i32, u128)> = self
|
||||
.owners
|
||||
.iter()
|
||||
.flat_map(|(d, c)| {
|
||||
c.iter()
|
||||
.filter(|((x, z), _)| self.in_free_zone(*x, *z))
|
||||
.map(|((x, z), u)| (d.clone(), *x, *z, *u))
|
||||
})
|
||||
.collect();
|
||||
let mut lines = Vec::new();
|
||||
for (dimension, cx, cz, owner) in inside {
|
||||
if let Some(c) = self.owners.get_mut(&dimension) {
|
||||
c.remove(&(cx, cz));
|
||||
}
|
||||
self.data.chunks.remove(&key(&dimension, cx, cz));
|
||||
self.changed = true;
|
||||
lines.push(format!(
|
||||
"chunk {cx} {cz} (x {}, z {}) in {dimension} of {} is in the free zone: unclaimed",
|
||||
cx * 16,
|
||||
cz * 16,
|
||||
smp_store::uuid_string(owner)
|
||||
));
|
||||
}
|
||||
lines.sort();
|
||||
lines
|
||||
}
|
||||
|
||||
pub fn save(&mut self) -> Result<(), String> {
|
||||
if self.changed {
|
||||
smp_store::write_json(&self.path, &self.data)?;
|
||||
@@ -271,15 +312,6 @@ impl Claims {
|
||||
Err(Some(format!("This land is claimed by {}.", name_of(owner))))
|
||||
}
|
||||
|
||||
/// How many chunks each owner holds (the weekly land tax), one pass.
|
||||
pub fn counts(&self) -> std::collections::HashMap<u128, usize> {
|
||||
let mut out = std::collections::HashMap::new();
|
||||
for &u in self.owners.values().flat_map(|c| c.values()) {
|
||||
*out.entry(u).or_default() += 1;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// How many chunks `uuid` holds.
|
||||
pub fn held(&self, uuid: u128) -> usize {
|
||||
self.owners
|
||||
@@ -334,6 +366,10 @@ impl Claims {
|
||||
("claim", []) => match self.chunk_owner(here, cx, cz) {
|
||||
Some(u) if u == me.uuid => vec![Out::Error("This chunk is already yours.".into())],
|
||||
Some(u) => vec![Out::Error(format!("This chunk is {}'s.", name_of(u)))],
|
||||
None if self.in_free_zone(cx, cz) => vec![Out::Error(format!(
|
||||
"No claims within {} blocks of 0 0: that land is free for all.",
|
||||
self.config.free_radius
|
||||
))],
|
||||
None => {
|
||||
let (held, limit) = (self.held(me.uuid), self.limit(played));
|
||||
if held >= limit {
|
||||
@@ -513,6 +549,54 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_claims_in_the_free_zone_and_old_ones_are_dropped() {
|
||||
let d = dir();
|
||||
let mut c = Claims::open(&d, Config::default()).unwrap();
|
||||
let alex = player(A, 0);
|
||||
for (x, z) in [(8.0, 8.0), (-520.0, 8.0), (600.0, 600.0)] {
|
||||
c.command(&alex, &at(x, z), 1_000_000, &find, &name_of, "claim")
|
||||
.unwrap();
|
||||
}
|
||||
c.save().unwrap();
|
||||
let config = Config {
|
||||
free_radius: 500,
|
||||
..Config::default()
|
||||
};
|
||||
let mut c = Claims::open(&d, config).unwrap();
|
||||
// Chunk 0 0 and chunk -33 0 (x -528 to -513: outside 500) stay apart.
|
||||
assert_eq!(c.clear_free_zone().len(), 1);
|
||||
let ow = "minecraft:overworld";
|
||||
assert_eq!(c.owner(ow, 8, 8), None);
|
||||
assert_eq!(c.owner(ow, -520, 8), Some(A));
|
||||
assert_eq!(c.owner(ow, 600, 600), Some(A));
|
||||
let out = c
|
||||
.command(
|
||||
&alex,
|
||||
&at(490.0, -490.0),
|
||||
1_000_000,
|
||||
&find,
|
||||
&name_of,
|
||||
"claim",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(matches!(&out[0], Out::Error(e) if e.contains("free for all")));
|
||||
// The zone is the same in every dimension.
|
||||
let nether = Place {
|
||||
dimension: "minecraft:the_nether".into(),
|
||||
..at(8.0, 8.0)
|
||||
};
|
||||
let out = c
|
||||
.command(&alex, &nether, 1_000_000, &find, &name_of, "claim")
|
||||
.unwrap();
|
||||
assert!(matches!(&out[0], Out::Error(e) if e.contains("free for all")));
|
||||
// Saved without them.
|
||||
c.save().unwrap();
|
||||
let again = Claims::open(&d, Config::default()).unwrap();
|
||||
assert_eq!(again.owner(ow, 8, 8), None);
|
||||
let _ = std::fs::remove_dir_all(d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_world_stays_out_of_a_claim() {
|
||||
let d = dir();
|
||||
|
||||
@@ -42,46 +42,6 @@ pub struct Account {
|
||||
pub balance: i64,
|
||||
}
|
||||
|
||||
/// What the Treasury paid or got, for `/treasury log`.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Movement {
|
||||
pub time: i64,
|
||||
/// `vat`, `land_tax`, `grant`...
|
||||
pub kind: String,
|
||||
/// Positive: income; negative: spending.
|
||||
pub amount: i64,
|
||||
/// The player's name last seen.
|
||||
pub player: String,
|
||||
/// Why, as the operator wrote it (payments); empty for taxes.
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub reason: String,
|
||||
/// The operator who paid it (name).
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub by: String,
|
||||
}
|
||||
|
||||
/// The Treasury: the state's account. Every tax, fee and house edge goes in;
|
||||
/// operators' grants, refunds and prizes (and later bank interest and
|
||||
/// contracts) come out, never more than it holds. Nobody can make money.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Treasury {
|
||||
/// Since the Treasury opened, by kind.
|
||||
pub income: BTreeMap<String, i64>,
|
||||
pub spent: BTreeMap<String, i64>,
|
||||
/// Today's (UTC day number), by kind.
|
||||
pub day: i64,
|
||||
pub income_today: BTreeMap<String, i64>,
|
||||
pub spent_today: BTreeMap<String, i64>,
|
||||
/// The day before `day`'s, kept for the daily report.
|
||||
pub income_yesterday: BTreeMap<String, i64>,
|
||||
pub spent_yesterday: BTreeMap<String, i64>,
|
||||
/// The latest movements, newest last (at most `LOG`).
|
||||
pub log: Vec<Movement>,
|
||||
}
|
||||
|
||||
/// Movements `/treasury log` keeps.
|
||||
pub const LOG: usize = 50;
|
||||
/// Milliseconds in a day.
|
||||
pub const DAY_MS: i64 = 86_400_000;
|
||||
|
||||
@@ -100,16 +60,14 @@ pub struct Accounts {
|
||||
/// balances, so one write moves both and a crash can't split them.
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub ext: BTreeMap<String, serde_json::Value>,
|
||||
/// System pools, by name: the Treasury (`TREASURY`), its insurance
|
||||
/// fund (`INSURANCE`), the casino's pot. Money in a pool belongs to no
|
||||
/// player: never in `accounts()`, `/baltop` or the sidebar.
|
||||
/// System pools, by name: the casino's pot, the auction house's bids.
|
||||
/// Money in a pool belongs to no player: never in `accounts()`,
|
||||
/// `/baltop` or the sidebar.
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub pools: BTreeMap<String, i64>,
|
||||
/// What went into and out of the Treasury pool, for `/treasury`.
|
||||
#[serde(default)]
|
||||
pub treasury: Treasury,
|
||||
/// Money made and destroyed by the server (`adjust`: market sales and
|
||||
/// purchases, the daily reward), by kind, for `/eco stats`.
|
||||
/// purchases, the daily reward, operators' `/eco give` and `/eco take`),
|
||||
/// by kind, for `/eco stats`.
|
||||
#[serde(default)]
|
||||
pub flows: Flows,
|
||||
}
|
||||
@@ -144,10 +102,10 @@ impl Flows {
|
||||
}
|
||||
}
|
||||
|
||||
/// The state's pool: taxes, fees and house edges in; grants out.
|
||||
pub const TREASURY: &str = "treasury";
|
||||
/// Part of the state: covers what a liquidated position can't.
|
||||
pub const INSURANCE: &str = "insurance";
|
||||
/// What 0.30 took out (the Treasury, its insurance fund, the bank, the
|
||||
/// futures, the jobs, the taxes): their pools and their state in `ext`.
|
||||
const RETIRED_POOLS: [&str; 4] = ["treasury", "insurance", "bank", "futures"];
|
||||
const RETIRED_PARTS: [&str; 4] = ["bank", "futures", "jobs", "tax"];
|
||||
|
||||
/// A player as a command sees them.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
@@ -198,6 +156,7 @@ impl Economy {
|
||||
revision: 0,
|
||||
now: 0,
|
||||
};
|
||||
economy.retire()?;
|
||||
// Games the last run left undecided: their stakes go back.
|
||||
let held: Vec<(String, i64)> = economy.accounts.held.clone().into_iter().collect();
|
||||
for (key, amount) in held {
|
||||
@@ -208,6 +167,60 @@ impl Economy {
|
||||
Ok(economy)
|
||||
}
|
||||
|
||||
/// Once, on a world from before 0.30: bank savings and open futures
|
||||
/// positions' margins go back to their owners (logged `bank_closed`,
|
||||
/// `futures_closed`), and the retired parts' pools and state are dropped.
|
||||
fn retire(&mut self) -> Result<(), String> {
|
||||
let mut back: Vec<(&str, u128, i64)> = Vec::new();
|
||||
let ext = &self.accounts.ext;
|
||||
if let Some(accounts) = ext
|
||||
.get("bank")
|
||||
.and_then(|b| b.get("accounts"))
|
||||
.and_then(|a| a.as_object())
|
||||
{
|
||||
for (key, saving) in accounts {
|
||||
let amount = saving.get("amount").and_then(|a| a.as_i64()).unwrap_or(0);
|
||||
if let Some(u) = super::smp_store::parse_uuid(key) {
|
||||
back.push(("bank_closed", u, amount));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(positions) = ext
|
||||
.get("futures")
|
||||
.and_then(|f| f.get("positions"))
|
||||
.and_then(|p| p.as_array())
|
||||
{
|
||||
for p in positions {
|
||||
let owner = p
|
||||
.get("owner")
|
||||
.and_then(|o| o.as_str())
|
||||
.and_then(super::smp_store::parse_uuid);
|
||||
let margin = p.get("margin").and_then(|m| m.as_i64()).unwrap_or(0);
|
||||
if let Some(u) = owner {
|
||||
back.push(("futures_closed", u, margin));
|
||||
}
|
||||
}
|
||||
}
|
||||
for (kind, uuid, amount) in back.into_iter().filter(|b| b.2 > 0) {
|
||||
let entry = self.entry(kind, None, Some(uuid), amount, "");
|
||||
self.audit.append(&entry)?;
|
||||
let account = self.accounts.accounts.entry(uuid_string(uuid)).or_default();
|
||||
account.balance = account.balance.saturating_add(amount);
|
||||
self.touch();
|
||||
}
|
||||
for part in RETIRED_PARTS {
|
||||
if self.accounts.ext.remove(part).is_some() {
|
||||
self.touch();
|
||||
}
|
||||
}
|
||||
for pool in RETIRED_POOLS {
|
||||
if self.accounts.pools.remove(pool).is_some() {
|
||||
self.touch();
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn touch(&mut self) {
|
||||
self.dirty = true;
|
||||
self.revision += 1;
|
||||
@@ -295,30 +308,23 @@ impl Economy {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A game decided: each stake leaves held, and `winner` gets `prize`
|
||||
/// (at most the stakes together), logged as `kind`.
|
||||
/// A game decided: each stake leaves held, and `winner` gets them all,
|
||||
/// logged as `kind`.
|
||||
pub fn award(
|
||||
&mut self,
|
||||
kind: &str,
|
||||
stakes: &[(u128, i64)],
|
||||
winner: u128,
|
||||
prize: i64,
|
||||
) -> Result<(), String> {
|
||||
let total: i64 = stakes.iter().map(|(_, a)| *a).sum();
|
||||
if stakes.iter().any(|(u, a)| self.held(*u) < *a) || !(0..=total).contains(&prize) {
|
||||
if stakes.iter().any(|(u, a)| self.held(*u) < *a) {
|
||||
return Err("The stakes are not held.".into());
|
||||
}
|
||||
let fee = total - prize;
|
||||
let prize: i64 = stakes.iter().map(|(_, a)| *a).sum();
|
||||
let mut entry = self.entry(kind, None, Some(winner), prize, "");
|
||||
entry["stakes"] = stakes
|
||||
.iter()
|
||||
.map(|(u, a)| serde_json::json!({ "uuid": uuid_string(*u), "amount": a }))
|
||||
.collect();
|
||||
if fee > 0 {
|
||||
// The house's fee goes to the Treasury, not away.
|
||||
entry["fee"] = fee.into();
|
||||
entry["to_pool"] = TREASURY.into();
|
||||
}
|
||||
self.audit.append(&entry)?;
|
||||
for (u, a) in stakes {
|
||||
self.take_held(&uuid_string(*u), *a);
|
||||
@@ -329,10 +335,6 @@ impl Economy {
|
||||
.entry(uuid_string(winner))
|
||||
.or_default();
|
||||
account.balance = account.balance.saturating_add(prize);
|
||||
if fee > 0 {
|
||||
self.pool_add(TREASURY, fee);
|
||||
self.count_treasury("gambling_fee", fee, Some(winner), "", "");
|
||||
}
|
||||
self.touch();
|
||||
Ok(())
|
||||
}
|
||||
@@ -351,11 +353,6 @@ impl Economy {
|
||||
self.accounts.pools.get(name).copied().unwrap_or(0)
|
||||
}
|
||||
|
||||
/// What went into and out of the Treasury.
|
||||
pub fn treasury(&self) -> &Treasury {
|
||||
&self.accounts.treasury
|
||||
}
|
||||
|
||||
fn pool_add(&mut self, name: &str, delta: i64) {
|
||||
let p = self.accounts.pools.entry(name.to_owned()).or_default();
|
||||
*p = p.saturating_add(delta);
|
||||
@@ -364,61 +361,8 @@ impl Economy {
|
||||
}
|
||||
}
|
||||
|
||||
/// Counts a Treasury movement (`amount` > 0 income, < 0 spending).
|
||||
fn count_treasury(
|
||||
&mut self,
|
||||
kind: &str,
|
||||
amount: i64,
|
||||
player: Option<u128>,
|
||||
reason: &str,
|
||||
by: &str,
|
||||
) {
|
||||
if amount == 0 {
|
||||
return;
|
||||
}
|
||||
let day = self.now.div_euclid(DAY_MS);
|
||||
let player = player
|
||||
.and_then(|u| self.name_of(u).map(str::to_owned))
|
||||
.unwrap_or_default();
|
||||
let by = super::smp_store::parse_uuid(by)
|
||||
.and_then(|u| self.name_of(u).map(str::to_owned))
|
||||
.unwrap_or_else(|| by.to_owned());
|
||||
let t = &mut self.accounts.treasury;
|
||||
if t.day != day {
|
||||
let next = t.day + 1 == day;
|
||||
t.income_yesterday = std::mem::take(&mut t.income_today);
|
||||
t.spent_yesterday = std::mem::take(&mut t.spent_today);
|
||||
if !next {
|
||||
t.income_yesterday.clear();
|
||||
t.spent_yesterday.clear();
|
||||
}
|
||||
t.day = day;
|
||||
}
|
||||
let (all, today) = if amount > 0 {
|
||||
(&mut t.income, &mut t.income_today)
|
||||
} else {
|
||||
(&mut t.spent, &mut t.spent_today)
|
||||
};
|
||||
for map in [all, today] {
|
||||
let v = map.entry(kind.to_owned()).or_default();
|
||||
*v = v.saturating_add(amount.abs());
|
||||
}
|
||||
t.log.push(Movement {
|
||||
time: self.now,
|
||||
kind: kind.to_owned(),
|
||||
amount,
|
||||
player,
|
||||
reason: reason.to_owned(),
|
||||
by,
|
||||
});
|
||||
if t.log.len() > LOG {
|
||||
let extra = t.log.len() - LOG;
|
||||
t.log.drain(..extra);
|
||||
}
|
||||
}
|
||||
|
||||
/// A tax, a fee, a stake lost: `amount` of `uuid`'s balance goes into
|
||||
/// `pool`, logged as `kind`. An Err when they hold less.
|
||||
/// A bid, a bet: `amount` of `uuid`'s balance goes into `pool`, logged
|
||||
/// as `kind`. An Err when they hold less.
|
||||
pub fn to_pool(
|
||||
&mut self,
|
||||
kind: &str,
|
||||
@@ -463,16 +407,13 @@ impl Economy {
|
||||
.expect("checked")
|
||||
.balance -= amount;
|
||||
self.pool_add(pool, amount);
|
||||
if pool == TREASURY {
|
||||
self.count_treasury(kind, amount, Some(uuid), reason, by);
|
||||
}
|
||||
self.touch();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A payment from `pool` to `uuid` (a grant, a refund, a payout),
|
||||
/// A payment from `pool` to `uuid` (a refund, a payout),
|
||||
/// logged as `kind`: never more than the pool holds.
|
||||
#[allow(dead_code)] // the casino, the auction house and the futures
|
||||
#[allow(dead_code)] // the casino and the auction house
|
||||
pub fn from_pool(
|
||||
&mut self,
|
||||
kind: &str,
|
||||
@@ -521,16 +462,13 @@ impl Economy {
|
||||
.expect("checked")
|
||||
.balance += amount;
|
||||
self.pool_add(pool, -amount);
|
||||
if pool == TREASURY {
|
||||
self.count_treasury(kind, -amount, Some(uuid), reason, by);
|
||||
}
|
||||
self.touch();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Moves `amount` from one pool to another (the casino's profit to the
|
||||
/// Treasury), logged as `kind`; an Err when `from` holds less.
|
||||
#[allow(dead_code)] // the casino, the auction house and the futures
|
||||
/// Moves `amount` from one pool to another, logged as `kind`; an Err
|
||||
/// when `from` holds less.
|
||||
#[allow(dead_code)] // the casino and the auction house
|
||||
pub fn pool_move(
|
||||
&mut self,
|
||||
kind: &str,
|
||||
@@ -557,12 +495,29 @@ impl Economy {
|
||||
self.audit.append(&entry)?;
|
||||
self.pool_add(from, -amount);
|
||||
self.pool_add(to, amount);
|
||||
if to == TREASURY {
|
||||
self.count_treasury(kind, amount, None, reason, "");
|
||||
}
|
||||
if from == TREASURY {
|
||||
self.count_treasury(kind, -amount, None, reason, "");
|
||||
self.touch();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Money made for a pool (the casino's float, once), logged as `kind`
|
||||
/// and counted as made.
|
||||
pub fn mint_pool(
|
||||
&mut self,
|
||||
kind: &str,
|
||||
pool: &str,
|
||||
amount: i64,
|
||||
reason: &str,
|
||||
) -> Result<(), String> {
|
||||
if amount <= 0 || amount > MAX_AMOUNT {
|
||||
return Err("The amount must be a positive whole number.".into());
|
||||
}
|
||||
let entry = serde_json::json!({
|
||||
"time": self.now, "kind": kind, "amount": amount,
|
||||
"to_pool": pool, "pool_before": self.pool(pool), "reason": reason,
|
||||
});
|
||||
self.audit.append(&entry)?;
|
||||
self.pool_add(pool, amount);
|
||||
self.count_flow(kind, amount);
|
||||
self.touch();
|
||||
Ok(())
|
||||
}
|
||||
@@ -570,7 +525,7 @@ impl Economy {
|
||||
/// A bet against a pool decided: `uuid`'s held `stake` goes into
|
||||
/// `pool`, then `payout` (at most what the pool then holds) comes out
|
||||
/// to them; one log line. What was paid. Never makes money.
|
||||
#[allow(dead_code)] // the casino, the auction house and the futures
|
||||
#[allow(dead_code)] // the casino and the auction house
|
||||
pub fn settle_pool(
|
||||
&mut self,
|
||||
kind: &str,
|
||||
@@ -595,9 +550,6 @@ impl Economy {
|
||||
self.pool_add(pool, stake - paid);
|
||||
let account = self.accounts.accounts.entry(key).or_default();
|
||||
account.balance = account.balance.saturating_add(paid);
|
||||
if pool == TREASURY {
|
||||
self.count_treasury(kind, stake - paid, Some(uuid), "", "");
|
||||
}
|
||||
self.touch();
|
||||
Ok(paid)
|
||||
}
|
||||
@@ -768,6 +720,18 @@ impl Economy {
|
||||
/// Adds (or with a negative `delta`, takes) money from the server: the
|
||||
/// market, games, operators. The balance never goes below zero.
|
||||
pub fn adjust(&mut self, kind: &str, uuid: u128, delta: i64, by: &str) -> Result<i64, String> {
|
||||
self.adjust_why(kind, uuid, delta, by, "")
|
||||
}
|
||||
|
||||
/// [`Self::adjust`] with the operator's reason in the log.
|
||||
pub fn adjust_why(
|
||||
&mut self,
|
||||
kind: &str,
|
||||
uuid: u128,
|
||||
delta: i64,
|
||||
by: &str,
|
||||
reason: &str,
|
||||
) -> Result<i64, String> {
|
||||
let key = uuid_string(uuid);
|
||||
let Some(a) = self.accounts.accounts.get(&key) else {
|
||||
return Err("No such account.".into());
|
||||
@@ -784,13 +748,23 @@ impl Economy {
|
||||
} else {
|
||||
(None, Some(uuid))
|
||||
};
|
||||
let entry = self.entry(kind, from, to, delta.abs(), by);
|
||||
let mut entry = self.entry(kind, from, to, delta.abs(), by);
|
||||
if !reason.is_empty() {
|
||||
entry["reason"] = reason.into();
|
||||
}
|
||||
self.audit.append(&entry)?;
|
||||
self.accounts
|
||||
.accounts
|
||||
.get_mut(&key)
|
||||
.expect("checked")
|
||||
.balance = after;
|
||||
self.count_flow(kind, delta);
|
||||
self.touch();
|
||||
Ok(after)
|
||||
}
|
||||
|
||||
/// Counts money made (`delta` > 0) or destroyed, by the kind's first word.
|
||||
fn count_flow(&mut self, kind: &str, delta: i64) {
|
||||
let flows = &mut self.accounts.flows;
|
||||
flows.roll(self.now.div_euclid(DAY_MS));
|
||||
let family = kind.split(' ').next().unwrap_or(kind).to_owned();
|
||||
@@ -800,8 +774,6 @@ impl Economy {
|
||||
&mut flows.burned
|
||||
};
|
||||
*map.entry(family).or_default() += delta.abs();
|
||||
self.touch();
|
||||
Ok(after)
|
||||
}
|
||||
|
||||
/// Appends a line of another kind to the audit log (a trade's items),
|
||||
@@ -905,41 +877,56 @@ impl Economy {
|
||||
out
|
||||
}
|
||||
|
||||
/// `/eco take <player> <amount> [reason]`: an operator takes money from
|
||||
/// a player (a cheat's gains) into the Treasury. Nobody can make money:
|
||||
/// `give` and `set` say where grants come from now.
|
||||
/// `/eco give <player> <amount> <reason>` (made for them: an event
|
||||
/// prize, a bug refund) and `/eco take <player> <amount> [reason]` (a
|
||||
/// cheat's gains, destroyed): operators of level 3. A gift is told to
|
||||
/// everyone, with its reason.
|
||||
fn eco(&mut self, me: &Player, args: &[&str]) -> Vec<Out> {
|
||||
if me.level < 3 {
|
||||
return vec![Out::Error("Only operators of level 3 may use /eco.".into())];
|
||||
}
|
||||
const USAGE: &str = "Usage: /eco take <player> <amount> [reason]";
|
||||
match args {
|
||||
["give" | "set" | "add", ..] => vec![Out::Error(
|
||||
"Nobody can make money, operators included. Pay from the Treasury: /treasury pay <player> <amount> <reason>"
|
||||
.into(),
|
||||
)],
|
||||
["take", who, amount, reason @ ..] => {
|
||||
let Some(amount) = parse_amount(amount) else {
|
||||
return vec![Out::Error(
|
||||
"The amount must be a positive whole number.".into(),
|
||||
)];
|
||||
};
|
||||
let Some((uuid, account)) = self.find(who) else {
|
||||
return vec![Out::Error(format!("No player named {who} has an account."))];
|
||||
};
|
||||
let name = account.name.clone();
|
||||
let reason = reason.join(" ");
|
||||
let by = uuid_string(me.uuid);
|
||||
match self.into_pool("eco_take", uuid, TREASURY, amount, &reason, &by) {
|
||||
Ok(()) => vec![Out::Reply(format!(
|
||||
"Took {} from {name} into the Treasury. They have {}.",
|
||||
self.money(amount),
|
||||
self.money(self.balance(uuid))
|
||||
))],
|
||||
Err(e) => vec![Out::Error(e)],
|
||||
}
|
||||
const USAGE: &str =
|
||||
"Usage: /eco give <player> <amount> <reason>, /eco take <player> <amount> [reason]";
|
||||
let (give, who, amount, reason) = match args {
|
||||
[verb @ ("give" | "take"), who, amount, reason @ ..] => {
|
||||
(*verb == "give", *who, *amount, reason.join(" "))
|
||||
}
|
||||
_ => vec![Out::Error(USAGE.into())],
|
||||
_ => return vec![Out::Error(USAGE.into())],
|
||||
};
|
||||
let Some(amount) = parse_amount(amount) else {
|
||||
return vec![Out::Error(
|
||||
"The amount must be a positive whole number.".into(),
|
||||
)];
|
||||
};
|
||||
let reason = reason.trim().to_owned();
|
||||
if give && reason.chars().count() < 3 {
|
||||
return vec![Out::Error(
|
||||
"Say why: /eco give <player> <amount> <reason> (an event prize, a bug refund...)"
|
||||
.into(),
|
||||
)];
|
||||
}
|
||||
let Some((uuid, account)) = self.find(who) else {
|
||||
return vec![Out::Error(format!("No player named {who} has an account."))];
|
||||
};
|
||||
let name = account.name.clone();
|
||||
let by = uuid_string(me.uuid);
|
||||
let (kind, delta) = if give {
|
||||
("eco_give", amount)
|
||||
} else {
|
||||
("eco_take", -amount)
|
||||
};
|
||||
match self.adjust_why(kind, uuid, delta, &by, &reason) {
|
||||
Ok(_) if give => vec![Out::All(format!(
|
||||
"{} gave {} to {name}: {reason}.",
|
||||
me.name,
|
||||
self.money(amount)
|
||||
))],
|
||||
Ok(after) => vec![Out::Reply(format!(
|
||||
"Took {} from {name}. They have {}.",
|
||||
self.money(amount),
|
||||
self.money(after)
|
||||
))],
|
||||
Err(e) => vec![Out::Error(e)],
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1067,90 +1054,151 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nobody_makes_money_and_eco_take_goes_to_the_treasury() {
|
||||
fn operators_give_with_a_reason_and_take_and_both_are_logged() {
|
||||
let (mut e, dir) = economy("eco");
|
||||
let op = player(ALEX, "Alex", 4);
|
||||
for line in [
|
||||
"eco give Steve 5",
|
||||
"money give * 1000",
|
||||
"eco set Steve 1234567",
|
||||
"eco add Steve 1",
|
||||
] {
|
||||
assert!(
|
||||
matches!(&e.command(&op, &[], line).unwrap()[0], Out::Error(s) if s.starts_with("Nobody can make money")),
|
||||
"{line}"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
e.command(&player(ALEX, "Alex", 2), &[], "eco take Steve 5")
|
||||
e.command(&player(ALEX, "Alex", 2), &[], "eco give Steve 5 a prize")
|
||||
.unwrap(),
|
||||
[Out::Error("Only operators of level 3 may use /eco.".into())]
|
||||
);
|
||||
assert!(
|
||||
matches!(&e.command(&op, &[], "eco give Steve 5").unwrap()[0], Out::Error(s) if s.starts_with("Say why")),
|
||||
"a gift needs its reason"
|
||||
);
|
||||
assert!(
|
||||
matches!(&e.command(&op, &[], "eco set Steve 5").unwrap()[0], Out::Error(s) if s.starts_with("Usage"))
|
||||
);
|
||||
assert_eq!(
|
||||
e.command(&op, &[], "money give Steve 50 build contest")
|
||||
.unwrap(),
|
||||
[Out::All(
|
||||
"Alex gave 50 coins to Steve: build contest.".into()
|
||||
)]
|
||||
);
|
||||
assert_eq!(
|
||||
e.command(&op, &[], "eco take Steve 200").unwrap(),
|
||||
[Out::Error("Only 100 coins there.".into())]
|
||||
[Out::Error("Only 150 coins there.".into())]
|
||||
);
|
||||
assert_eq!(
|
||||
e.command(&op, &[], "eco take Steve 40 duped diamonds")
|
||||
.unwrap(),
|
||||
[Out::Reply(
|
||||
"Took 40 coins from Steve into the Treasury. They have 60 coins.".into()
|
||||
"Took 40 coins from Steve. They have 110 coins.".into()
|
||||
)]
|
||||
);
|
||||
assert_eq!((e.balance(STEVE), e.pool(TREASURY)), (60, 40));
|
||||
assert_eq!(e.treasury().income["eco_take"], 40);
|
||||
let m = e.treasury().log.last().unwrap();
|
||||
assert_eq!(e.balance(STEVE), 110);
|
||||
let flows = e.flows();
|
||||
assert_eq!(
|
||||
(m.player.as_str(), m.reason.as_str(), m.by.as_str()),
|
||||
("Steve", "duped diamonds", "Alex")
|
||||
(flows.minted["eco_give"], flows.burned["eco_take"]),
|
||||
(50, 40)
|
||||
);
|
||||
let log = log_lines(&dir);
|
||||
let last = log.last().unwrap();
|
||||
let [.., give, take] = &log[..] else {
|
||||
panic!("{log:?}")
|
||||
};
|
||||
assert_eq!(
|
||||
(last["kind"].as_str(), last["to_pool"].as_str()),
|
||||
(Some("eco_take"), Some("treasury"))
|
||||
(
|
||||
give["kind"].as_str(),
|
||||
give["reason"].as_str(),
|
||||
give["amount"].as_i64()
|
||||
),
|
||||
(Some("eco_give"), Some("build contest"), Some(50))
|
||||
);
|
||||
assert_eq!(last["by"], uuid_string(ALEX));
|
||||
// Total money (balances + pools) never changed: 2 accounts of 100.
|
||||
assert_eq!(e.balance(ALEX) + e.balance(STEVE) + e.pool(TREASURY), 200);
|
||||
assert_eq!(
|
||||
(
|
||||
take["kind"].as_str(),
|
||||
take["reason"].as_str(),
|
||||
take["from"].as_str()
|
||||
),
|
||||
(
|
||||
Some("eco_take"),
|
||||
Some("duped diamonds"),
|
||||
Some(uuid_string(STEVE).as_str())
|
||||
)
|
||||
);
|
||||
assert_eq!(take["by"], uuid_string(ALEX));
|
||||
fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pools_pay_only_what_they_hold_and_survive_a_restart() {
|
||||
let (mut e, dir) = economy("pools");
|
||||
e.to_pool("vat", ALEX, TREASURY, 30, "").unwrap();
|
||||
e.to_pool("ah_bid", ALEX, "ah", 30, "").unwrap();
|
||||
assert!(
|
||||
e.to_pool("vat", ALEX, TREASURY, 71, "").is_err(),
|
||||
e.to_pool("ah_bid", ALEX, "ah", 71, "").is_err(),
|
||||
"more than Alex has"
|
||||
);
|
||||
assert!(
|
||||
e.from_pool("grant", TREASURY, STEVE, 31, "event").is_err(),
|
||||
"more than the Treasury holds"
|
||||
e.from_pool("ah_sale", "ah", STEVE, 31, "").is_err(),
|
||||
"more than the pool holds"
|
||||
);
|
||||
e.from_pool("grant", TREASURY, STEVE, 10, "event prize")
|
||||
e.from_pool("ah_sale", "ah", STEVE, 10, "").unwrap();
|
||||
e.pool_move("test", "ah", "casino", 5, "").unwrap();
|
||||
e.mint_pool("casino_float", "casino", 100, "the casino's float")
|
||||
.unwrap();
|
||||
e.pool_move("insurance_fund", TREASURY, INSURANCE, 5, "")
|
||||
.unwrap();
|
||||
assert_eq!((e.pool(TREASURY), e.pool(INSURANCE)), (15, 5));
|
||||
assert_eq!((e.pool("ah"), e.pool("casino")), (15, 105));
|
||||
assert_eq!((e.balance(ALEX), e.balance(STEVE)), (70, 110));
|
||||
let t = e.treasury();
|
||||
assert_eq!(
|
||||
(t.income["vat"], t.spent["grant"], t.spent["insurance_fund"]),
|
||||
(30, 10, 5)
|
||||
);
|
||||
assert_eq!(t.income_today["vat"], 30);
|
||||
assert_eq!(e.flows().minted["casino_float"], 100);
|
||||
// Pools are nobody's: not in the ranking.
|
||||
assert_eq!(e.accounts().len(), 2);
|
||||
e.save().unwrap();
|
||||
let again = Economy::open(&dir, Config::default()).unwrap();
|
||||
assert_eq!((again.pool(TREASURY), again.pool(INSURANCE)), (15, 5));
|
||||
assert_eq!(again.treasury().log.len(), 3);
|
||||
assert_eq!((again.pool("ah"), again.pool("casino")), (15, 105));
|
||||
fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settle_pool_never_pays_more_than_the_pool_and_award_fees_go_to_the_treasury() {
|
||||
fn a_world_from_before_0_30_gets_its_savings_and_margins_back() {
|
||||
let dir = std::env::temp_dir().join(format!("smp-eco-retire-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
let (a, s) = (uuid_string(ALEX), uuid_string(STEVE));
|
||||
let old = serde_json::json!({
|
||||
"accounts": {a.clone(): {"name": "Alex", "balance": 10}, s.clone(): {"name": "Steve", "balance": 20}},
|
||||
"pools": {"treasury": 5000, "insurance": 300, "bank": 700, "futures": 90, "casino": 40},
|
||||
"treasury": {"income": {"vat": 12}, "log": []},
|
||||
"ext": {
|
||||
"bank": {"accounts": {a.clone(): {"amount": 500, "low": 500, "earned": 3}, s.clone(): {"amount": 200}}},
|
||||
"futures": {"positions": [{"id": 1, "owner": s.clone(), "name": "Steve", "item": "minecraft:diamond",
|
||||
"long": true, "size": 1.0, "entry": 200.0, "margin": 90, "leverage": 2, "opened": 0}]},
|
||||
"jobs": {"day": 3},
|
||||
"tax": {"week": 2},
|
||||
"auction": {"listings": []}
|
||||
}
|
||||
});
|
||||
fs::write(dir.join("economy.json"), old.to_string()).unwrap();
|
||||
let mut e = Economy::open(&dir, Config::default()).unwrap();
|
||||
assert_eq!((e.balance(ALEX), e.balance(STEVE)), (510, 310));
|
||||
assert_eq!(
|
||||
e.pools().keys().collect::<Vec<_>>(),
|
||||
["casino"],
|
||||
"the retired pools are gone"
|
||||
);
|
||||
assert!(
|
||||
e.ext("bank").is_none()
|
||||
&& e.ext("futures").is_none()
|
||||
&& e.ext("jobs").is_none()
|
||||
&& e.ext("tax").is_none()
|
||||
);
|
||||
assert!(e.ext("auction").is_some(), "the auction house stays");
|
||||
let kinds: Vec<_> = log_lines(&dir)
|
||||
.iter()
|
||||
.map(|l| l["kind"].as_str().unwrap().to_owned())
|
||||
.collect();
|
||||
assert_eq!(kinds, ["bank_closed", "bank_closed", "futures_closed"]);
|
||||
e.save().unwrap();
|
||||
let saved = fs::read_to_string(dir.join("economy.json")).unwrap();
|
||||
assert!(!saved.contains("treasury"), "{saved}");
|
||||
// Only once.
|
||||
let again = Economy::open(&dir, Config::default()).unwrap();
|
||||
assert_eq!((again.balance(ALEX), again.balance(STEVE)), (510, 310));
|
||||
assert_eq!(log_lines(&dir).len(), 3);
|
||||
fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settle_pool_never_pays_more_than_the_pool_and_award_pays_the_stakes() {
|
||||
let (mut e, dir) = economy("settle");
|
||||
e.hold("bet", ALEX, 50).unwrap();
|
||||
// The pool held nothing: a 1,000 payout is capped at the stake.
|
||||
@@ -1166,21 +1214,14 @@ mod tests {
|
||||
e.settle_pool("roulette", ALEX, 10, 0, "casino").is_err(),
|
||||
"nothing held"
|
||||
);
|
||||
// A coinflip with a 10% fee: the fee is the Treasury's.
|
||||
// A coinflip: the winner gets both stakes, no fee.
|
||||
e.hold("coinflip_stake", ALEX, 50).unwrap();
|
||||
e.hold("coinflip_stake", STEVE, 50).unwrap();
|
||||
e.award("coinflip_win", &[(ALEX, 50), (STEVE, 50)], STEVE, 90)
|
||||
e.award("coinflip_win", &[(ALEX, 50), (STEVE, 50)], STEVE)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
(e.balance(ALEX), e.balance(STEVE), e.pool(TREASURY)),
|
||||
(10, 140, 10)
|
||||
);
|
||||
assert_eq!(e.treasury().income["gambling_fee"], 10);
|
||||
assert_eq!((e.balance(ALEX), e.balance(STEVE)), (10, 150));
|
||||
// Nothing made: 200 at the start.
|
||||
assert_eq!(
|
||||
e.balance(ALEX) + e.balance(STEVE) + e.pool(TREASURY) + e.pool("casino"),
|
||||
200
|
||||
);
|
||||
assert_eq!(e.balance(ALEX) + e.balance(STEVE) + e.pool("casino"), 200);
|
||||
fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -25,8 +25,6 @@ pub struct Config {
|
||||
pub cooldown_seconds: i64,
|
||||
/// How long an offer waits before its stake goes back.
|
||||
pub offer_seconds: i64,
|
||||
/// What the server keeps of the pot, in percent (0: the winner takes all).
|
||||
pub fee_percent: i64,
|
||||
/// The lottery: a ticket's price, the tickets one player may hold in a
|
||||
/// draw, and the time from a round's first ticket to its draw.
|
||||
pub ticket_price: i64,
|
||||
@@ -42,7 +40,6 @@ impl Default for Config {
|
||||
max_bet: 1000,
|
||||
cooldown_seconds: 30,
|
||||
offer_seconds: 60,
|
||||
fee_percent: 0,
|
||||
ticket_price: 10,
|
||||
max_tickets: 100,
|
||||
lottery_seconds: 3600,
|
||||
@@ -284,15 +281,14 @@ impl Games {
|
||||
return vec![Out::Error(e)];
|
||||
}
|
||||
let o = self.offers.remove(i);
|
||||
let pot = amount * 2;
|
||||
let prize = pot - pot * self.config.fee_percent.clamp(0, 100) / 100;
|
||||
let prize = amount * 2;
|
||||
let (winner, loser, winner_name, loser_name) = if heads() {
|
||||
(o.from, me.uuid, o.from_name.as_str(), me.name.as_str())
|
||||
} else {
|
||||
(me.uuid, o.from, me.name.as_str(), o.from_name.as_str())
|
||||
};
|
||||
let stakes = [(o.from, amount), (me.uuid, amount)];
|
||||
if let Err(e) = economy.award("coinflip_win", &stakes, winner, prize) {
|
||||
if let Err(e) = economy.award("coinflip_win", &stakes, winner) {
|
||||
// Not decided: both stakes go back (or, failing that, at the next start).
|
||||
for (u, a) in stakes {
|
||||
let _ = economy.release("coinflip_cancel", u, a);
|
||||
|
||||
@@ -163,7 +163,9 @@ impl<S: Stack> Invsee<S> {
|
||||
return Some((vec![Out::Error(format!("Usage: /{name} <player>"))], None));
|
||||
};
|
||||
match find(who) {
|
||||
Some((uuid, _)) if uuid == me.uuid => {
|
||||
// Your own inventory would move stacks into itself; your own
|
||||
// ender chest is a second container, so an operator may open it.
|
||||
Some((uuid, _)) if uuid == me.uuid && kind == Kind::Player => {
|
||||
Some((vec![Out::Error("That is your own inventory.".into())], None))
|
||||
}
|
||||
Some((uuid, real)) => Some((Vec::new(), Some((uuid, real, kind)))),
|
||||
@@ -496,6 +498,10 @@ mod tests {
|
||||
i.command(&op(), &find, "invsee Boss").unwrap().0,
|
||||
[Out::Error("That is your own inventory.".into())]
|
||||
);
|
||||
assert_eq!(
|
||||
i.command(&op(), &find, "endersee Boss").unwrap().1,
|
||||
Some((1, "Boss".into(), Kind::Ender))
|
||||
);
|
||||
assert_eq!(
|
||||
i.command(&op(), &find, "endersee Nobody").unwrap().0,
|
||||
[Out::Error("No player named Nobody has played here.".into())]
|
||||
|
||||
@@ -1,571 +0,0 @@
|
||||
// The contracts board (`/jobs`): each day at 00:00 UTC the state posts a few
|
||||
// contracts ("deliver 64 wheat for 160 coins"), paid from the Treasury: the
|
||||
// taxes go back to players who gather what the server asks for, never new
|
||||
// money. A delivery takes plain items from the player's inventory (they
|
||||
// leave the game: a sink for items) and pays at once. Never AFK income: only
|
||||
// a command with the items in hand pays; each contract has a few deliveries
|
||||
// for the whole server, and a player makes at most `max_per_player` a day.
|
||||
//
|
||||
// Prices: each item pays its market sell price plus `premium_percent`, and
|
||||
// never more than `ask_percent` of what the market asks for it (checked
|
||||
// again at delivery, as the market's prices move): buying from the market to
|
||||
// deliver never pays. One day's contracts together cost at most
|
||||
// `budget_percent` of the Treasury when posted. The board lives in
|
||||
// `economy.json` (`ext.jobs`), written with the money. No game types; the
|
||||
// market's prices come through a closure. Added to the game as
|
||||
// `game::smp_jobs`; also a module of this crate, for its tests.
|
||||
|
||||
use super::smp_economy::{DAY_MS, Economy, Out, Player, TREASURY};
|
||||
use super::smp_market::{Bag, item_name};
|
||||
use super::smp_store::uuid_string;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// `config.json`'s `jobs`.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Config {
|
||||
pub enabled: bool,
|
||||
pub contracts_per_day: usize,
|
||||
/// Items in one delivery.
|
||||
pub batch: u32,
|
||||
/// Deliveries of one contract for the whole server.
|
||||
pub deliveries: u32,
|
||||
/// Deliveries one player makes in a day, all contracts.
|
||||
pub max_per_player: u32,
|
||||
/// Over the market's sell price.
|
||||
pub premium_percent: i64,
|
||||
/// Of the market's ask (its buy price), at most.
|
||||
pub ask_percent: i64,
|
||||
/// The day's contracts cost at most this share of the Treasury.
|
||||
pub budget_percent: i64,
|
||||
/// What contracts may ask for (items the market buys).
|
||||
pub items: Vec<String>,
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
contracts_per_day: 4,
|
||||
batch: 64,
|
||||
deliveries: 8,
|
||||
max_per_player: 2,
|
||||
premium_percent: 30,
|
||||
ask_percent: 90,
|
||||
budget_percent: 10,
|
||||
items: [
|
||||
"wheat",
|
||||
"carrot",
|
||||
"potato",
|
||||
"beetroot",
|
||||
"sugar_cane",
|
||||
"pumpkin",
|
||||
"melon_slice",
|
||||
"cactus",
|
||||
"oak_log",
|
||||
"spruce_log",
|
||||
"birch_log",
|
||||
"cobblestone",
|
||||
"coal",
|
||||
"raw_iron",
|
||||
"raw_copper",
|
||||
"redstone",
|
||||
"lapis_lazuli",
|
||||
"leather",
|
||||
"string",
|
||||
"bone",
|
||||
"feather",
|
||||
"gunpowder",
|
||||
"kelp",
|
||||
"bamboo",
|
||||
]
|
||||
.map(|i| format!("minecraft:{i}"))
|
||||
.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Contract {
|
||||
pub item: String,
|
||||
pub count: u32,
|
||||
/// Coins a delivery pays (at most; less if the market's ask fell).
|
||||
pub pay: i64,
|
||||
pub left: u32,
|
||||
}
|
||||
|
||||
/// `ext.jobs`.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Board {
|
||||
pub day: i64,
|
||||
pub contracts: Vec<Contract>,
|
||||
/// Deliveries today, by player.
|
||||
pub done: BTreeMap<String, u32>,
|
||||
pub paid_today: i64,
|
||||
pub paid: i64,
|
||||
}
|
||||
|
||||
pub struct Jobs {
|
||||
pub config: Config,
|
||||
pub board: Board,
|
||||
dirty: bool,
|
||||
}
|
||||
|
||||
/// `(sell, buy)`: what the market pays for one item and what it asks; None
|
||||
/// when it doesn't trade it.
|
||||
pub type PriceOf<'a> = &'a dyn Fn(&str) -> Option<(i64, i64)>;
|
||||
|
||||
/// A number from `day` and `i`, the same on every run (no world random).
|
||||
fn pick(day: i64, i: u64) -> u64 {
|
||||
let mut x =
|
||||
(day as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15) ^ i.wrapping_mul(0xBF58_476D_1CE4_E5B9);
|
||||
x ^= x >> 31;
|
||||
x = x.wrapping_mul(0x94D0_49BB_1331_11EB);
|
||||
x ^ (x >> 29)
|
||||
}
|
||||
|
||||
impl Jobs {
|
||||
pub fn open(economy: &Economy, config: Config) -> Self {
|
||||
let board = economy
|
||||
.ext("jobs")
|
||||
.and_then(|v| serde_json::from_value::<Board>(v.clone()).ok())
|
||||
.unwrap_or_default();
|
||||
Self {
|
||||
config,
|
||||
board,
|
||||
dirty: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn store(&mut self, economy: &mut Economy) {
|
||||
if self.dirty {
|
||||
economy.set_ext(
|
||||
"jobs",
|
||||
serde_json::to_value(&self.board).unwrap_or_default(),
|
||||
);
|
||||
self.dirty = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// One item's pay: the sell price plus the premium, below the ask.
|
||||
fn unit(&self, sell: i64, buy: i64) -> i64 {
|
||||
let mut unit = sell * (100 + self.config.premium_percent) / 100;
|
||||
if buy > 0 {
|
||||
unit = unit.min(buy * self.config.ask_percent / 100);
|
||||
}
|
||||
unit.max(0)
|
||||
}
|
||||
|
||||
/// Once a second from the glue; at 00:00 UTC, the day's contracts.
|
||||
pub fn tick(&mut self, economy: &Economy, price: PriceOf, now: i64) -> Vec<Out> {
|
||||
let day = now.div_euclid(DAY_MS);
|
||||
if day == self.board.day || !self.config.enabled {
|
||||
return Vec::new();
|
||||
}
|
||||
self.board.day = day;
|
||||
self.board.done.clear();
|
||||
self.board.paid_today = 0;
|
||||
self.board.contracts.clear();
|
||||
self.dirty = true;
|
||||
let mut pool: Vec<(String, i64)> = self
|
||||
.config
|
||||
.items
|
||||
.iter()
|
||||
.filter_map(|i| {
|
||||
let (sell, buy) = price(i)?;
|
||||
let unit = self.unit(sell, buy);
|
||||
(sell > 0 && unit > 0).then(|| (i.clone(), unit))
|
||||
})
|
||||
.collect();
|
||||
let mut budget =
|
||||
economy.pool(TREASURY).max(0) * self.config.budget_percent.clamp(0, 100) / 100;
|
||||
for n in 0..self.config.contracts_per_day as u64 {
|
||||
if pool.is_empty() {
|
||||
break;
|
||||
}
|
||||
let (item, unit) = pool.remove((pick(day, n) % pool.len() as u64) as usize);
|
||||
let pay = unit * i64::from(self.config.batch);
|
||||
let left = (budget / pay.max(1)).min(i64::from(self.config.deliveries)) as u32;
|
||||
if left == 0 {
|
||||
continue;
|
||||
}
|
||||
budget -= pay * i64::from(left);
|
||||
self.board.contracts.push(Contract {
|
||||
item,
|
||||
count: self.config.batch,
|
||||
pay,
|
||||
left,
|
||||
});
|
||||
}
|
||||
if self.board.contracts.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
vec![Out::All(format!(
|
||||
"Today's contracts are up (/jobs): {}.",
|
||||
self.board
|
||||
.contracts
|
||||
.iter()
|
||||
.map(|c| format!(
|
||||
"{} {} for {}",
|
||||
c.count,
|
||||
item_name(&c.item),
|
||||
economy.money(c.pay)
|
||||
))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
))]
|
||||
}
|
||||
|
||||
/// `/jobs ...`; None for another command. `trades`: the player is in
|
||||
/// survival or adventure (a creative player could make the items).
|
||||
pub fn command(
|
||||
&mut self,
|
||||
economy: &mut Economy,
|
||||
me: &Player,
|
||||
trades: bool,
|
||||
bag: &mut dyn Bag,
|
||||
price: PriceOf,
|
||||
line: &str,
|
||||
) -> Option<Vec<Out>> {
|
||||
let words: Vec<&str> = line.split_whitespace().collect();
|
||||
if words.first() != Some(&"jobs") {
|
||||
return None;
|
||||
}
|
||||
if !self.config.enabled {
|
||||
return Some(vec![Out::Error(
|
||||
"There are no contracts on this server.".into(),
|
||||
)]);
|
||||
}
|
||||
Some(match words[1..] {
|
||||
[] => self.list(economy, me),
|
||||
["deliver", n] => match n.trim_start_matches('#').parse::<usize>() {
|
||||
Ok(n) if n >= 1 => self.deliver(economy, me, trades, bag, price, n - 1),
|
||||
_ => vec![Out::Error("Usage: /jobs deliver <contract number>".into())],
|
||||
},
|
||||
_ => vec![Out::Error(
|
||||
"Usage: /jobs, /jobs deliver <contract number>".into(),
|
||||
)],
|
||||
})
|
||||
}
|
||||
|
||||
fn list(&self, economy: &Economy, me: &Player) -> Vec<Out> {
|
||||
if self.board.contracts.is_empty() {
|
||||
return vec![Out::Reply(
|
||||
"No contracts today: the Treasury has nothing to spend.".into(),
|
||||
)];
|
||||
}
|
||||
let done = self
|
||||
.board
|
||||
.done
|
||||
.get(&uuid_string(me.uuid))
|
||||
.copied()
|
||||
.unwrap_or(0);
|
||||
let mut out = vec![Out::Reply(format!(
|
||||
"Today's contracts, paid by the Treasury (you've done {done} of {} today):",
|
||||
self.config.max_per_player
|
||||
))];
|
||||
for (i, c) in self.board.contracts.iter().enumerate() {
|
||||
out.push(Out::Reply(format!(
|
||||
"#{} deliver {} {} for {} ({} left) · /jobs deliver {}",
|
||||
i + 1,
|
||||
c.count,
|
||||
item_name(&c.item),
|
||||
economy.money(c.pay),
|
||||
c.left,
|
||||
i + 1
|
||||
)));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn deliver(
|
||||
&mut self,
|
||||
economy: &mut Economy,
|
||||
me: &Player,
|
||||
trades: bool,
|
||||
bag: &mut dyn Bag,
|
||||
price: PriceOf,
|
||||
i: usize,
|
||||
) -> Vec<Out> {
|
||||
let err = |s: String| vec![Out::Error(s)];
|
||||
if !trades {
|
||||
return err("Contracts are for players in survival or adventure mode.".into());
|
||||
}
|
||||
let Some(c) = self.board.contracts.get(i).cloned() else {
|
||||
return err(format!("There's no contract #{} today (/jobs).", i + 1));
|
||||
};
|
||||
if c.left == 0 {
|
||||
return err(format!("Contract #{} is done for today.", i + 1));
|
||||
}
|
||||
let key = uuid_string(me.uuid);
|
||||
let done = self.board.done.get(&key).copied().unwrap_or(0);
|
||||
if done >= self.config.max_per_player {
|
||||
return err(format!(
|
||||
"You've made your {} deliveries today; new contracts at 00:00 UTC.",
|
||||
self.config.max_per_player
|
||||
));
|
||||
}
|
||||
// The market's prices now: buying there to deliver never pays.
|
||||
let pay = match price(&c.item) {
|
||||
Some((sell, buy)) => c.pay.min(self.unit(sell, buy) * i64::from(c.count)),
|
||||
None => c.pay,
|
||||
};
|
||||
if pay <= 0 || economy.pool(TREASURY) < pay {
|
||||
return err("The Treasury can't pay that contract now.".into());
|
||||
}
|
||||
if bag.count(&c.item) < c.count {
|
||||
return err(format!(
|
||||
"You need {} plain {} in your inventory.",
|
||||
c.count,
|
||||
item_name(&c.item)
|
||||
));
|
||||
}
|
||||
let took = bag.take(&c.item, c.count);
|
||||
if took < c.count {
|
||||
bag.give(&c.item, took);
|
||||
return err(format!(
|
||||
"You need {} plain {} in your inventory.",
|
||||
c.count,
|
||||
item_name(&c.item)
|
||||
));
|
||||
}
|
||||
if let Err(e) = economy.from_pool(
|
||||
&format!("contract {} {}", c.count, c.item),
|
||||
TREASURY,
|
||||
me.uuid,
|
||||
pay,
|
||||
"",
|
||||
) {
|
||||
bag.give(&c.item, took);
|
||||
return err(e);
|
||||
}
|
||||
let contract = &mut self.board.contracts[i];
|
||||
contract.left -= 1;
|
||||
self.board.done.insert(key, done + 1);
|
||||
self.board.paid_today += pay;
|
||||
self.board.paid += pay;
|
||||
self.dirty = true;
|
||||
vec![Out::Reply(format!(
|
||||
"Delivered {} {}: the Treasury paid you {}.",
|
||||
c.count,
|
||||
item_name(&c.item),
|
||||
economy.money(pay)
|
||||
))]
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::smp_economy::Config as EcoConfig;
|
||||
use std::collections::HashMap;
|
||||
|
||||
const A: u128 = 1;
|
||||
const B: u128 = 2;
|
||||
const T0: i64 = 1_793_577_600_000;
|
||||
|
||||
struct Inv(HashMap<String, u32>);
|
||||
impl Bag for Inv {
|
||||
fn held(&self) -> Option<(String, u32, bool)> {
|
||||
None
|
||||
}
|
||||
fn carried(&self) -> Vec<(String, u32)> {
|
||||
self.0.iter().map(|(k, v)| (k.clone(), *v)).collect()
|
||||
}
|
||||
fn count(&mut self, item: &str) -> u32 {
|
||||
self.0.get(item).copied().unwrap_or(0)
|
||||
}
|
||||
fn take(&mut self, item: &str, count: u32) -> u32 {
|
||||
let have = self.0.entry(item.into()).or_default();
|
||||
let t = count.min(*have);
|
||||
*have -= t;
|
||||
t
|
||||
}
|
||||
fn room(&mut self, _: &str) -> u32 {
|
||||
u32::MAX
|
||||
}
|
||||
fn give(&mut self, item: &str, count: u32) -> u32 {
|
||||
*self.0.entry(item.into()).or_default() += count;
|
||||
count
|
||||
}
|
||||
}
|
||||
|
||||
fn me(u: u128) -> Player {
|
||||
Player {
|
||||
uuid: u,
|
||||
name: format!("P{u}"),
|
||||
level: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// wheat sells 2, asks 3; cobblestone sells 1, asks 4; sand sells 1,
|
||||
/// asks 1 (no room for a premium: never posted).
|
||||
fn price(id: &str) -> Option<(i64, i64)> {
|
||||
match id {
|
||||
"minecraft:wheat" => Some((2, 3)),
|
||||
"minecraft:cobblestone" => Some((1, 4)),
|
||||
"minecraft:sand" => Some((1, 1)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn setup(name: &str, treasury: i64) -> (Economy, Jobs) {
|
||||
let dir = std::env::temp_dir().join(format!("smp-jobs-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let mut e = Economy::open(&dir, EcoConfig::default()).unwrap();
|
||||
e.now = T0;
|
||||
e.seen(A, "PA");
|
||||
e.seen(B, "PB");
|
||||
if treasury > 0 {
|
||||
e.adjust("sell", B, treasury, "").unwrap();
|
||||
e.to_pool("seed", B, TREASURY, treasury, "").unwrap();
|
||||
}
|
||||
let config = Config {
|
||||
items: ["wheat", "cobblestone", "dirt", "sand"]
|
||||
.map(|i| format!("minecraft:{i}"))
|
||||
.into(),
|
||||
..Config::default()
|
||||
};
|
||||
let j = Jobs::open(&e, config);
|
||||
(e, j)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contracts_pay_from_the_treasury_below_the_market_ask_within_the_budget() {
|
||||
let (mut e, mut j) = setup("post", 10_000);
|
||||
let out = j.tick(&e, &price, T0);
|
||||
assert_eq!(
|
||||
j.board.contracts.len(),
|
||||
2,
|
||||
"dirt isn't traded, sand leaves no premium"
|
||||
);
|
||||
let wheat = j
|
||||
.board
|
||||
.contracts
|
||||
.iter()
|
||||
.find(|c| c.item == "minecraft:wheat")
|
||||
.unwrap();
|
||||
// 2 × 1.3 = 2 (whole coins), under 90% of 3: 128 a delivery.
|
||||
assert_eq!(wheat.pay, 128);
|
||||
assert!(matches!(&out[0], Out::All(s) if s.starts_with("Today's contracts are up")));
|
||||
let cost: i64 = j
|
||||
.board
|
||||
.contracts
|
||||
.iter()
|
||||
.map(|c| c.pay * i64::from(c.left))
|
||||
.sum();
|
||||
assert!(cost <= 1_000, "{cost}");
|
||||
assert!(j.tick(&e, &price, T0 + 5_000).is_empty(), "once a day");
|
||||
let _ = &mut e;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_delivery_takes_the_items_and_pays_once_within_the_limits() {
|
||||
let (mut e, mut j) = setup("deliver", 10_000);
|
||||
j.tick(&e, &price, T0);
|
||||
let n = j
|
||||
.board
|
||||
.contracts
|
||||
.iter()
|
||||
.position(|c| c.item == "minecraft:wheat")
|
||||
.unwrap()
|
||||
+ 1;
|
||||
let mut bag = Inv(HashMap::from([("minecraft:wheat".to_owned(), 200)]));
|
||||
let line = format!("jobs deliver {n}");
|
||||
let out = j
|
||||
.command(&mut e, &me(A), false, &mut bag, &price, &line)
|
||||
.unwrap();
|
||||
assert!(
|
||||
matches!(&out[0], Out::Error(s) if s.contains("survival")),
|
||||
"creative can't"
|
||||
);
|
||||
let out = j
|
||||
.command(&mut e, &me(A), true, &mut bag, &price, &line)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
out,
|
||||
[Out::Reply(
|
||||
"Delivered 64 wheat: the Treasury paid you 128 coins.".into()
|
||||
)]
|
||||
);
|
||||
assert_eq!(
|
||||
(e.balance(A), e.pool(TREASURY), bag.0["minecraft:wheat"]),
|
||||
(128, 9_872, 136)
|
||||
);
|
||||
j.command(&mut e, &me(A), true, &mut bag, &price, &line)
|
||||
.unwrap();
|
||||
let out = j
|
||||
.command(&mut e, &me(A), true, &mut bag, &price, &line)
|
||||
.unwrap();
|
||||
assert!(
|
||||
matches!(&out[0], Out::Error(s) if s.contains("2 deliveries today")),
|
||||
"{out:?}"
|
||||
);
|
||||
assert_eq!(bag.0["minecraft:wheat"], 72, "nothing taken when refused");
|
||||
// Too few items: nothing taken, nothing paid.
|
||||
let mut few = Inv(HashMap::from([("minecraft:wheat".to_owned(), 63)]));
|
||||
let out = j
|
||||
.command(&mut e, &me(B), true, &mut few, &price, &line)
|
||||
.unwrap();
|
||||
assert!(matches!(&out[0], Out::Error(s) if s.starts_with("You need 64")));
|
||||
assert_eq!(few.0["minecraft:wheat"], 63);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_falling_ask_lowers_the_pay_so_buying_to_deliver_never_pays() {
|
||||
let (mut e, mut j) = setup("ask", 10_000);
|
||||
j.tick(&e, &price, T0);
|
||||
let n = j
|
||||
.board
|
||||
.contracts
|
||||
.iter()
|
||||
.position(|c| c.item == "minecraft:wheat")
|
||||
.unwrap()
|
||||
+ 1;
|
||||
// The market now asks 2 for wheat: a delivery pays 64 × 1 (90% of 2), not 128.
|
||||
let cheap = |id: &str| {
|
||||
if id == "minecraft:wheat" {
|
||||
Some((1, 2))
|
||||
} else {
|
||||
price(id)
|
||||
}
|
||||
};
|
||||
let mut bag = Inv(HashMap::from([("minecraft:wheat".to_owned(), 64)]));
|
||||
j.command(
|
||||
&mut e,
|
||||
&me(A),
|
||||
true,
|
||||
&mut bag,
|
||||
&cheap,
|
||||
&format!("jobs deliver {n}"),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
e.balance(A) < 64 * 2,
|
||||
"less than buying them cost: {}",
|
||||
e.balance(A)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_treasury_posts_nothing_and_the_board_survives_a_restart() {
|
||||
let (e, mut j) = setup("empty", 0);
|
||||
assert!(j.tick(&e, &price, T0).is_empty());
|
||||
assert!(j.board.contracts.is_empty());
|
||||
let (mut e, mut j) = setup("restart", 10_000);
|
||||
j.tick(&e, &price, T0);
|
||||
j.store(&mut e);
|
||||
e.save().unwrap();
|
||||
let dir = std::env::temp_dir().join(format!("smp-jobs-restart-{}", std::process::id()));
|
||||
let e2 = Economy::open(&dir, EcoConfig::default()).unwrap();
|
||||
let mut j2 = Jobs::open(&e2, Config::default());
|
||||
assert_eq!(j2.board.contracts, j.board.contracts);
|
||||
assert!(
|
||||
j2.tick(&e2, &price, T0 + 1000).is_empty(),
|
||||
"not posted twice"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -133,9 +133,8 @@ impl Lottery {
|
||||
ticket < 0
|
||||
})
|
||||
.expect("the ticket is below the total");
|
||||
let pot = total * price;
|
||||
let prize = pot - pot * config.fee_percent.clamp(0, 100) / 100;
|
||||
match economy.award("lottery_win", &stakes, winner.0, prize) {
|
||||
let prize = total * price;
|
||||
match economy.award("lottery_win", &stakes, winner.0) {
|
||||
Ok(()) => vec![Out::All(format!(
|
||||
"Lottery: {} won {} with {} of {total} tickets!",
|
||||
winner.1,
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
// lends the player's items as a `Bag`. Added to the game as
|
||||
// `game::smp_market`; also a module of this crate, for its tests.
|
||||
|
||||
use super::smp_economy::{Economy, MAX_AMOUNT, Out, Player, TREASURY, parse_amount};
|
||||
use super::smp_economy::{Economy, MAX_AMOUNT, Out, Player, parse_amount};
|
||||
use super::smp_market_prices::{
|
||||
Book, Dynamic, MINUTE_MS, Point, Quote, Span, arrow, base_mid, one_price, to_get, to_pay,
|
||||
};
|
||||
@@ -328,9 +328,6 @@ pub struct Market {
|
||||
pub warnings: Vec<String>,
|
||||
/// Each item made of or into others: those others (`families`).
|
||||
family: BTreeMap<String, Vec<String>>,
|
||||
/// Items whose history `open` scaled to their one price, by how much
|
||||
/// (for the futures' entries).
|
||||
pub rebased: BTreeMap<String, f64>,
|
||||
}
|
||||
|
||||
/// The market's items made of one another (by `smp_recipes::chains`), in
|
||||
@@ -412,7 +409,6 @@ impl Market {
|
||||
book_dirty: !rebased.is_empty(),
|
||||
warnings: Vec::new(),
|
||||
family: BTreeMap::new(),
|
||||
rebased,
|
||||
};
|
||||
market.link();
|
||||
(market, problems)
|
||||
@@ -431,7 +427,6 @@ impl Market {
|
||||
book_dirty: false,
|
||||
warnings: Vec::new(),
|
||||
family: BTreeMap::new(),
|
||||
rebased: BTreeMap::new(),
|
||||
};
|
||||
m.link();
|
||||
m
|
||||
@@ -591,7 +586,7 @@ impl Market {
|
||||
}
|
||||
|
||||
/// What the market asks and pays for one `id` now, and the mid; None
|
||||
/// when it isn't traded. For futures and charts.
|
||||
/// when it isn't traded. For the charts.
|
||||
pub fn spot(&self, id: &str) -> Option<Quote> {
|
||||
let b = self.config.items.get(id)?;
|
||||
Some(if self.config.dynamic.enabled {
|
||||
@@ -610,32 +605,6 @@ impl Market {
|
||||
})
|
||||
}
|
||||
|
||||
#[allow(
|
||||
dead_code,
|
||||
reason = "read by the futures (smp-econ), not the market itself"
|
||||
)]
|
||||
/// The time-weighted mean mid of `id` over the last `minutes` minutes.
|
||||
pub fn twap(&self, id: &str, minutes: u32, now: i64) -> Option<f64> {
|
||||
self.config
|
||||
.items
|
||||
.contains_key(id)
|
||||
.then(|| self.book.twap(id, minutes, now))
|
||||
.flatten()
|
||||
}
|
||||
|
||||
#[allow(
|
||||
dead_code,
|
||||
reason = "read by the futures (smp-econ), not the market itself"
|
||||
)]
|
||||
/// The 10-minute TWAP, rounded, from at least 5 one-minute points.
|
||||
pub fn mark_price(&self, id: &str, now: i64) -> Option<i64> {
|
||||
self.config
|
||||
.items
|
||||
.contains_key(id)
|
||||
.then(|| self.book.mark_price(id, now))
|
||||
.flatten()
|
||||
}
|
||||
|
||||
/// Items of `id` traded (both sides) in the last `hours`.
|
||||
pub fn volume(&self, id: &str, now: i64, hours: u32) -> u64 {
|
||||
self.book.volume(id, now, hours)
|
||||
@@ -804,12 +773,6 @@ impl Market {
|
||||
"{name}: {} each, {ways}.",
|
||||
each_money(economy, self.each(&id))
|
||||
))];
|
||||
if self.config.dynamic.enabled && self.config.dynamic.tax_percent > 0.0 {
|
||||
out.push(Out::Reply(format!(
|
||||
"Tax: {}% of each trade, to the Treasury. Big trades move the price as they go.",
|
||||
self.config.dynamic.tax_percent
|
||||
)));
|
||||
}
|
||||
if self.config.dynamic.enabled {
|
||||
out.push(Out::Reply(format!(
|
||||
"Moves: {}; traded in 24 h: {}. Chart: /market {}",
|
||||
@@ -943,23 +906,13 @@ impl Market {
|
||||
match economy.adjust(&kind, me.uuid, pay, "") {
|
||||
Ok(_) => {
|
||||
self.count_sale(me.uuid, now, &id, taken, pay);
|
||||
let due = self.tax_on(pay);
|
||||
let tax = self.tax(economy, me.uuid, due, &format!("sell {taken} {id}"));
|
||||
self.moved(&id, taken, false);
|
||||
total = total.saturating_add(pay - tax);
|
||||
total = total.saturating_add(pay);
|
||||
let each = each_money(economy, pay as f64 / f64::from(taken));
|
||||
out.push(Out::Reply(format!(
|
||||
"Sold {taken} {name} at {each} each: {}.",
|
||||
economy.money(pay)
|
||||
)));
|
||||
if tax > 0 {
|
||||
out.push(Out::Reply(format!(
|
||||
"Tax {}%: -{}. You get {}.",
|
||||
self.config.dynamic.tax_percent,
|
||||
economy.money(tax),
|
||||
economy.money(pay - tax)
|
||||
)));
|
||||
}
|
||||
if taken < limited {
|
||||
let hours = self.config.dynamic.round_trip_hours;
|
||||
out.push(Out::Reply(format!(
|
||||
@@ -1030,16 +983,10 @@ impl Market {
|
||||
if cost > MAX_AMOUNT {
|
||||
return vec![Out::Error("That costs too much at once.".into())];
|
||||
}
|
||||
let tax = self.tax_on(cost);
|
||||
if economy.balance(me.uuid) < cost + tax {
|
||||
if economy.balance(me.uuid) < cost {
|
||||
return vec![Out::Error(format!(
|
||||
"{count} {name} cost {}{}; you have {}.",
|
||||
"{count} {name} cost {}; you have {}.",
|
||||
economy.money(cost),
|
||||
if tax > 0 {
|
||||
format!(" + {} tax", economy.money(tax))
|
||||
} else {
|
||||
String::new()
|
||||
},
|
||||
economy.money(economy.balance(me.uuid))
|
||||
))];
|
||||
}
|
||||
@@ -1059,12 +1006,6 @@ impl Market {
|
||||
} else {
|
||||
self.value(&id, given, true).0
|
||||
};
|
||||
let tax = if given > 0 {
|
||||
let due = self.tax_on(paid);
|
||||
self.tax(economy, me.uuid, due, &format!("buy {given} {id}"))
|
||||
} else {
|
||||
0
|
||||
};
|
||||
if given > 0 {
|
||||
self.moved(&id, given, true);
|
||||
let now = economy.now;
|
||||
@@ -1076,14 +1017,6 @@ impl Market {
|
||||
"Bought {given} {name} at {each} each: {}.",
|
||||
economy.money(paid)
|
||||
))];
|
||||
if tax > 0 {
|
||||
out.push(Out::Reply(format!(
|
||||
"Tax {}%: -{}. You paid {}.",
|
||||
self.config.dynamic.tax_percent,
|
||||
economy.money(tax),
|
||||
economy.money(paid + tax)
|
||||
)));
|
||||
}
|
||||
if count < wanted {
|
||||
out.push(Out::Reply(format!(
|
||||
"Only {count} of {wanted}: the price moved {}% this minute, as far as it may.",
|
||||
@@ -1117,34 +1050,6 @@ impl Market {
|
||||
}
|
||||
|
||||
impl Market {
|
||||
/// The tax on a trade of `amount` coins (0 without moving prices).
|
||||
pub fn tax_on(&self, amount: i64) -> i64 {
|
||||
if self.config.dynamic.enabled {
|
||||
self.config.dynamic.tax(amount)
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
/// Takes `tax` from `uuid` into the Treasury (logged as `market_tax`):
|
||||
/// what it took (0 when the log refused).
|
||||
fn tax(&mut self, economy: &mut Economy, uuid: u128, tax: i64, what: &str) -> i64 {
|
||||
let tax = tax.min(economy.balance(uuid));
|
||||
if tax <= 0 {
|
||||
return 0;
|
||||
}
|
||||
match economy.to_pool("market_tax", uuid, TREASURY, tax, what) {
|
||||
Ok(()) => {
|
||||
self.book.taxes = self.book.taxes.saturating_add(tax);
|
||||
tax
|
||||
}
|
||||
Err(e) => {
|
||||
self.warnings.push(format!("market tax: {e}"));
|
||||
0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A trade's move of the price (at once, with its family's).
|
||||
fn moved(&mut self, id: &str, n: u32, buy: bool) {
|
||||
if !self.config.dynamic.enabled {
|
||||
@@ -1288,8 +1193,8 @@ impl WebView {
|
||||
"generated": now,
|
||||
"currency": currency,
|
||||
"moving": self.config.enabled && d.enabled,
|
||||
"tax_percent": d.tax_percent,
|
||||
"taxes_collected": self.book.taxes,
|
||||
// No tax since 0.30: kept at 0 for older readers.
|
||||
"tax_percent": 0,
|
||||
"daily_summary": {
|
||||
"day": self.book.summary_day * DAY_MS,
|
||||
"lines": self.book.last_summary,
|
||||
@@ -1742,7 +1647,7 @@ mod tests {
|
||||
fn no_trades_on_moving_prices_ever_earn_once_the_items_are_back() {
|
||||
// Five players pump and dump every item for 2,000 minutes, with the
|
||||
// drift between; then every position is closed. On every item the
|
||||
// market ends up with the money, before any tax.
|
||||
// market ends up with the money.
|
||||
let c = moving();
|
||||
let mut m = Market::new(c.clone());
|
||||
let t0 = 1_800_000_000_000i64;
|
||||
@@ -1888,13 +1793,11 @@ mod tests {
|
||||
"{} >= {start}",
|
||||
e.balance(me.uuid)
|
||||
);
|
||||
// The Treasury got the tax on every trade.
|
||||
assert!(e.pool(TREASURY) > 0);
|
||||
fs::remove_dir_all(dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moving_prices_tax_the_receipt_and_keep_bought_items_from_coming_back() {
|
||||
fn moving_prices_pay_the_whole_receipt_and_keep_bought_items_from_coming_back() {
|
||||
let mut c = Config::default();
|
||||
let _ = validate(&mut c);
|
||||
let (mut e, _, me, dir) = setup("dyn");
|
||||
@@ -1906,21 +1809,16 @@ mod tests {
|
||||
// One price, 200; three diamonds move it 1.5% as they go.
|
||||
let out = m.sell(&mut e, &me, &mut b, &["diamond"]);
|
||||
assert_eq!(
|
||||
out[..2],
|
||||
[
|
||||
Out::Reply("Sold 3 diamond at 198 coins each: 595 coins.".into()),
|
||||
Out::Reply("Tax 2%: -12 coins. You get 583 coins.".into())
|
||||
]
|
||||
out[0],
|
||||
Out::Reply("Sold 3 diamond at 198 coins each: 595 coins.".into())
|
||||
);
|
||||
assert_eq!(e.pool(TREASURY), 12);
|
||||
assert_eq!(e.balance(me.uuid), 10_000 + 595, "no tax taken");
|
||||
let out = m.buy(&mut e, &me, &mut b, &["iron_ingot", "10"]);
|
||||
assert_eq!(
|
||||
out[..2],
|
||||
[
|
||||
Out::Reply("Bought 10 iron ingot at 15 coins each: 151 coins.".into()),
|
||||
Out::Reply("Tax 2%: -3 coins. You paid 154 coins.".into())
|
||||
]
|
||||
out[0],
|
||||
Out::Reply("Bought 10 iron ingot at 15 coins each: 151 coins.".into())
|
||||
);
|
||||
assert_eq!(e.balance(me.uuid), 10_000 + 595 - 151);
|
||||
let out = m.sell(&mut e, &me, &mut b, &["iron_ingot"]);
|
||||
assert_eq!(
|
||||
out[0],
|
||||
@@ -1934,7 +1832,7 @@ mod tests {
|
||||
"{out:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
out[2],
|
||||
out[1],
|
||||
Out::Reply("10 iron ingot kept: bought from the market in the last 24 h.".into())
|
||||
);
|
||||
b.items.insert("minecraft:iron_ingot".into(), 10);
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
// (rare, dear items move faster than common ones). A trade of n items costs
|
||||
// the area under the price along the way it moves the price (`fill`): the
|
||||
// same curve both ways, so a sale exactly retraces a purchase, and a round
|
||||
// trip earns nothing but the tax, whoever trades and however the price was
|
||||
// trip earns nothing, whoever trades and however the price was
|
||||
// pushed. Within a minute the price moves at most `max_minute_move_percent`
|
||||
// from where the minute began: the rest of a trade waits for the next minute
|
||||
// (never priced flat: buying slowly while the price drifts back, then
|
||||
@@ -15,8 +15,7 @@
|
||||
// 1 (mean reversion). Items made of one another (`smp_recipes`) share one
|
||||
// factor, with an elasticity by value, so crafting can't beat the market. The
|
||||
// history: one point a minute for 24 h, one an hour for 30 days
|
||||
// (`market_prices.json`), the time-weighted mean for futures (`twap`,
|
||||
// `mark_price`), the day's movers and news (`summary`). No game types. Added
|
||||
// (`market_prices.json`), the day's movers and news (`summary`). No game types. Added
|
||||
// to the game as `game::smp_market_prices`; also a module of this crate, for
|
||||
// its tests.
|
||||
|
||||
@@ -44,9 +43,6 @@ pub struct Dynamic {
|
||||
pub max_player_impact_percent: f64,
|
||||
/// How far the price may move within one minute (either way).
|
||||
pub max_minute_move_percent: f64,
|
||||
/// The share of each trade that goes to the Treasury (a money sink for
|
||||
/// the players, shown on the receipt).
|
||||
pub tax_percent: f64,
|
||||
/// What a player bought from the market can't be sold back to it for
|
||||
/// this long (that many of that item).
|
||||
pub round_trip_hours: i64,
|
||||
@@ -68,7 +64,6 @@ impl Default for Dynamic {
|
||||
max_trade_impact_percent: 2.0,
|
||||
max_player_impact_percent: 2.0,
|
||||
max_minute_move_percent: 5.0,
|
||||
tax_percent: 2.0,
|
||||
round_trip_hours: 24,
|
||||
depth: BTreeMap::new(),
|
||||
news_percent: 30.0,
|
||||
@@ -103,16 +98,9 @@ impl Dynamic {
|
||||
50.0,
|
||||
d.max_minute_move_percent,
|
||||
);
|
||||
self.tax_percent = ok(self.tax_percent, 0.0, 50.0, d.tax_percent);
|
||||
self.round_trip_hours = self.round_trip_hours.clamp(0, 24 * 30);
|
||||
self.depth.retain(|_, v| v.is_finite() && *v > 0.0);
|
||||
}
|
||||
|
||||
/// The tax on `amount` coins (rounded to the nearest coin).
|
||||
pub fn tax(&self, amount: i64) -> i64 {
|
||||
let bp = (self.tax_percent * 100.0).round() as i64;
|
||||
(amount.saturating_mul(bp) + 5_000) / 10_000
|
||||
}
|
||||
}
|
||||
|
||||
/// One point of history: its start (ms), the mid price then, the items
|
||||
@@ -190,8 +178,6 @@ pub struct Book {
|
||||
/// What each player bought (item, when, how many), for the round-trip
|
||||
/// rule.
|
||||
pub bought: BTreeMap<String, Vec<(String, i64, u32)>>,
|
||||
/// The Treasury's take so far (display).
|
||||
pub taxes: i64,
|
||||
/// The last daily summary's lines (for the web page and Kira).
|
||||
pub last_summary: Vec<String>,
|
||||
/// The base price each item's history is quoted in (since 0.29).
|
||||
@@ -366,7 +352,7 @@ impl Book {
|
||||
/// At load: each minute's band opens where its price is, and a book
|
||||
/// from before 0.29 (no `bases`), whose history is quoted in the old
|
||||
/// mid of buy and sell (`old`), gets it scaled to the one price of now
|
||||
/// (`bases`), so the charts, the moves and the futures' TWAP see no
|
||||
/// (`bases`), so the charts and the moves see no
|
||||
/// cliff. The items it scaled, by how much.
|
||||
pub fn settle(
|
||||
&mut self,
|
||||
@@ -500,46 +486,6 @@ impl Book {
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(
|
||||
dead_code,
|
||||
reason = "read by the futures (smp-econ), not the market itself"
|
||||
)]
|
||||
/// The minute points of `id` within the last `minutes` minutes.
|
||||
fn recent(&self, id: &str, minutes: u32, now: i64) -> impl Iterator<Item = &Point> {
|
||||
let since = now - i64::from(minutes) * MINUTE_MS;
|
||||
self.items
|
||||
.get(id)
|
||||
.into_iter()
|
||||
.flat_map(move |t| t.minutes.iter().rev().take_while(move |p| p.t >= since))
|
||||
}
|
||||
|
||||
#[allow(
|
||||
dead_code,
|
||||
reason = "read by the futures (smp-econ), not the market itself"
|
||||
)]
|
||||
/// The time-weighted mean mid over the last `minutes` complete minutes
|
||||
/// (each point is one minute); None without a point there.
|
||||
pub fn twap(&self, id: &str, minutes: u32, now: i64) -> Option<f64> {
|
||||
let (n, sum) = self
|
||||
.recent(id, minutes, now)
|
||||
.fold((0u32, 0.0), |(n, s), p| (n + 1, s + p.price));
|
||||
(n > 0).then(|| sum / f64::from(n))
|
||||
}
|
||||
|
||||
#[allow(
|
||||
dead_code,
|
||||
reason = "read by the futures (smp-econ), not the market itself"
|
||||
)]
|
||||
/// The 10-minute TWAP, rounded, from at least 5 points: a mark price
|
||||
/// one player can't move much.
|
||||
pub fn mark_price(&self, id: &str, now: i64) -> Option<i64> {
|
||||
let n = self.recent(id, 10, now).count();
|
||||
(n >= 5)
|
||||
.then(|| self.twap(id, 10, now))
|
||||
.flatten()
|
||||
.map(|p| p.round() as i64)
|
||||
}
|
||||
|
||||
/// Items of `id` traded in the last `hours` (minutes for a day, hours
|
||||
/// beyond).
|
||||
pub fn volume(&self, id: &str, now: i64, hours: u32) -> u64 {
|
||||
@@ -732,8 +678,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_round_trip_earns_nothing_but_the_tax_for_1_64_and_past_the_band() {
|
||||
let c = Dynamic::default();
|
||||
fn a_round_trip_earns_nothing_for_1_64_and_past_the_band() {
|
||||
// 1, 64, and 2,000 (far past the minute's 5% band: only what fits
|
||||
// goes, and the same count comes back).
|
||||
for n in [1u32, 64, 2000] {
|
||||
@@ -742,11 +687,9 @@ mod tests {
|
||||
let (a, done) = deal(&mut b, n, first_buy);
|
||||
let (z, back) = deal(&mut b, done, !first_buy);
|
||||
assert_eq!(back, done);
|
||||
let net = a + z - c.tax(a.abs()) - c.tax(z.abs());
|
||||
assert!(a + z <= 0, "{n} {first_buy}: {a} {z}");
|
||||
assert!(net < 0, "{n} {first_buy}: {net}");
|
||||
// Within the band the curve is retraced exactly: only the
|
||||
// rounding of whole coins (at most 2) and the tax remain.
|
||||
// rounding of whole coins (at most 2) remains.
|
||||
if n <= 8 {
|
||||
assert!(a + z >= -2, "{n} {first_buy}: {a} {z}");
|
||||
}
|
||||
@@ -839,18 +782,14 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn twap_mark_price_volume_and_history() {
|
||||
fn volume_and_history() {
|
||||
let c = Dynamic::default();
|
||||
let mut b = book();
|
||||
assert_eq!(b.mark_price("minecraft:diamond", T0), None);
|
||||
for m in 1..=12 {
|
||||
b.trade(&c, D, &[], 250.0, delta(), 2, true);
|
||||
b.tick(&c, &mids(), T0 + m * MINUTE_MS);
|
||||
}
|
||||
let now = T0 + 12 * MINUTE_MS;
|
||||
let mark = b.mark_price("minecraft:diamond", now).unwrap();
|
||||
let spot = b.quote(&c, D, 250.0, delta()).mid;
|
||||
assert!(mark as f64 <= spot && mark > 250, "{mark} {spot}");
|
||||
assert_eq!(b.volume("minecraft:diamond", now, 24), 24);
|
||||
assert_eq!(b.history("minecraft:diamond", Span::Minute).len(), 12);
|
||||
let h = b.history("minecraft:diamond", Span::Minute);
|
||||
@@ -904,7 +843,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_round_trip_window_and_the_tax() {
|
||||
fn the_round_trip_window_and_one_price() {
|
||||
let c = Dynamic::default();
|
||||
let mut b = book();
|
||||
b.bought("u", "minecraft:diamond", 5, T0);
|
||||
@@ -918,9 +857,6 @@ mod tests {
|
||||
0
|
||||
);
|
||||
assert!(b.bought.is_empty());
|
||||
assert_eq!(c.tax(100), 2);
|
||||
assert_eq!(c.tax(25), 1);
|
||||
assert_eq!(c.tax(24), 0);
|
||||
// One price from the old two: their geometric mean, whole.
|
||||
assert_eq!(one_price(400, 100), 200);
|
||||
assert_eq!(one_price(30, 8), 15);
|
||||
|
||||
@@ -30,33 +30,6 @@ pub struct Config {
|
||||
pub homes: Vec<[i64; 2]>,
|
||||
/// `/rules`, a line each.
|
||||
pub rules: Vec<String>,
|
||||
/// What each kind of teleport costs (coins, to the Treasury), paid when
|
||||
/// it happens: a called-off one costs nothing.
|
||||
pub fees: Fees,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Fees {
|
||||
/// Free: a lost or new player can always get back.
|
||||
pub spawn: i64,
|
||||
pub home: i64,
|
||||
pub warp: i64,
|
||||
pub back: i64,
|
||||
/// Paid by the one who moves.
|
||||
pub tpa: i64,
|
||||
}
|
||||
|
||||
impl Default for Fees {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
spawn: 0,
|
||||
home: 5,
|
||||
warp: 10,
|
||||
back: 10,
|
||||
tpa: 10,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
@@ -71,7 +44,6 @@ impl Default for Config {
|
||||
"No griefing, no stealing, no cheats.".into(),
|
||||
"The currency is earned in game only: never bought or sold for real money.".into(),
|
||||
],
|
||||
fees: Fees::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -121,7 +93,6 @@ enum To {
|
||||
pub struct Pending {
|
||||
pub uuid: u128,
|
||||
to: To,
|
||||
fee: i64,
|
||||
from: Place,
|
||||
health: f32,
|
||||
due: i64,
|
||||
@@ -143,8 +114,7 @@ pub struct Teleport {
|
||||
pub to: Place,
|
||||
/// What the player is told once there.
|
||||
pub done: String,
|
||||
/// What it costs, and what for (the audit log's reason).
|
||||
pub fee: i64,
|
||||
/// Where to, in words (`where you died`).
|
||||
pub what: String,
|
||||
/// The cooldown and `/back` before it: `call_off` puts them back.
|
||||
undo: (Option<i64>, Option<(Place, bool)>),
|
||||
@@ -271,22 +241,7 @@ impl Places {
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// What the command in `line` will cost its player (`/tpaccept`: the
|
||||
/// one who asked pays, when they move).
|
||||
pub fn fee(&self, line: &str) -> i64 {
|
||||
let f = &self.config.fees;
|
||||
let fee = match line.split_whitespace().next() {
|
||||
Some("spawn") => f.spawn,
|
||||
Some("home") => f.home,
|
||||
Some("warp") => f.warp,
|
||||
Some("back") => f.back,
|
||||
Some("tpa") => f.tpa,
|
||||
_ => 0,
|
||||
};
|
||||
fee.max(0)
|
||||
}
|
||||
|
||||
/// A teleport the game couldn't make or the player couldn't pay: no
|
||||
/// A teleport the game couldn't make: no
|
||||
/// cooldown, and `/back` goes where it went before.
|
||||
pub fn call_off(&mut self, t: &Teleport) {
|
||||
match t.undo.0 {
|
||||
@@ -301,14 +256,6 @@ impl Places {
|
||||
|
||||
/// Starts a teleport's warmup, or says why not.
|
||||
fn start(&mut self, me: &Body, to: To, now: i64) -> Vec<Out> {
|
||||
let fee = match &to {
|
||||
To::Player(..) => self.config.fees.tpa,
|
||||
To::Place(_, what) if what == "the spawn" => self.config.fees.spawn,
|
||||
To::Place(_, what) if what.starts_with("home") => self.config.fees.home,
|
||||
To::Place(_, what) if what.starts_with("warp") => self.config.fees.warp,
|
||||
To::Place(..) => self.config.fees.back,
|
||||
}
|
||||
.max(0);
|
||||
if let Some(&t) = self.last.get(&me.uuid) {
|
||||
let wait = t + self.config.cooldown_seconds * 1000 - now;
|
||||
if wait > 0 {
|
||||
@@ -322,19 +269,13 @@ impl Places {
|
||||
self.pending.push(Pending {
|
||||
uuid: me.uuid,
|
||||
to,
|
||||
fee,
|
||||
from: me.at.clone(),
|
||||
health: me.health,
|
||||
due: now + self.config.warmup_seconds.max(0) * 1000,
|
||||
});
|
||||
let cost = if fee > 0 {
|
||||
format!(" (fee {fee} coins)")
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
if self.config.warmup_seconds > 0 {
|
||||
vec![Out::Reply(format!(
|
||||
"Teleporting in {} s{cost}: don't move.",
|
||||
"Teleporting in {} s: don't move.",
|
||||
self.config.warmup_seconds
|
||||
))]
|
||||
} else {
|
||||
@@ -634,7 +575,6 @@ impl Places {
|
||||
name: me.name.clone(),
|
||||
to,
|
||||
done: format!("Teleported to {what}."),
|
||||
fee: p.fee,
|
||||
what,
|
||||
undo,
|
||||
});
|
||||
@@ -808,7 +748,7 @@ mod tests {
|
||||
let out = p.command(&player(B, 0), &both, 1_000, "tpaccept").unwrap();
|
||||
assert_eq!(
|
||||
out[1],
|
||||
Out::Tell(A, "Teleporting in 3 s (fee 10 coins): don't move.".into())
|
||||
Out::Tell(A, "Teleporting in 3 s: don't move.".into())
|
||||
);
|
||||
// Steve walked on meanwhile: Alex lands where Steve is then.
|
||||
let later = [body(A, 0.0), body(B, 120.0)];
|
||||
@@ -846,19 +786,14 @@ mod tests {
|
||||
dead.health = 0.0;
|
||||
p.tick(&[dead], 0);
|
||||
let alive = [body(A, 0.0)];
|
||||
assert_eq!(p.fee("back"), 10);
|
||||
assert_eq!(p.fee("spawn"), 0);
|
||||
assert_eq!(p.fee("tpaccept"), 0, "the one who moves pays");
|
||||
let out = p.command(&player(A, 0), &alive, 1_000, "back").unwrap();
|
||||
assert_eq!(
|
||||
out,
|
||||
vec![Out::Reply(
|
||||
"Teleporting in 3 s (fee 10 coins): don't move.".into()
|
||||
)]
|
||||
vec![Out::Reply("Teleporting in 3 s: don't move.".into())]
|
||||
);
|
||||
let (_, go) = p.tick(&alive, 4_000);
|
||||
assert_eq!((go[0].fee, go[0].what.as_str()), (10, "where you died"));
|
||||
// Couldn't pay: /back still goes where they died, at once.
|
||||
assert_eq!(go[0].what, "where you died");
|
||||
// The game couldn't make it: /back still goes where they died, at once.
|
||||
p.call_off(&go[0]);
|
||||
p.command(&player(A, 0), &alive, 4_000, "back").unwrap();
|
||||
let (_, go) = p.tick(&alive, 7_000);
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
// The economy in the open: `/eco stats` (the money supply and where it
|
||||
// sits, what the server made and destroyed today, the Treasury's day, how
|
||||
// evenly money is spread), a report to everyone at 00:00 UTC on the day
|
||||
// sits, what the server made and destroyed today, how evenly money is
|
||||
// spread), a report to everyone at 00:00 UTC on the day
|
||||
// before, and `web/economy.json` for the site's /hub (written once a minute,
|
||||
// off the tick thread, by the glue). One snapshot a day is kept (60 days) in
|
||||
// `economy.json` (`ext.report`) for the curves. Reading only: this part
|
||||
// moves no money. No game types. Added to the game as `game::smp_report`;
|
||||
// also a module of this crate, for its tests.
|
||||
|
||||
use super::smp_economy::{DAY_MS, Economy, INSURANCE, Out, TREASURY};
|
||||
use super::smp_economy::{DAY_MS, Economy, Out};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// `config.json`'s `report`.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
@@ -41,16 +41,11 @@ pub struct Day {
|
||||
pub day: i64,
|
||||
/// All the money there is, at the end of the day.
|
||||
pub supply: i64,
|
||||
/// Players' money: wallets, savings, money held (bids, stakes, margins).
|
||||
/// Players' money: wallets, money held (bids, stakes).
|
||||
pub players: i64,
|
||||
pub treasury: i64,
|
||||
pub insurance: i64,
|
||||
/// Made and destroyed by the server that day.
|
||||
pub minted: i64,
|
||||
pub burned: i64,
|
||||
/// The Treasury's income and spending that day.
|
||||
pub taxes: i64,
|
||||
pub spent: i64,
|
||||
/// Over players with money: Gini (0 to 1, in thousandths) and the
|
||||
/// richest ten's share (percent).
|
||||
pub gini_milli: i64,
|
||||
@@ -73,8 +68,8 @@ pub struct Report {
|
||||
minute: i64,
|
||||
}
|
||||
|
||||
/// Pools that are the state's, not players' money.
|
||||
const STATE_POOLS: [&str; 3] = [TREASURY, INSURANCE, "casino"];
|
||||
/// Pools that aren't players' money: the casino's pot.
|
||||
const HOUSE_POOLS: [&str; 1] = ["casino"];
|
||||
|
||||
/// Gini (thousandths) and the top ten's share (percent) of `wealth`.
|
||||
pub fn spread(mut wealth: Vec<i64>) -> (i64, i64) {
|
||||
@@ -152,83 +147,53 @@ impl Report {
|
||||
}
|
||||
}
|
||||
|
||||
/// The day's numbers now. `savings`: what each player has in the bank.
|
||||
pub fn snapshot(economy: &mut Economy, savings: &HashMap<u128, i64>, day: i64) -> Day {
|
||||
/// The day's numbers now.
|
||||
pub fn snapshot(economy: &mut Economy, day: i64) -> Day {
|
||||
let accounts = economy.accounts();
|
||||
let wallets: i64 = accounts.iter().map(|a| a.2).sum();
|
||||
let pools: i64 = economy.pools().values().sum();
|
||||
let state: i64 = STATE_POOLS.iter().map(|p| economy.pool(p)).sum();
|
||||
let house: i64 = HOUSE_POOLS.iter().map(|p| economy.pool(p)).sum();
|
||||
let supply = wallets + economy.held_total() + pools;
|
||||
let mut wealth: HashMap<u128, i64> = accounts.iter().map(|a| (a.0, a.2)).collect();
|
||||
for (u, s) in savings {
|
||||
*wealth.entry(*u).or_default() += s;
|
||||
}
|
||||
let (gini_milli, top10_percent) = spread(wealth.into_values().collect());
|
||||
let (gini_milli, top10_percent) = spread(accounts.iter().map(|a| a.2).collect());
|
||||
let today = economy.now.div_euclid(DAY_MS);
|
||||
let (minted, burned, taxes, spent) = {
|
||||
let t = economy.treasury();
|
||||
let (inc, out) = if t.day == day {
|
||||
(sum(&t.income_today), sum(&t.spent_today))
|
||||
} else if t.day == day + 1 {
|
||||
(sum(&t.income_yesterday), sum(&t.spent_yesterday))
|
||||
} else {
|
||||
(0, 0)
|
||||
};
|
||||
let f = economy.flows();
|
||||
let (m, b) = if day == today {
|
||||
(sum(&f.minted), sum(&f.burned))
|
||||
} else if day + 1 == today {
|
||||
(sum(&f.minted_yesterday), sum(&f.burned_yesterday))
|
||||
} else {
|
||||
(0, 0)
|
||||
};
|
||||
(m, b, inc, out)
|
||||
let f = economy.flows();
|
||||
let (minted, burned) = if day == today {
|
||||
(sum(&f.minted), sum(&f.burned))
|
||||
} else if day + 1 == today {
|
||||
(sum(&f.minted_yesterday), sum(&f.burned_yesterday))
|
||||
} else {
|
||||
(0, 0)
|
||||
};
|
||||
Day {
|
||||
day,
|
||||
supply,
|
||||
players: supply - state,
|
||||
treasury: economy.pool(TREASURY),
|
||||
insurance: economy.pool(INSURANCE),
|
||||
players: supply - house,
|
||||
minted,
|
||||
burned,
|
||||
taxes,
|
||||
spent,
|
||||
gini_milli,
|
||||
top10_percent,
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs `/eco stats` (or `/money stats`); None for another command.
|
||||
pub fn command(
|
||||
&self,
|
||||
economy: &mut Economy,
|
||||
savings: &HashMap<u128, i64>,
|
||||
line: &str,
|
||||
) -> Option<Vec<Out>> {
|
||||
pub fn command(&self, economy: &mut Economy, line: &str) -> Option<Vec<Out>> {
|
||||
let words: Vec<&str> = line.split_whitespace().collect();
|
||||
if !matches!(words.as_slice(), ["eco" | "money", "stats"]) {
|
||||
return None;
|
||||
}
|
||||
let today = economy.now.div_euclid(DAY_MS);
|
||||
let d = Self::snapshot(economy, savings, today);
|
||||
let d = Self::snapshot(economy, today);
|
||||
let since = self.state.days.last().map(|y| d.supply - y.supply);
|
||||
let pools: Vec<String> = economy
|
||||
.pools()
|
||||
.iter()
|
||||
.filter(|(k, v)| **v > 0 && !STATE_POOLS.contains(&k.as_str()))
|
||||
.filter(|(k, v)| **v > 0 && !HOUSE_POOLS.contains(&k.as_str()))
|
||||
.map(|(k, v)| format!("{} {}", k.replace('_', " "), economy.money(*v)))
|
||||
.collect();
|
||||
let flows = economy.flows().clone();
|
||||
let t = economy.treasury().clone();
|
||||
let (inc, out) = if t.day == today {
|
||||
(t.income_today, t.spent_today)
|
||||
} else {
|
||||
Default::default()
|
||||
};
|
||||
let mut lines = vec![
|
||||
Some(vec![
|
||||
Out::Reply(format!(
|
||||
"Money supply: {}{}. Players hold {} (of it in pools: {}), the Treasury {}, the insurance fund {}.",
|
||||
"Money supply: {}{}. Players hold {} (of it in pools: {}), the casino's pot {}.",
|
||||
economy.money(d.supply),
|
||||
since.map_or(String::new(), |s| format!(
|
||||
" ({}{} since midnight)",
|
||||
@@ -241,8 +206,7 @@ impl Report {
|
||||
} else {
|
||||
pools.join(", ")
|
||||
},
|
||||
economy.money(d.treasury),
|
||||
economy.money(d.insurance)
|
||||
economy.money(d.supply - d.players)
|
||||
)),
|
||||
Out::Reply(format!(
|
||||
"Today the server made {} ({}) and destroyed {} ({}).",
|
||||
@@ -251,27 +215,16 @@ impl Report {
|
||||
economy.money(sum(&flows.burned)),
|
||||
list(economy, &flows.burned, 3)
|
||||
)),
|
||||
Out::Reply(format!(
|
||||
"Taxes and fees today: {} ({}). The Treasury paid out {}.",
|
||||
economy.money(sum(&inc)),
|
||||
list(economy, &inc, 4),
|
||||
economy.money(sum(&out))
|
||||
)),
|
||||
Out::Reply(format!(
|
||||
"Spread: Gini {}.{:03} (0 equal, 1 one player has all), the richest ten hold {}%.",
|
||||
d.gini_milli / 1000,
|
||||
d.gini_milli % 1000,
|
||||
d.top10_percent
|
||||
)),
|
||||
];
|
||||
lines.push(Out::Reply(
|
||||
"The Treasury: /treasury. The taxes: /eco taxes.".into(),
|
||||
));
|
||||
Some(lines)
|
||||
])
|
||||
}
|
||||
|
||||
/// Whether `tick` has work at `now` (a new minute or day): the glue
|
||||
/// gathers the savings only then.
|
||||
/// Whether `tick` has work at `now` (a new minute or day).
|
||||
pub fn due(&self, now: i64) -> bool {
|
||||
now.div_euclid(60_000) != self.minute || now.div_euclid(DAY_MS) > self.state.day
|
||||
}
|
||||
@@ -279,12 +232,7 @@ impl Report {
|
||||
/// Once a second from the glue. At the day's turn: the day before is
|
||||
/// kept and, if on, told to everyone. Returns the lines, and whether
|
||||
/// `web/economy.json` is due (once a minute).
|
||||
pub fn tick(
|
||||
&mut self,
|
||||
economy: &mut Economy,
|
||||
savings: &HashMap<u128, i64>,
|
||||
now: i64,
|
||||
) -> (Vec<Out>, bool) {
|
||||
pub fn tick(&mut self, economy: &mut Economy, now: i64) -> (Vec<Out>, bool) {
|
||||
let minute = now.div_euclid(60_000);
|
||||
let web = self.config.web_json && minute != self.minute;
|
||||
self.minute = minute;
|
||||
@@ -296,7 +244,7 @@ impl Report {
|
||||
self.state.day = today;
|
||||
self.dirty = true;
|
||||
// The supply now is yesterday's end, give or take a second.
|
||||
let d = Self::snapshot(economy, savings, yesterday);
|
||||
let d = Self::snapshot(economy, yesterday);
|
||||
let before = self
|
||||
.state
|
||||
.days
|
||||
@@ -318,7 +266,7 @@ impl Report {
|
||||
|
||||
fn daily_line(&self, economy: &Economy, d: &Day, before: Option<i64>) -> String {
|
||||
format!(
|
||||
"Economy yesterday: money supply {}{}; the server made {} and destroyed {}; taxes and fees {} to the Treasury (now {}), which paid out {}. /eco stats",
|
||||
"Economy yesterday: money supply {}{}; the server made {} and destroyed {}. /eco stats",
|
||||
economy.money(d.supply),
|
||||
before.map_or(String::new(), |b| {
|
||||
let c = d.supply - b;
|
||||
@@ -329,27 +277,15 @@ impl Report {
|
||||
)
|
||||
}),
|
||||
economy.money(d.minted),
|
||||
economy.money(d.burned),
|
||||
economy.money(d.taxes),
|
||||
economy.money(d.treasury),
|
||||
economy.money(d.spent)
|
||||
economy.money(d.burned)
|
||||
)
|
||||
}
|
||||
|
||||
/// `web/economy.json`: aggregates, the Treasury's every movement (public
|
||||
/// in game too), the tax code, the days kept.
|
||||
pub fn web(
|
||||
&self,
|
||||
economy: &mut Economy,
|
||||
savings: &HashMap<u128, i64>,
|
||||
taxes: Value,
|
||||
now: i64,
|
||||
) -> Value {
|
||||
/// `web/economy.json`: aggregates and the days kept.
|
||||
pub fn web(&self, economy: &mut Economy, now: i64) -> Value {
|
||||
let today = now.div_euclid(DAY_MS);
|
||||
let d = Self::snapshot(economy, savings, today);
|
||||
let d = Self::snapshot(economy, today);
|
||||
let flows = economy.flows().clone();
|
||||
let t = economy.treasury().clone();
|
||||
let fresh = t.day == today;
|
||||
let pools: BTreeMap<String, i64> = economy.pools().clone();
|
||||
let daily = self.state.days.last().map(|y| {
|
||||
self.daily_line(
|
||||
@@ -372,16 +308,6 @@ impl Report {
|
||||
"pools": pools,
|
||||
"minted_today": flows.minted,
|
||||
"burned_today": flows.burned,
|
||||
"treasury": {
|
||||
"balance": economy.pool(TREASURY),
|
||||
"insurance": economy.pool(INSURANCE),
|
||||
"income": t.income,
|
||||
"spent": t.spent,
|
||||
"income_today": if fresh { t.income_today } else { BTreeMap::new() },
|
||||
"spent_today": if fresh { t.spent_today } else { BTreeMap::new() },
|
||||
"log": t.log,
|
||||
},
|
||||
"taxes": taxes,
|
||||
"days": self.state.days,
|
||||
"daily_report": daily,
|
||||
})
|
||||
@@ -419,15 +345,13 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stats_show_what_was_made_destroyed_and_taxed_today() {
|
||||
fn stats_show_what_was_made_and_destroyed_today() {
|
||||
let (mut e, r) = setup("stats");
|
||||
e.adjust("sell 20 minecraft:wheat", A, 600, "").unwrap();
|
||||
e.adjust("daily_reward", B, 50, "").unwrap();
|
||||
e.adjust("buy 5 minecraft:torch", A, -100, "").unwrap();
|
||||
e.to_pool("vat", A, TREASURY, 25, "").unwrap();
|
||||
let out = r
|
||||
.command(&mut e, &HashMap::from([(B, 0)]), "eco stats")
|
||||
.unwrap();
|
||||
e.to_pool("ah_bid", A, "auction", 25, "").unwrap();
|
||||
let out = r.command(&mut e, "eco stats").unwrap();
|
||||
let text: Vec<String> = out
|
||||
.iter()
|
||||
.map(|o| match o {
|
||||
@@ -435,52 +359,40 @@ mod tests {
|
||||
o => panic!("{o:?}"),
|
||||
})
|
||||
.collect();
|
||||
assert!(
|
||||
text[0].starts_with("Money supply: 550 coins. Players hold 525 coins"),
|
||||
"{}",
|
||||
text[0]
|
||||
assert_eq!(
|
||||
text[0],
|
||||
"Money supply: 550 coins. Players hold 550 coins (of it in pools: auction 25 coins), the casino's pot 0 coins."
|
||||
);
|
||||
assert_eq!(
|
||||
text[1],
|
||||
"Today the server made 650 coins (market sales 600 coins, daily reward 50 coins) and destroyed 100 coins (market purchases 100 coins)."
|
||||
);
|
||||
assert!(
|
||||
text[2].starts_with("Taxes and fees today: 25 coins (vat 25 coins)"),
|
||||
"{}",
|
||||
text[2]
|
||||
);
|
||||
assert!(r.command(&mut e, &HashMap::new(), "eco give A 5").is_none());
|
||||
assert!(!text.iter().any(|l| l.contains("Treasury")), "{text:?}");
|
||||
assert!(r.command(&mut e, "eco give A 5").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn midnight_reports_the_day_before_once_and_keeps_it_through_a_restart() {
|
||||
let (mut e, mut r) = setup("daily");
|
||||
e.adjust("sell 1 minecraft:diamond", A, 100, "").unwrap();
|
||||
e.to_pool("vat", A, TREASURY, 5, "").unwrap();
|
||||
let (out, web) = r.tick(&mut e, &HashMap::new(), T0 + 2000);
|
||||
let (out, web) = r.tick(&mut e, T0 + 2000);
|
||||
assert!(out.is_empty() && web, "same day; the JSON's first minute");
|
||||
assert!(
|
||||
!r.tick(&mut e, &HashMap::new(), T0 + 3000).1,
|
||||
"once a minute"
|
||||
);
|
||||
assert!(!r.tick(&mut e, T0 + 3000).1, "once a minute");
|
||||
e.now = T0 + DAY_MS + 500;
|
||||
let now = e.now;
|
||||
let (out, _) = r.tick(&mut e, &HashMap::new(), now);
|
||||
let (out, _) = r.tick(&mut e, now);
|
||||
assert_eq!(
|
||||
out,
|
||||
[Out::All(
|
||||
"Economy yesterday: money supply 100 coins; the server made 100 coins and destroyed 0 coins; taxes and fees 5 coins to the Treasury (now 5 coins), which paid out 0 coins. /eco stats"
|
||||
"Economy yesterday: money supply 100 coins; the server made 100 coins and destroyed 0 coins. /eco stats"
|
||||
.into()
|
||||
)]
|
||||
);
|
||||
assert!(
|
||||
r.tick(&mut e, &HashMap::new(), now + 1000).0.is_empty(),
|
||||
"once"
|
||||
);
|
||||
let json = r.web(&mut e, &HashMap::new(), json!({}), now);
|
||||
assert!(r.tick(&mut e, now + 1000).0.is_empty(), "once");
|
||||
let json = r.web(&mut e, now);
|
||||
assert_eq!(json["schema"], 1);
|
||||
assert_eq!(json["days"][0]["minted"], 100);
|
||||
assert_eq!(json["treasury"]["balance"], 5);
|
||||
assert!(json.get("treasury").is_none());
|
||||
assert!(
|
||||
json["daily_report"]
|
||||
.as_str()
|
||||
|
||||
@@ -1,676 +0,0 @@
|
||||
// The tax code and the Treasury's commands. Every tax, fee and house edge
|
||||
// goes into the Treasury pool (smp_economy's `TREASURY`), never away, and
|
||||
// operators pay only from it (`/treasury pay`): nobody makes money. Weekly
|
||||
// taxes (land per claimed chunk, a light progressive wealth tax) are
|
||||
// collected on Monday 00:00 UTC, looked at once a second for an O(1) "due?".
|
||||
// `/eco taxes` explains the rates and a player's own bill; `/treasury` shows
|
||||
// the balance, income by tax and spending; `/treasury log` the last
|
||||
// movements. The tax state (the week collected, unpaid land tax) is kept in
|
||||
// `economy.json` with the balances (`Economy::ext("tax")`). No game types.
|
||||
// Added to the game as `game::smp_tax`; also a module of this crate, for its
|
||||
// tests.
|
||||
|
||||
use super::smp_economy::{DAY_MS, Economy, INSURANCE, Out, Player, TREASURY, parse_amount};
|
||||
use super::smp_store::uuid_string;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
|
||||
/// `config.json`'s `taxes`.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Config {
|
||||
/// Weekly taxes on (land, wealth); the per-sale ones are their parts'.
|
||||
pub enabled: bool,
|
||||
/// VAT on sales between players (the auction house), paid by the seller
|
||||
/// from the price, in percent.
|
||||
pub vat_percent: i64,
|
||||
/// Claimed chunks nobody pays for.
|
||||
pub land_free_chunks: usize,
|
||||
/// Land tax a week per claimed chunk past the free ones.
|
||||
pub land_tax_per_chunk: i64,
|
||||
/// `[from, percent]`: each week, `percent` of the part of a balance
|
||||
/// above `from` (up to the next bracket's `from`).
|
||||
pub wealth_brackets: Vec<[i64; 2]>,
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
vat_percent: 5,
|
||||
land_free_chunks: 4,
|
||||
land_tax_per_chunk: 5,
|
||||
wealth_brackets: vec![[50_000, 1], [250_000, 2], [1_000_000, 3]],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One weekly collection, for `/treasury` and the report.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Collection {
|
||||
pub week: i64,
|
||||
pub land: i64,
|
||||
pub wealth: i64,
|
||||
/// Land tax players couldn't pay (added to their arrears).
|
||||
pub unpaid: i64,
|
||||
pub payers: usize,
|
||||
}
|
||||
|
||||
/// The tax state, in `economy.json` (`ext.tax`).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct State {
|
||||
/// The last week collected (Monday-based week number).
|
||||
pub week: i64,
|
||||
/// Land tax owed, by UUID: no new claim until it's paid.
|
||||
pub arrears: BTreeMap<String, i64>,
|
||||
pub last: Option<Collection>,
|
||||
}
|
||||
|
||||
/// The week number of `now` (ms), weeks starting Monday 00:00 UTC (1970-01-01
|
||||
/// was a Thursday).
|
||||
pub fn week(now: i64) -> i64 {
|
||||
(now.div_euclid(DAY_MS) + 3).div_euclid(7)
|
||||
}
|
||||
|
||||
/// When the week after `week` starts (ms).
|
||||
pub fn week_start(week: i64) -> i64 {
|
||||
(week * 7 - 3) * DAY_MS
|
||||
}
|
||||
|
||||
pub struct Taxes {
|
||||
pub config: Config,
|
||||
pub state: State,
|
||||
dirty: bool,
|
||||
/// What each player has in the bank (the glue sets it): the wealth tax
|
||||
/// counts it with the balance, so saving doesn't hide wealth.
|
||||
pub savings: HashMap<u128, i64>,
|
||||
}
|
||||
|
||||
/// `12d 3h`, `3h 05m`, `4m`.
|
||||
pub fn span(ms: i64) -> String {
|
||||
let m = ms.max(0) / 60_000;
|
||||
let (d, h, m) = (m / 1440, m / 60 % 24, m % 60);
|
||||
if d > 0 {
|
||||
format!("{d}d {h}h")
|
||||
} else if h > 0 {
|
||||
format!("{h}h {m:02}m")
|
||||
} else {
|
||||
format!("{m}m")
|
||||
}
|
||||
}
|
||||
|
||||
impl Taxes {
|
||||
/// The taxes, their state read from the economy (none yet: the first
|
||||
/// collection is next Monday, never weeks back).
|
||||
pub fn open(economy: &Economy, config: Config) -> Self {
|
||||
let state = economy
|
||||
.ext("tax")
|
||||
.and_then(|v| serde_json::from_value::<State>(v.clone()).ok());
|
||||
let dirty = state.is_none();
|
||||
let state = state.unwrap_or_else(|| State {
|
||||
week: week(economy.now),
|
||||
..State::default()
|
||||
});
|
||||
Self {
|
||||
config,
|
||||
state,
|
||||
dirty,
|
||||
savings: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes the state into the economy (saved with the balances).
|
||||
pub fn store(&mut self, economy: &mut Economy) {
|
||||
if self.dirty {
|
||||
economy.set_ext("tax", serde_json::to_value(&self.state).unwrap_or_default());
|
||||
self.dirty = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// The land tax a week for `chunks` claimed chunks.
|
||||
pub fn land_bill(&self, chunks: usize) -> i64 {
|
||||
let taxed = chunks.saturating_sub(self.config.land_free_chunks) as i64;
|
||||
taxed.saturating_mul(self.config.land_tax_per_chunk.max(0))
|
||||
}
|
||||
|
||||
/// The wealth tax a week on `balance`.
|
||||
pub fn wealth_bill(&self, balance: i64) -> i64 {
|
||||
let mut brackets = self.config.wealth_brackets.clone();
|
||||
brackets.sort();
|
||||
let mut bill: i128 = 0;
|
||||
for (i, [from, percent]) in brackets.iter().enumerate() {
|
||||
let to = brackets.get(i + 1).map_or(i64::MAX, |b| b[0]);
|
||||
let part = balance.min(to) - from;
|
||||
if part > 0 {
|
||||
bill += i128::from(part) * i128::from((*percent).clamp(0, 100)) / 100;
|
||||
}
|
||||
}
|
||||
bill as i64
|
||||
}
|
||||
|
||||
/// What `uuid` owes in land tax.
|
||||
pub fn arrears(&self, uuid: u128) -> i64 {
|
||||
self.state
|
||||
.arrears
|
||||
.get(&uuid_string(uuid))
|
||||
.copied()
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Whether `uuid` may claim more land: not while they owe land tax.
|
||||
pub fn may_claim(&self, economy: &Economy, uuid: u128) -> Result<(), String> {
|
||||
match self.arrears(uuid) {
|
||||
0 => Ok(()),
|
||||
owed => Err(format!(
|
||||
"You owe {} of land tax: no new claim until it's paid (/eco taxes pay).",
|
||||
economy.money(owed)
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a weekly collection is due (cheap: once a second).
|
||||
pub fn due(&self, now: i64) -> bool {
|
||||
self.config.enabled && week(now) > self.state.week
|
||||
}
|
||||
|
||||
/// Collects the week's land and wealth taxes into the Treasury. `chunks`:
|
||||
/// claimed chunks by owner. Unpaid land tax becomes arrears; the wealth
|
||||
/// tax takes only what is there (it never exceeds the balance).
|
||||
pub fn collect(
|
||||
&mut self,
|
||||
economy: &mut Economy,
|
||||
chunks: &HashMap<u128, usize>,
|
||||
now: i64,
|
||||
) -> Vec<Out> {
|
||||
let this = week(now);
|
||||
if this <= self.state.week {
|
||||
return Vec::new();
|
||||
}
|
||||
self.state.week = this;
|
||||
self.dirty = true;
|
||||
let mut c = Collection {
|
||||
week: this,
|
||||
..Collection::default()
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
let mut owners: Vec<(u128, usize)> = chunks.iter().map(|(u, n)| (*u, *n)).collect();
|
||||
owners.sort();
|
||||
for (uuid, n) in owners {
|
||||
let key = uuid_string(uuid);
|
||||
let owed = self.land_bill(n) + self.arrears(uuid);
|
||||
if owed <= 0 {
|
||||
continue;
|
||||
}
|
||||
let paid = owed.min(economy.balance(uuid));
|
||||
if paid > 0
|
||||
&& economy
|
||||
.to_pool("land_tax", uuid, TREASURY, paid, "")
|
||||
.is_err()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
c.land += paid;
|
||||
c.payers += 1;
|
||||
let left = owed - paid;
|
||||
if left > 0 {
|
||||
c.unpaid += left;
|
||||
self.state.arrears.insert(key, left);
|
||||
out.push(Out::Tell(
|
||||
uuid,
|
||||
format!(
|
||||
"Land tax: you paid {} and still owe {}. No new claim until it's paid (/eco taxes pay).",
|
||||
economy.money(paid),
|
||||
economy.money(left)
|
||||
),
|
||||
));
|
||||
} else {
|
||||
self.state.arrears.remove(&key);
|
||||
out.push(Out::Tell(
|
||||
uuid,
|
||||
format!("Land tax for {n} chunks: {}.", economy.money(paid)),
|
||||
));
|
||||
}
|
||||
}
|
||||
for (uuid, _, balance) in economy.accounts() {
|
||||
let saved = self.savings.get(&uuid).copied().unwrap_or(0);
|
||||
let bill = self.wealth_bill(balance.saturating_add(saved)).min(balance);
|
||||
if bill > 0
|
||||
&& economy
|
||||
.to_pool("wealth_tax", uuid, TREASURY, bill, "")
|
||||
.is_ok()
|
||||
{
|
||||
c.wealth += bill;
|
||||
out.push(Out::Tell(
|
||||
uuid,
|
||||
format!("Wealth tax this week: {}.", economy.money(bill)),
|
||||
));
|
||||
}
|
||||
}
|
||||
out.push(Out::All(format!(
|
||||
"Weekly taxes collected: land {}, wealth {}. Treasury: {}.",
|
||||
economy.money(c.land),
|
||||
economy.money(c.wealth),
|
||||
economy.money(economy.pool(TREASURY))
|
||||
)));
|
||||
self.state.last = Some(c);
|
||||
out
|
||||
}
|
||||
|
||||
/// Runs `/treasury ...` and `/eco taxes ...`; None for another command.
|
||||
/// `my_chunks`: the chunks `me` holds.
|
||||
pub fn command(
|
||||
&mut self,
|
||||
economy: &mut Economy,
|
||||
me: &Player,
|
||||
my_chunks: usize,
|
||||
line: &str,
|
||||
) -> Option<Vec<Out>> {
|
||||
let words: Vec<&str> = line.split_whitespace().collect();
|
||||
Some(match words.as_slice() {
|
||||
["treasury"] => self.treasury(economy),
|
||||
["treasury", "log"] => self.log(economy),
|
||||
["treasury", "pay", who, amount, reason @ ..] => {
|
||||
self.pay(economy, me, who, amount, &reason.join(" "))
|
||||
}
|
||||
["treasury", ..] => vec![Out::Error(
|
||||
"Usage: /treasury, /treasury log, /treasury pay <player> <amount> <reason>".into(),
|
||||
)],
|
||||
["eco" | "taxes", "taxes"] | ["taxes"] => self.explain(economy, me, my_chunks),
|
||||
["eco" | "taxes", "taxes", "pay"] | ["taxes", "pay"] => self.pay_arrears(economy, me),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
fn treasury(&self, economy: &Economy) -> Vec<Out> {
|
||||
let t = economy.treasury();
|
||||
let list = |m: &BTreeMap<String, i64>| -> String {
|
||||
let mut v: Vec<(&String, &i64)> = m.iter().collect();
|
||||
v.sort_by(|a, b| b.1.cmp(a.1).then(a.0.cmp(b.0)));
|
||||
let parts: Vec<String> = v
|
||||
.iter()
|
||||
.map(|(k, a)| format!("{} {}", k.replace('_', " "), economy.money(**a)))
|
||||
.collect();
|
||||
if parts.is_empty() {
|
||||
"nothing".into()
|
||||
} else {
|
||||
parts.join(", ")
|
||||
}
|
||||
};
|
||||
let today = t.day == economy.now.div_euclid(DAY_MS);
|
||||
let empty = BTreeMap::new();
|
||||
let (inc, out) = if today {
|
||||
(&t.income_today, &t.spent_today)
|
||||
} else {
|
||||
(&empty, &empty)
|
||||
};
|
||||
let mut lines = vec![
|
||||
Out::Reply(format!(
|
||||
"Treasury: {} (insurance fund {}). Taxes, fees and house edges go in; grants, refunds and prizes come out. Nobody can make money.",
|
||||
economy.money(economy.pool(TREASURY)),
|
||||
economy.money(economy.pool(INSURANCE))
|
||||
)),
|
||||
Out::Reply(format!("Today in: {}. Out: {}.", list(inc), list(out))),
|
||||
Out::Reply(format!(
|
||||
"Since it opened, in: {}. Out: {}.",
|
||||
list(&t.income),
|
||||
list(&t.spent)
|
||||
)),
|
||||
];
|
||||
if let Some(c) = &self.state.last {
|
||||
lines.push(Out::Reply(format!(
|
||||
"Last weekly taxes: land {}, wealth {}, {} unpaid.",
|
||||
economy.money(c.land),
|
||||
economy.money(c.wealth),
|
||||
economy.money(c.unpaid)
|
||||
)));
|
||||
}
|
||||
lines.push(Out::Reply(
|
||||
"Every payment: /treasury log. The rates: /eco taxes.".into(),
|
||||
));
|
||||
lines
|
||||
}
|
||||
|
||||
fn log(&self, economy: &Economy) -> Vec<Out> {
|
||||
let t = economy.treasury();
|
||||
if t.log.is_empty() {
|
||||
return vec![Out::Reply("The Treasury hasn't moved yet.".into())];
|
||||
}
|
||||
let mut out = vec![Out::Reply(
|
||||
"The Treasury's last movements (newest first):".into(),
|
||||
)];
|
||||
for m in t.log.iter().rev().take(10) {
|
||||
let sign = if m.amount >= 0 { "+" } else { "-" };
|
||||
let mut line = format!(
|
||||
"{} ago: {sign}{} {}",
|
||||
span(economy.now - m.time),
|
||||
economy.money(m.amount.abs()),
|
||||
m.kind.replace('_', " ")
|
||||
);
|
||||
if !m.player.is_empty() {
|
||||
line.push_str(&format!(" ({})", m.player));
|
||||
}
|
||||
if !m.reason.is_empty() {
|
||||
line.push_str(&format!(": {}", m.reason));
|
||||
}
|
||||
if !m.by.is_empty() {
|
||||
line.push_str(&format!(", by {}", m.by));
|
||||
}
|
||||
out.push(Out::Reply(line));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `/treasury pay`: an operator pays a player from the Treasury, with a
|
||||
/// reason everyone sees.
|
||||
fn pay(
|
||||
&mut self,
|
||||
economy: &mut Economy,
|
||||
me: &Player,
|
||||
who: &str,
|
||||
amount: &str,
|
||||
reason: &str,
|
||||
) -> Vec<Out> {
|
||||
if me.level < 3 {
|
||||
return vec![Out::Error(
|
||||
"Only operators of level 3 may pay from the Treasury.".into(),
|
||||
)];
|
||||
}
|
||||
let Some(amount) = parse_amount(amount) else {
|
||||
return vec![Out::Error(
|
||||
"The amount must be a positive whole number.".into(),
|
||||
)];
|
||||
};
|
||||
let reason = reason.trim();
|
||||
if reason.chars().count() < 3 {
|
||||
return vec![Out::Error(
|
||||
"Say why: /treasury pay <player> <amount> <reason> (an event prize, a bug refund...)".into(),
|
||||
)];
|
||||
}
|
||||
let Some((uuid, account)) = economy.find(who) else {
|
||||
return vec![Out::Error(format!("No player named {who} has an account."))];
|
||||
};
|
||||
let name = account.name.clone();
|
||||
match economy.out_of_pool(
|
||||
"grant",
|
||||
TREASURY,
|
||||
uuid,
|
||||
amount,
|
||||
reason,
|
||||
&uuid_string(me.uuid),
|
||||
) {
|
||||
Ok(()) => vec![Out::All(format!(
|
||||
"The Treasury paid {} to {name}: {reason} (by {}). Treasury: {}.",
|
||||
economy.money(amount),
|
||||
me.name,
|
||||
economy.money(economy.pool(TREASURY))
|
||||
))],
|
||||
Err(e) => vec![Out::Error(e)],
|
||||
}
|
||||
}
|
||||
|
||||
/// `/eco taxes`: the tax code, and the player's own bill.
|
||||
fn explain(&self, economy: &Economy, me: &Player, chunks: usize) -> Vec<Out> {
|
||||
let c = &self.config;
|
||||
let mut brackets = c.wealth_brackets.clone();
|
||||
brackets.sort();
|
||||
let wealth: Vec<String> = brackets
|
||||
.iter()
|
||||
.map(|[from, p]| format!("{p}% above {}", economy.money(*from)))
|
||||
.collect();
|
||||
let next = week_start(self.state.week + 1) - economy.now;
|
||||
let land = self.land_bill(chunks);
|
||||
let saved = self.savings.get(&me.uuid).copied().unwrap_or(0);
|
||||
let wealth_bill = self.wealth_bill(economy.balance(me.uuid).saturating_add(saved));
|
||||
let mut out = vec![
|
||||
Out::Reply("Taxes: all of them go to the Treasury (/treasury), which pays grants, prizes and refunds.".into()),
|
||||
Out::Reply(format!(
|
||||
"VAT: {}% of each auction house sale, paid by the seller.",
|
||||
c.vat_percent
|
||||
)),
|
||||
Out::Reply(format!(
|
||||
"Land tax, weekly: {} a claimed chunk past your first {}. Unpaid tax stops new claims.",
|
||||
economy.money(c.land_tax_per_chunk),
|
||||
c.land_free_chunks
|
||||
)),
|
||||
Out::Reply(format!("Wealth tax, weekly, on your balance and bank savings: {}.", wealth.join(", "))),
|
||||
Out::Reply("Games of chance: the house's fee goes to the Treasury.".into()),
|
||||
];
|
||||
if !c.enabled {
|
||||
out.push(Out::Reply(
|
||||
"The weekly taxes are off on this server.".into(),
|
||||
));
|
||||
} else {
|
||||
out.push(Out::Reply(format!(
|
||||
"Your bill on Monday (in {}): land {} ({chunks} chunks), wealth {}{}.",
|
||||
span(next),
|
||||
economy.money(land),
|
||||
economy.money(wealth_bill),
|
||||
match self.arrears(me.uuid) {
|
||||
0 => String::new(),
|
||||
a => format!(", owed {}", economy.money(a)),
|
||||
}
|
||||
)));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `/eco taxes pay`: pays the land tax owed, as much as the balance
|
||||
/// allows.
|
||||
fn pay_arrears(&mut self, economy: &mut Economy, me: &Player) -> Vec<Out> {
|
||||
let owed = self.arrears(me.uuid);
|
||||
if owed == 0 {
|
||||
return vec![Out::Reply("You owe no tax.".into())];
|
||||
}
|
||||
let paid = owed.min(economy.balance(me.uuid));
|
||||
if paid <= 0 {
|
||||
return vec![Out::Error(format!(
|
||||
"You owe {} and have nothing to pay it with.",
|
||||
economy.money(owed)
|
||||
))];
|
||||
}
|
||||
if let Err(e) = economy.to_pool("land_tax", me.uuid, TREASURY, paid, "arrears") {
|
||||
return vec![Out::Error(e)];
|
||||
}
|
||||
self.dirty = true;
|
||||
let key = uuid_string(me.uuid);
|
||||
if paid == owed {
|
||||
self.state.arrears.remove(&key);
|
||||
vec![Out::Reply(format!(
|
||||
"Paid {} of land tax: you owe nothing.",
|
||||
economy.money(paid)
|
||||
))]
|
||||
} else {
|
||||
self.state.arrears.insert(key, owed - paid);
|
||||
vec![Out::Reply(format!(
|
||||
"Paid {}; you still owe {}.",
|
||||
economy.money(paid),
|
||||
economy.money(owed - paid)
|
||||
))]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::smp_economy::Config as EcoConfig;
|
||||
use std::fs;
|
||||
|
||||
const ALEX: u128 = 1;
|
||||
const STEVE: u128 = 2;
|
||||
const RICH: u128 = 3;
|
||||
/// Monday 2026-11-02 00:00 UTC.
|
||||
const MONDAY: i64 = 1_793_577_600_000;
|
||||
|
||||
fn setup(name: &str) -> (Economy, Taxes, std::path::PathBuf) {
|
||||
let dir = std::env::temp_dir().join(format!("smp-tax-{name}-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
let mut e = Economy::open(&dir, EcoConfig::default()).unwrap();
|
||||
e.now = MONDAY - 3_600_000;
|
||||
for (u, n) in [(ALEX, "Alex"), (STEVE, "Steve"), (RICH, "Rich")] {
|
||||
e.seen(u, n);
|
||||
}
|
||||
e.adjust("sell", ALEX, 1_000, "").unwrap();
|
||||
e.adjust("sell", STEVE, 20, "").unwrap();
|
||||
e.adjust("sell", RICH, 300_000, "").unwrap();
|
||||
let t = Taxes::open(&e, Config::default());
|
||||
(e, t, dir)
|
||||
}
|
||||
|
||||
fn me(uuid: u128, level: u8) -> Player {
|
||||
Player {
|
||||
uuid,
|
||||
name: "x".into(),
|
||||
level,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn weeks_start_on_monday() {
|
||||
assert_eq!(week(MONDAY) - week(MONDAY - 1), 1);
|
||||
assert_eq!(week_start(week(MONDAY)), MONDAY);
|
||||
assert_eq!(week(MONDAY + 6 * DAY_MS), week(MONDAY));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bills_are_progressive() {
|
||||
let t = Taxes {
|
||||
config: Config::default(),
|
||||
state: State::default(),
|
||||
dirty: false,
|
||||
savings: HashMap::new(),
|
||||
};
|
||||
assert_eq!(t.land_bill(4), 0);
|
||||
assert_eq!(t.land_bill(16), 60);
|
||||
assert_eq!(t.wealth_bill(50_000), 0);
|
||||
assert_eq!(t.wealth_bill(60_000), 100);
|
||||
// 1% of 200k + 2% of 50k.
|
||||
assert_eq!(t.wealth_bill(300_000), 3_000);
|
||||
// 1% of 200k + 2% of 750k + 3% of 1M.
|
||||
assert_eq!(t.wealth_bill(2_000_000), 2_000 + 15_000 + 30_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bank_savings_count_for_the_wealth_tax() {
|
||||
let (mut e, mut t, _dir) = setup("savings");
|
||||
// Alex: 1,000 in the wallet, 59,000 saved: taxed on 60,000.
|
||||
t.savings.insert(ALEX, 59_000);
|
||||
let now = MONDAY;
|
||||
t.collect(&mut e, &HashMap::new(), now);
|
||||
assert_eq!(e.balance(ALEX), 900);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn monday_collects_once_into_the_treasury_and_unpaid_land_tax_blocks_claims() {
|
||||
let (mut e, mut t, dir) = setup("collect");
|
||||
let chunks = HashMap::from([(ALEX, 10usize), (STEVE, 14), (RICH, 2)]);
|
||||
assert!(!t.due(e.now), "not before Monday");
|
||||
assert!(
|
||||
{
|
||||
let now = e.now;
|
||||
t.collect(&mut e, &chunks, now)
|
||||
}
|
||||
.is_empty()
|
||||
);
|
||||
let total =
|
||||
|e: &Economy| e.balance(ALEX) + e.balance(STEVE) + e.balance(RICH) + e.pool(TREASURY);
|
||||
let before = total(&e);
|
||||
e.now = MONDAY + 1000;
|
||||
assert!(t.due(e.now));
|
||||
let out = {
|
||||
let now = e.now;
|
||||
t.collect(&mut e, &chunks, now)
|
||||
};
|
||||
assert!(matches!(out.last(), Some(Out::All(_))));
|
||||
// Alex 6 chunks taxed (30), Steve 10 (50, has 20), Rich wealth 3,000.
|
||||
assert_eq!(
|
||||
(e.balance(ALEX), e.balance(STEVE), e.balance(RICH)),
|
||||
(970, 0, 297_000)
|
||||
);
|
||||
assert_eq!(e.pool(TREASURY), 30 + 20 + 3_000);
|
||||
assert_eq!(total(&e), before, "taxes move money, never make or burn it");
|
||||
assert_eq!(t.arrears(STEVE), 30);
|
||||
assert!(t.may_claim(&e, STEVE).is_err() && t.may_claim(&e, ALEX).is_ok());
|
||||
// Twice the same week: nothing.
|
||||
assert!(!t.due(e.now));
|
||||
assert!(
|
||||
{
|
||||
let now = e.now + 1000;
|
||||
t.collect(&mut e, &chunks, now)
|
||||
}
|
||||
.is_empty()
|
||||
);
|
||||
// Steve earns and pays what he owes.
|
||||
e.adjust("sell", STEVE, 100, "").unwrap();
|
||||
let out = t
|
||||
.command(&mut e, &me(STEVE, 0), 14, "eco taxes pay")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
out,
|
||||
[Out::Reply(
|
||||
"Paid 30 coins of land tax: you owe nothing.".into()
|
||||
)]
|
||||
);
|
||||
assert!(t.may_claim(&e, STEVE).is_ok());
|
||||
let tr = e.treasury();
|
||||
assert_eq!(
|
||||
(tr.income["land_tax"], tr.income["wealth_tax"]),
|
||||
(80, 3_000)
|
||||
);
|
||||
// The state survives a restart with the balances.
|
||||
t.store(&mut e);
|
||||
e.save().unwrap();
|
||||
let mut again = Economy::open(&dir, EcoConfig::default()).unwrap();
|
||||
again.now = e.now;
|
||||
let t2 = Taxes::open(&again, Config::default());
|
||||
assert_eq!(t2.state.week, week(MONDAY));
|
||||
assert!(!t2.due(again.now), "a restart doesn't collect again");
|
||||
fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_operators_pay_from_the_treasury_and_never_more_than_it_holds() {
|
||||
let (mut e, mut t, dir) = setup("pay");
|
||||
e.to_pool("vat", RICH, TREASURY, 500, "").unwrap();
|
||||
let steve = me(STEVE, 0);
|
||||
assert!(matches!(
|
||||
&t.command(&mut e, &steve, 0, "treasury pay Steve 10 because")
|
||||
.unwrap()[0],
|
||||
Out::Error(_)
|
||||
));
|
||||
let op = Player {
|
||||
uuid: ALEX,
|
||||
name: "Alex".into(),
|
||||
level: 3,
|
||||
};
|
||||
assert!(
|
||||
matches!(&t.command(&mut e, &op, 0, "treasury pay Steve 10").unwrap()[0], Out::Error(s) if s.starts_with("Say why"))
|
||||
);
|
||||
assert!(matches!(
|
||||
&t.command(&mut e, &op, 0, "treasury pay Steve 501 too much")
|
||||
.unwrap()[0],
|
||||
Out::Error(_)
|
||||
));
|
||||
assert_eq!(
|
||||
t.command(&mut e, &op, 0, "treasury pay Steve 200 bug refund #12").unwrap(),
|
||||
[Out::All("The Treasury paid 200 coins to Steve: bug refund #12 (by Alex). Treasury: 300 coins.".into())]
|
||||
);
|
||||
assert_eq!(e.balance(STEVE), 220);
|
||||
let log = t.command(&mut e, &steve, 0, "treasury log").unwrap();
|
||||
assert!(
|
||||
matches!(&log[1], Out::Reply(s) if s.contains("-200 coins grant (Steve): bug refund #12, by Alex")),
|
||||
"{log:?}"
|
||||
);
|
||||
let shown = t.command(&mut e, &steve, 0, "treasury").unwrap();
|
||||
assert!(matches!(&shown[0], Out::Reply(s) if s.starts_with("Treasury: 300 coins")));
|
||||
let taxes = t.command(&mut e, &steve, 16, "eco taxes").unwrap();
|
||||
assert!(
|
||||
taxes
|
||||
.iter()
|
||||
.any(|o| matches!(o, Out::Reply(s) if s.contains("land 60 coins (16 chunks)"))),
|
||||
"{taxes:?}"
|
||||
);
|
||||
fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
}
|
||||
+39
-55
@@ -3,9 +3,8 @@ Steve, `horizon-ctl --offscreen`) join it. Checked from the clients' own chat an
|
||||
|
||||
- the economy opens an account for each player (config.json written with its defaults, then economy.json);
|
||||
- /balance, /pay (the payer's answer and the payee's line), refused payments (too much, past what the payee may receive today, to oneself, unknown name);
|
||||
- the Treasury: an operator (made one from the server's console) pays a player from it with a reason (/treasury pay),
|
||||
which everyone sees in /treasury and /treasury log; /eco give is refused to everyone (nobody can make money), and a
|
||||
non-operator can't pay from the Treasury; /eco taxes explains the tax code;
|
||||
- money from an operator (made one from the server's console): /eco give with a reason everyone reads; a
|
||||
non-operator can't; /treasury, /bank, /jobs, /futures and /eco taxes are gone, and the old Treasury pool is dropped;
|
||||
- /baltop;
|
||||
- the commands are in each player's command tree (the chat box's suggestions);
|
||||
- the market (Steve in survival, items given from the console): /worth, /sell <item>, /sell hand, /buy, with the
|
||||
@@ -19,10 +18,10 @@ Steve, `horizon-ctl --offscreen`) join it. Checked from the clients' own chat an
|
||||
- the lottery: two players' tickets held, one drawn after `lottery_seconds`, the winner announced to everyone and paid
|
||||
the pot;
|
||||
- after the server stops cleanly and starts again, the balances are the same, and audit.log holds every change;
|
||||
- a Treasury payment for an operator, then the /shop menu: a category, one item bought by a click and a stack by
|
||||
- an operator's /eco give, then the /shop menu: a category, one item bought by a click and a stack by
|
||||
a shift-click, a stack of the player's own sold by clicking it, "Sell all" stopped by the daily limit (the rest
|
||||
kept, the limit shown in the menu and in /worth, the day's sales in market.json), the close button;
|
||||
- after the restart, prices moving: a purchase and a sale taxed (receipts, the Treasury), an item bought can't be sold
|
||||
- after the restart, prices moving: a purchase and a sale with no tax (receipts), an item bought can't be sold
|
||||
back while one found can, one price for diamond, moved down by a sale and up by a purchase as they go, /market's item
|
||||
page buying, the 24 h chart given as a map and drawn in the player's hand (market-chart.png), web/market.json, and
|
||||
market_prices.json written when the world closes;
|
||||
@@ -603,16 +602,14 @@ def nicks(server, alex, steve, out):
|
||||
|
||||
|
||||
def shop(server, alex):
|
||||
"""Alex alone, op, in survival: paid from the Treasury (/eco give refused), then the /shop menu: a category, buying one and a stack,
|
||||
"""Alex alone, op, in survival: given money (/eco give), then the /shop menu: a category, buying one and a stack,
|
||||
selling a stack of hers, "Sell all" up to the daily limit, closing. Returns the coins it added, and the lines."""
|
||||
results = {}
|
||||
# Only what the step gives her: "Sell all" would sell the rest too.
|
||||
server.console("clear Alex")
|
||||
time.sleep(1)
|
||||
results["money"] = alex.say("/treasury pay Alex 2010 proof: shop funds")
|
||||
assert any("The Treasury paid 2,010 coins to Alex: proof: shop funds (by Alex)." in l for l in results["money"]), results
|
||||
results["no_printing"] = alex.say("/eco give Alex 10")
|
||||
assert any("Nobody can make money" in l for l in results["no_printing"]), results
|
||||
results["money"] = alex.say("/eco give Alex 2010 proof: shop funds")
|
||||
assert any("Alex gave 2,010 coins to Alex: proof: shop funds." in l for l in results["money"]), results
|
||||
assert alex.count("minecraft:iron_ingot") == 0
|
||||
alex.say("/shop")
|
||||
menu = alex.menu()
|
||||
@@ -624,17 +621,17 @@ def shop(server, alex):
|
||||
alex.click(300 + 2) # one iron ingot
|
||||
assert alex.count("minecraft:iron_ingot") == 1, alex.run("inventory")
|
||||
results["bought"] = alex.chat()[-3:]
|
||||
# One price, 15 (2% of it rounds to no tax).
|
||||
# One price, 15.
|
||||
assert any("Bought 1 iron ingot at 15 coins each: 15 coins." in l for l in results["bought"]), results
|
||||
alex.click(300 + 45) # back
|
||||
alex.click(300 + 14) # Building
|
||||
alex.run("click-slot", 300 + 0, "shift") # a stack of cobblestone
|
||||
time.sleep(1)
|
||||
assert alex.count("minecraft:cobblestone") == 64, alex.run("inventory")
|
||||
# Along the price as the stack moves it: 64 at 1 coin come to 64.05, 65 whole coins, + 1 tax.
|
||||
# Along the price as the stack moves it: 64 at 1 coin come to 64.05, 65 whole coins.
|
||||
results["stack"] = alex.chat()[-4:] # an advancement may come after
|
||||
assert any("Bought 64 cobblestone at 1.02 coins each: 65 coins." in l for l in results["stack"]), results
|
||||
assert any("Tax 2%: -1 coins. You paid 66 coins." in l for l in results["stack"]), results
|
||||
assert not any("Tax" in l for l in results["stack"]), results
|
||||
# Her own stack of iron, clicked: the ingot she just bought stays (no buying to sell back), one she found sells.
|
||||
alex.click(alex.slot_of("minecraft:iron_ingot"))
|
||||
results["round_trip"] = alex.chat()[-1:]
|
||||
@@ -655,7 +652,7 @@ def shop(server, alex):
|
||||
alex.click(300 + 49)
|
||||
results["sell_all"] = alex.chat()[-8:]
|
||||
assert any("Sold 9 diamond at 196 coins each: 1,760 coins." in l for l in results["sell_all"]), results
|
||||
assert any("Tax 2%: -35 coins. You get 1,725 coins." in l for l in results["sell_all"]), results
|
||||
assert not any("Tax" in l for l in results["sell_all"]), results
|
||||
assert any("21 diamond kept: today's selling limit." in l for l in results["sell_all"]), results
|
||||
assert alex.count("minecraft:diamond") == 21, alex.run("inventory")
|
||||
limits = alex.menu()[50]
|
||||
@@ -666,8 +663,7 @@ def shop(server, alex):
|
||||
assert not alex.menu()
|
||||
results["worth"] = alex.say("/worth diamond")
|
||||
assert any("You may sell 631 more diamond today." in l for l in results["worth"]), results
|
||||
# The tax: 1 on the cobblestone, 35 on the diamonds, to the Treasury.
|
||||
return 2000 + 10 - 15 - 65 - 1 + 15 + 1760 - 35, results
|
||||
return 2000 + 10 - 15 - 65 + 15 + 1760, results
|
||||
|
||||
|
||||
def invsee(alex, data):
|
||||
@@ -725,9 +721,8 @@ def market(server, alex, out, data):
|
||||
results["bought"] = alex.chat()[-4:]
|
||||
# One diamond moves the price 0.5% as it is bought: 191.7, 192 whole coins.
|
||||
paid = 192
|
||||
tax = (paid * 200 + 5000) // 10000
|
||||
assert any(f"Bought 1 diamond at {paid} coins each: {paid} coins." in l for l in results["bought"]), results
|
||||
assert any(f"Tax 2%: -{tax} coins. You paid {paid + tax} coins." in l for l in results["bought"]), results
|
||||
assert not any("Tax" in l for l in results["bought"]), results
|
||||
alex.click(300 + 39) # the 24 h chart
|
||||
assert alex.count("minecraft:filled_map") == 1, alex.run("inventory")
|
||||
alex.click(300 + 53) # close
|
||||
@@ -751,8 +746,7 @@ def market(server, alex, out, data):
|
||||
# One price, which buying and selling both use.
|
||||
assert 190 < diamond["price"] < 200 and diamond["buy"] == diamond["sell"] == diamond["price"], results["web"]
|
||||
assert diamond["volume_24h"] == 10 and diamond["day"]["price"], results["web"]
|
||||
results["tax"] = tax
|
||||
return paid + tax, results
|
||||
return paid, results
|
||||
|
||||
|
||||
def trades(server, alex, steve, port, ctl, resources, root):
|
||||
@@ -855,23 +849,20 @@ def run(ctl, resources, out):
|
||||
data.mkdir(parents=True)
|
||||
(data / "config.json").write_text(json.dumps({"gambling": {"enabled": True, "cooldown_seconds": 20,
|
||||
"lottery_seconds": 30},
|
||||
# Free teleports and an instant reward: the sums below
|
||||
# stay as they were (fees and the reward's delay have
|
||||
# unit tests).
|
||||
"places": {"warmup_seconds": 2, "cooldown_seconds": 0,
|
||||
"fees": {"spawn": 0, "home": 0, "warp": 0,
|
||||
"back": 0, "tpa": 0}},
|
||||
# Quick teleports and an instant reward (the reward's
|
||||
# delay has unit tests).
|
||||
"places": {"warmup_seconds": 2, "cooldown_seconds": 0},
|
||||
# Paid once, after the restart, not to change the sums.
|
||||
"presence": {"daily_reward": 0, "reward_after_minutes": 0},
|
||||
# Steve plays a few minutes before his nickname.
|
||||
"nicks": {"min_playtime_minutes": 1},
|
||||
# Fixed prices, no tax, for the first run's sums; the
|
||||
# Fixed prices for the first run's sums; the
|
||||
# moving market is on after the restart.
|
||||
"market": {"dynamic": {"enabled": False}},
|
||||
# The casino's pot takes no float from the Treasury here (its
|
||||
# own proof, proof_casino.py, covers it): the sums stay.
|
||||
# The casino's pot gets no float here (its own proof,
|
||||
# proof_casino.py, covers it): the sums stay.
|
||||
"casino": {"float": 0}}))
|
||||
# A Treasury with money, the only place operators may pay from (nobody can make money).
|
||||
# A world from before 0.30, with a Treasury: its pool is dropped at the first start.
|
||||
(data / "economy.json").write_text(json.dumps({"accounts": {}, "pools": {"treasury": 5000}}))
|
||||
# A nickname an older version let through, taken off at the first start.
|
||||
(data / "nicks.json").write_text(json.dumps({"nicks": {"00000000-0000-0000-0000-00000000abcd": {
|
||||
@@ -885,23 +876,22 @@ def run(ctl, resources, out):
|
||||
time.sleep(2)
|
||||
config = json.loads((data / "config.json").read_text())
|
||||
results["config_keys"] = sorted(config)
|
||||
assert results["config_keys"] == ["auction", "bank", "board", "casino", "claims", "currency", "futures", "gambling", "jobs",
|
||||
"market", "nicks", "places", "presence", "report", "shop",
|
||||
"starting_balance", "taxes", "web"], config
|
||||
assert results["config_keys"] == ["auction", "board", "casino", "claims", "currency", "gambling", "market", "nicks", "places", "presence", "report", "shop",
|
||||
"starting_balance", "web"], config
|
||||
results["welcome"] = steve.chat()
|
||||
assert any("Welcome to the server, Steve!" in l for l in results["welcome"]), results["welcome"]
|
||||
assert any("1. Be respectful to everyone." in l for l in results["welcome"]), results["welcome"]
|
||||
assert any("Steve joined for the first time" in l for l in alex.chat()), alex.chat()
|
||||
assert config["gambling"] == {"enabled": True, "min_bet": 10, "max_bet": 1000, "cooldown_seconds": 20,
|
||||
"offer_seconds": 60, "fee_percent": 0, "ticket_price": 10,
|
||||
"offer_seconds": 60, "ticket_price": 10,
|
||||
"max_tickets": 100, "lottery_seconds": 30}, config
|
||||
# One price, to buy and to sell.
|
||||
assert config["market"]["items"]["minecraft:diamond"] == {"buy": 200, "sell": 200}, config
|
||||
server.console("op Alex")
|
||||
time.sleep(1)
|
||||
results["grant"] = alex.say("/treasury pay Alex 500 proof funding")
|
||||
assert any("The Treasury paid 500 coins to Alex: proof funding (by Alex). Treasury: 4,500 coins." in l
|
||||
for l in results["grant"]), results
|
||||
results["grant"] = alex.say("/eco give Alex 500 proof funding")
|
||||
assert any("Alex gave 500 coins to Alex: proof funding." in l for l in results["grant"]), results
|
||||
assert any("Alex gave 500 coins to Alex: proof funding." in l for l in steve.chat()[-3:]), "everyone reads it"
|
||||
results["balance"] = alex.say("/balance")
|
||||
assert any("Balance: 500 coins" in l for l in results["balance"]), results
|
||||
results["pay"] = alex.say("/pay Steve 120")
|
||||
@@ -917,17 +907,13 @@ def run(ctl, resources, out):
|
||||
"You can't pay yourself.",
|
||||
"No player named Nobody has an account.")):
|
||||
assert any(error in l for l in lines), results["refused"]
|
||||
results["eco_refused"] = steve.say("/eco give Steve 1000")
|
||||
assert not any("now has" in l for l in results["eco_refused"]), results
|
||||
results["treasury_refused"] = steve.say("/treasury pay Steve 1000 because")
|
||||
assert not any("The Treasury paid" in l for l in results["treasury_refused"]), results
|
||||
results["treasury"] = steve.say("/treasury")
|
||||
assert any("Treasury: 4,500 coins" in l for l in results["treasury"]), results
|
||||
assert any("grant 500 coins" in l for l in results["treasury"]), results
|
||||
results["treasury_log"] = steve.say("/treasury log")
|
||||
assert any("-500 coins grant (Alex): proof funding, by Alex" in l for l in results["treasury_log"]), results
|
||||
results["taxes"] = steve.say("/eco taxes")
|
||||
assert any("Land tax, weekly: 5 coins a claimed chunk past your first 4." in l for l in results["taxes"]), results
|
||||
results["eco_refused"] = steve.say("/eco give Steve 1000 because")
|
||||
assert not any("gave" in l for l in results["eco_refused"]), results
|
||||
# The parts taken out: their commands are gone.
|
||||
for line in ("/treasury", "/bank", "/jobs", "/futures", "/eco taxes"):
|
||||
results[line] = steve.say(line)
|
||||
refused = ("Unknown or incomplete command", "Incorrect argument", "Usage", "Only operators")
|
||||
assert any(any(r in l for r in refused) for l in results[line]), results
|
||||
results["baltop"] = steve.say("/baltop")
|
||||
assert any("1. Alex: 380 coins" in l for l in results["baltop"]), results
|
||||
assert any("2. Steve: 120 coins" in l for l in results["baltop"]), results
|
||||
@@ -985,7 +971,7 @@ def run(ctl, resources, out):
|
||||
assert balances["Alex"] == alex_after and sum(balances.values()) + held == 815, economy
|
||||
audit = [json.loads(l) for l in (data / "audit.log").read_text().splitlines()]
|
||||
results["audit"] = [a["kind"] for a in audit]
|
||||
assert results["audit"] == ["open", "open", "grant", "pay", "sell 60 minecraft:wheat",
|
||||
assert results["audit"] == ["open", "open", "eco_give", "pay", "sell 60 minecraft:wheat",
|
||||
"sell 1 minecraft:diamond", "buy 5 minecraft:torch", "trade", "trade_failed",
|
||||
"trade", "trade_money", "pay", "pay", "coinflip_stake", "coinflip_stake", "coinflip_win",
|
||||
"coinflip_stake", "lottery_ticket", "lottery_ticket", "lottery_win"] + (
|
||||
@@ -1027,16 +1013,14 @@ def run(ctl, resources, out):
|
||||
economy = json.loads((data / "economy.json").read_text())
|
||||
total = sum(a["balance"] for a in economy["accounts"].values())
|
||||
assert "held" not in economy and total == 815 + 7 + added, economy
|
||||
# Every coin an operator gave came out of the Treasury.
|
||||
# The market's tax went in: 36 in the shop, and on her diamond.
|
||||
assert economy["pools"]["treasury"] == 5000 - 500 - 2010 + 36 + results["market"]["tax"], economy["pools"]
|
||||
# The old Treasury pool is gone; no fee or tax took anything.
|
||||
assert "treasury" not in economy.get("pools", {}), economy.get("pools")
|
||||
audit = [json.loads(l) for l in (data / "audit.log").read_text().splitlines()]
|
||||
took = [a for a in audit if a["kind"] == "invsee_take"]
|
||||
assert [(a["item"], a["count"], a["inventory"]) for a in took] == [("minecraft:torch", 5, "inventory")], took
|
||||
kinds = [a["kind"] for a in audit if not a["kind"].startswith("invsee_")]
|
||||
shop_kinds = ["grant", "buy 1 minecraft:iron_ingot", "buy 64 minecraft:cobblestone", "market_tax",
|
||||
"sell 1 minecraft:iron_ingot", "sell 9 minecraft:diamond", "market_tax",
|
||||
"buy 1 minecraft:diamond", "market_tax"]
|
||||
shop_kinds = ["eco_give", "buy 1 minecraft:iron_ingot", "buy 64 minecraft:cobblestone",
|
||||
"sell 1 minecraft:iron_ingot", "sell 9 minecraft:diamond", "buy 1 minecraft:diamond"]
|
||||
assert kinds[-len(shop_kinds):] == shop_kinds, kinds[-8:]
|
||||
kinds = kinds[:-len(shop_kinds)]
|
||||
assert kinds[-2] in ("held_returned", "coinflip_cancel") and kinds[-1] == "daily_reward", kinds[-3:]
|
||||
|
||||
@@ -5,7 +5,7 @@ Run it with `SMP_PROOF=casino python3 native/tools/native.py proof --resources P
|
||||
|
||||
Checked from the clients' own menus, chat, titles and action bar, and from the files the server wrote:
|
||||
|
||||
- the pot is funded from the Treasury at the first start (no coin made), and `/casino help` shows every game's odds;
|
||||
- the pot gets its float at the first start (made once), and `/casino help` shows every game's odds;
|
||||
- `/casino` opens the lobby (its title, the four games, the pot); a click opens a game;
|
||||
- roulette: a bet on Red, the stake raised, the wheel turns (several different pictures while it spins), the round
|
||||
is paid and the table shows the result; a second spin with the menu closed mid-spin is still paid, once;
|
||||
@@ -15,7 +15,7 @@ Checked from the clients' own menus, chat, titles and action bar, and from the f
|
||||
- blackjack: a hand dealt and stood (or settled at once on a blackjack);
|
||||
- the bet limits: the most is `max_bet`;
|
||||
- after the server stops: every bet held is settled exactly once in audit.log, nothing is left held, and the money
|
||||
in the game (balances + pools) is what the Treasury held at the start; casino.json holds the day's totals; no
|
||||
in the game (balances + pools) is the float and the operator's two `/eco give`s; casino.json holds the day's totals; no
|
||||
console command the casino ran was refused.
|
||||
"""
|
||||
import importlib.util
|
||||
@@ -31,7 +31,8 @@ _spec = importlib.util.spec_from_file_location("smp_proof_harness", Path(__file_
|
||||
harness = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(harness)
|
||||
|
||||
TREASURY = 50_000
|
||||
FLOAT = 20_000
|
||||
GIVEN = 2 * 3000
|
||||
NAME = re.compile(r'"?text"?\s*:\s*"((?:[^"\\]|\\.)*)"')
|
||||
GAMES = ("casino_roulette", "casino_slots", "casino_crash", "casino_blackjack")
|
||||
|
||||
@@ -225,8 +226,7 @@ def run(ctl, resources, out):
|
||||
data.mkdir(parents=True)
|
||||
(data / "config.json").write_text(json.dumps({
|
||||
"gambling": {"enabled": True}, "starting_balance": 0, "presence": {"daily_reward": 0},
|
||||
"casino": {"cooldown_ms": 500, "float": 20_000}}))
|
||||
(data / "economy.json").write_text(json.dumps({"accounts": {}, "pools": {"treasury": TREASURY}}))
|
||||
"casino": {"cooldown_ms": 500, "float": FLOAT}}))
|
||||
server.start()
|
||||
for line in ("difficulty peaceful", "gamerule spawn_monsters false"):
|
||||
server.console(line)
|
||||
@@ -235,7 +235,7 @@ def run(ctl, resources, out):
|
||||
server.console("op Alex")
|
||||
time.sleep(2)
|
||||
for who in ("Alex", "Steve"):
|
||||
assert any("The Treasury paid" in l for l in alex.say(f"/treasury pay {who} 3000 casino proof"))
|
||||
assert any(f"Alex gave 3,000 coins to {who}: casino proof." in l for l in alex.say(f"/eco give {who} 3000 casino proof"))
|
||||
results["help"] = alex.say("/casino help")
|
||||
text = "\n".join(results["help"])
|
||||
for want in ("bets 10 coins to 1,000 coins", "pot (20,000 coins now)", "European", "94.2%", "96%", "3 to 2"):
|
||||
@@ -273,7 +273,7 @@ def run(ctl, resources, out):
|
||||
results["economy"] = economy
|
||||
held = sum(economy.get("held", {}).values())
|
||||
money = sum(a["balance"] for a in economy["accounts"].values()) + held + sum(economy["pools"].values())
|
||||
assert held == 0 and money == TREASURY, economy
|
||||
assert held == 0 and money == FLOAT + GIVEN, economy
|
||||
ledger = json.loads((data / "casino.json").read_text())
|
||||
results["casino.json"] = ledger
|
||||
assert sum(t["rounds"] for t in ledger["totals"].values()) == 7, ledger
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
//! `super::smp_store` reaches the same code in both.
|
||||
#[path = "../mixins/smp/smp_auction.rs"]
|
||||
pub mod smp_auction;
|
||||
#[path = "../mixins/smp/smp_bank.rs"]
|
||||
pub mod smp_bank;
|
||||
#[path = "../mixins/smp/smp_board.rs"]
|
||||
pub mod smp_board;
|
||||
#[path = "../mixins/smp/smp_casino.rs"]
|
||||
@@ -21,14 +19,10 @@ pub mod smp_casino_slots;
|
||||
pub mod smp_claims;
|
||||
#[path = "../mixins/smp/smp_economy.rs"]
|
||||
pub mod smp_economy;
|
||||
#[path = "../mixins/smp/smp_futures.rs"]
|
||||
pub mod smp_futures;
|
||||
#[path = "../mixins/smp/smp_games.rs"]
|
||||
pub mod smp_games;
|
||||
#[path = "../mixins/smp/smp_invsee.rs"]
|
||||
pub mod smp_invsee;
|
||||
#[path = "../mixins/smp/smp_jobs.rs"]
|
||||
pub mod smp_jobs;
|
||||
#[path = "../mixins/smp/smp_lottery.rs"]
|
||||
pub mod smp_lottery;
|
||||
#[path = "../mixins/smp/smp_market.rs"]
|
||||
@@ -53,7 +47,5 @@ pub mod smp_report;
|
||||
pub mod smp_shop;
|
||||
#[path = "../mixins/smp/smp_store.rs"]
|
||||
pub mod smp_store;
|
||||
#[path = "../mixins/smp/smp_tax.rs"]
|
||||
pub mod smp_tax;
|
||||
#[path = "../mixins/smp/smp_trade.rs"]
|
||||
pub mod smp_trade;
|
||||
Reference in new issue
Block a user