mirror of
https://github.com/horizon-rust/horizon-mods.git
synced 2026-11-07 11:05:58 -10:00
Compare commits
4
Commits
main
...
deploy/r6-mods
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1db37be21e | ||
|
|
f22d1edc78 | ||
|
|
ad74e43a70 | ||
|
|
f5696b3e90 |
No files matched your search
@@ -78,7 +78,9 @@ jobs:
|
||||
names = zipfile.ZipFile(path).namelist()
|
||||
manifest = json.loads(zipfile.ZipFile(path).read("horizon-native.json"))
|
||||
assert manifest["hooks"] >= 1, manifest
|
||||
assert all(n == "horizon-native.json" or (n.startswith(f"mixins/{manifest['id']}/") and n.endswith((".rs", ".toml"))) for n in names), names
|
||||
bundle = f"mixins/{manifest['id']}/"
|
||||
assert all(n == "horizon-native.json" or n == bundle + "LICENSE" or (n.startswith(bundle) and n.endswith((".rs", ".toml"))) for n in names), names
|
||||
assert bundle + "LICENSE" in names, "the mod's LICENSE travels in its package"
|
||||
print(path, manifest)
|
||||
PY
|
||||
- uses: actions/upload-artifact@v4
|
||||
|
||||
@@ -83,7 +83,8 @@ The index: `https://horizon-rust.github.io/horizon-mods/index.json`.
|
||||
[horizon-mixins](https://github.com/horizon-rust/horizon-mixins) bundles on Horizon's native hook points
|
||||
([docs/native-mods.md](https://github.com/horizon-rust/horizon/blob/main/docs/native-mods.md)). Each folder keeps the
|
||||
history of the repository it came from. A mod builds on any Horizon whose hook points are at least the version its
|
||||
`compatibility.json` names; `horizon_revision` is the commit it was last tested on.
|
||||
`compatibility.json` names; `horizon_revision` is the commit it was last tested on. `smp` and `voicechat-server`
|
||||
are server mods, built into the official server's engine (see their READMEs).
|
||||
|
||||
```sh
|
||||
python3 native/tools/native.py check zoom # apply on the tested Horizon and cargo check the game
|
||||
|
||||
@@ -4,5 +4,5 @@
|
||||
"horizon_repository": "https://github.com/horizon-rust/horizon",
|
||||
"horizon_revision": "4baa571566e39bfd6594988d3bb2bc85440c30e7",
|
||||
"horizon_mixins_repository": "https://github.com/horizon-rust/horizon-mixins",
|
||||
"horizon_mixins_revision": "0d6b103c70633d8fd9443d8d0acd3aadaacedb06"
|
||||
"horizon_mixins_revision": "d9c83d7096b7ea5b83678da2efdfd6161f74997e"
|
||||
}
|
||||
@@ -4,5 +4,5 @@
|
||||
"horizon_repository": "https://github.com/horizon-rust/horizon",
|
||||
"horizon_revision": "6f8c7a0e5d531d3834c4d0be4659a1db8e7e5c2a",
|
||||
"horizon_mixins_repository": "https://github.com/horizon-rust/horizon-mixins",
|
||||
"horizon_mixins_revision": "0d6b103c70633d8fd9443d8d0acd3aadaacedb06"
|
||||
"horizon_mixins_revision": "d9c83d7096b7ea5b83678da2efdfd6161f74997e"
|
||||
}
|
||||
@@ -4,5 +4,5 @@
|
||||
"horizon_repository": "https://github.com/horizon-rust/horizon",
|
||||
"horizon_revision": "6f8c7a0e5d531d3834c4d0be4659a1db8e7e5c2a",
|
||||
"horizon_mixins_repository": "https://github.com/horizon-rust/horizon-mixins",
|
||||
"horizon_mixins_revision": "0d6b103c70633d8fd9443d8d0acd3aadaacedb06"
|
||||
"horizon_mixins_revision": "d9c83d7096b7ea5b83678da2efdfd6161f74997e"
|
||||
}
|
||||
@@ -4,5 +4,5 @@
|
||||
"horizon_repository": "https://github.com/horizon-rust/horizon",
|
||||
"horizon_revision": "6f8c7a0e5d531d3834c4d0be4659a1db8e7e5c2a",
|
||||
"horizon_mixins_repository": "https://github.com/horizon-rust/horizon-mixins",
|
||||
"horizon_mixins_revision": "0d6b103c70633d8fd9443d8d0acd3aadaacedb06"
|
||||
"horizon_mixins_revision": "d9c83d7096b7ea5b83678da2efdfd6161f74997e"
|
||||
}
|
||||
@@ -4,5 +4,5 @@
|
||||
"horizon_repository": "https://github.com/horizon-rust/horizon",
|
||||
"horizon_revision": "4baa571566e39bfd6594988d3bb2bc85440c30e7",
|
||||
"horizon_mixins_repository": "https://github.com/horizon-rust/horizon-mixins",
|
||||
"horizon_mixins_revision": "0d6b103c70633d8fd9443d8d0acd3aadaacedb06"
|
||||
"horizon_mixins_revision": "d9c83d7096b7ea5b83678da2efdfd6161f74997e"
|
||||
}
|
||||
Generated
+1
-1
@@ -4,7 +4,7 @@ version = 4
|
||||
|
||||
[[package]]
|
||||
name = "horizon-mod-smp"
|
||||
version = "0.30.1"
|
||||
version = "0.31.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "horizon-mod-smp"
|
||||
version = "0.30.1"
|
||||
version = "0.31.0"
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
publish = false
|
||||
|
||||
@@ -21,7 +21,7 @@ header and footer).
|
||||
| `/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); listing is free |
|
||||
| `/ah sell <price> [count]` | lists the stack in your hand (or part of it), whole (enchantments, name, contents...), 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 |
|
||||
@@ -261,8 +261,14 @@ raising one's own only holds the difference, and a bid in the last `snipe_second
|
||||
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`:
|
||||
reverse. A listing carries the whole stack (since 0.31): its enchantments, name, damage, lore, a shulker box's
|
||||
or a bundle's contents and every other component, written as the game writes it in player files, and given back
|
||||
exactly so (Horizon's `native_give_stack`) to the buyer, the winner, or the seller when it comes back. `/ah sell
|
||||
<price> <count>` lists part of the stack in hand; the rest stays in the hand, the same. The menu shows the stack
|
||||
itself (its tooltip), with the listing's price, seller and time under it. A stack is taken only if it would come
|
||||
back the same (its data written and read back equal); not market charts (free at `/market`), nor a stack holding
|
||||
more than 32 KiB of data (a box of books). Listings and mail from before 0.31 hold plain items and are given back as
|
||||
before. Survival and adventure players only. `config.json`'s `auction`:
|
||||
`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).
|
||||
|
||||
@@ -4,5 +4,5 @@
|
||||
"horizon_repository": "https://github.com/horizon-rust/horizon",
|
||||
"horizon_revision": "975c07d9151847e6af13afc4b5ab37b6bbe78ef5",
|
||||
"horizon_mixins_repository": "https://github.com/horizon-rust/horizon-mixins",
|
||||
"horizon_mixins_revision": "0d6b103c70633d8fd9443d8d0acd3aadaacedb06"
|
||||
"horizon_mixins_revision": "d9c83d7096b7ea5b83678da2efdfd6161f74997e"
|
||||
}
|
||||
@@ -39,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.30.1";
|
||||
pub const VERSION: &str = "0.31.0";
|
||||
|
||||
/// `config.json` in `world/horizon-smp/`, written with its defaults the
|
||||
/// first time, and again with the keys a newer version adds (values kept).
|
||||
@@ -429,6 +429,127 @@ impl Bag for GameBag<'_, '_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The most encoded data one listed stack may carry (a shulker box of
|
||||
/// written books can hold megabytes; economy.json is written whole).
|
||||
const MAX_STACK_DATA: usize = 32 * 1024;
|
||||
|
||||
/// A stack as the auction house keeps it: `ItemStack.CODEC`'s NBT (id,
|
||||
/// count, components), as the game saves it in player files, in hex.
|
||||
fn encode_stack(stack: &ItemStack) -> Option<String> {
|
||||
let tag = horizon_items::item_nbt::item_to_nbt(stack);
|
||||
let bytes = horizon_world::nbt::write_network(&tag, horizon_world::nbt::Limits::saved()).ok()?;
|
||||
Some(bytes.iter().map(|b| format!("{b:02x}")).collect())
|
||||
}
|
||||
|
||||
fn decode_stack(data: &str) -> Option<ItemStack> {
|
||||
if data.len() % 2 != 0 || data.len() > 2 * MAX_STACK_DATA {
|
||||
return None;
|
||||
}
|
||||
let bytes: Vec<u8> = (0..data.len())
|
||||
.step_by(2)
|
||||
.map(|i| u8::from_str_radix(data.get(i..i + 2)?, 16).ok())
|
||||
.collect::<Option<_>>()?;
|
||||
let (tag, _) = horizon_world::nbt::read_network(&bytes, horizon_world::nbt::Limits::default()).ok()?;
|
||||
horizon_items::item_nbt::item_from_nbt(&tag)
|
||||
}
|
||||
|
||||
fn whole_of(stack: &ItemStack) -> Option<smp_auction::Whole> {
|
||||
Some(smp_auction::Whole { item: stack.item.to_string(), count: stack.count, data: encode_stack(stack)? })
|
||||
}
|
||||
|
||||
/// Why `stack` can't be listed whole, if it can't: only what comes back
|
||||
/// exactly as it went (every component read back the same) is taken.
|
||||
fn unsellable(stack: &ItemStack) -> Option<String> {
|
||||
if &*stack.item == "minecraft:filled_map" && stack.data().and_then(|d| d.map_id).is_some_and(|m| m >= CHART_MAPS) {
|
||||
return Some("Market charts are free at /market: they can't be sold.".into());
|
||||
}
|
||||
let Some(data) = encode_stack(stack) else {
|
||||
return Some("That stack can't be listed: its data can't be written.".into());
|
||||
};
|
||||
if data.len() > 2 * MAX_STACK_DATA {
|
||||
return Some("That stack holds too much (books, contents...) to be listed.".into());
|
||||
}
|
||||
if decode_stack(&data).as_ref() != Some(stack) {
|
||||
return Some("That stack can't be listed whole: some of its data wouldn't come back the same.".into());
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The same stack but for the count.
|
||||
fn same_kind(a: &ItemStack, b: &ItemStack) -> bool {
|
||||
a.item == b.item && a.damage == b.damage && a.extra == b.extra
|
||||
}
|
||||
|
||||
/// Whether `stack` fits whole in `slots` (the 36 main slots), as
|
||||
/// `Inventory.add` places it: onto equal stacks, then into empty slots.
|
||||
fn fits_in(slots: &[Option<ItemStack>], stack: &ItemStack, max: u32) -> bool {
|
||||
let room: u32 = slots
|
||||
.iter()
|
||||
.take(36)
|
||||
.map(|s| match s {
|
||||
None => max,
|
||||
Some(s) if same_kind(s, stack) => max.saturating_sub(s.count),
|
||||
Some(_) => 0,
|
||||
})
|
||||
.sum();
|
||||
room >= stack.count
|
||||
}
|
||||
|
||||
/// `native_give_stack` for the auction house: what didn't fit, encoded
|
||||
/// again (all of it when the data can't be read: it waits in the mail).
|
||||
fn give_whole_to(game: &mut Game<'_>, uuid: u128, w: &smp_auction::Whole) -> Option<smp_auction::Whole> {
|
||||
let Some(stack) = decode_stack(&w.data) else {
|
||||
log::error!("[smp] auction house: a stack's data can't be read; it stays in the mail");
|
||||
return Some(w.clone());
|
||||
};
|
||||
let rest = game.native_give_stack(uuid, stack)?;
|
||||
Some(whole_of(&rest).unwrap_or_else(|| w.clone()))
|
||||
}
|
||||
|
||||
impl smp_auction::Hand for GameBag<'_, '_> {
|
||||
/// The acting player's state is swapped into the game inside their
|
||||
/// command, so the hand is the game's selected slot (no helper hands out
|
||||
/// a whole stack yet).
|
||||
fn held_whole(&self) -> Option<Result<smp_auction::Whole, String>> {
|
||||
let s = self.0.inventory.get(self.0.selected)?.as_ref()?;
|
||||
if let Some(why) = unsellable(s) {
|
||||
return Some(Err(why));
|
||||
}
|
||||
whole_of(s).map(Ok)
|
||||
}
|
||||
fn take_held(&mut self, held: &smp_auction::Whole, count: u32) -> Option<smp_auction::Whole> {
|
||||
self.0.player.as_ref()?;
|
||||
let selected = self.0.selected;
|
||||
let slot = self.0.inventory.get_mut(selected)?;
|
||||
let s = slot.as_ref()?;
|
||||
if count == 0 || count > s.count || encode_stack(s).as_deref() != Some(held.data.as_str()) {
|
||||
return None;
|
||||
}
|
||||
let mut taken = s.clone();
|
||||
taken.count = count;
|
||||
let encoded = whole_of(&taken)?;
|
||||
if count == s.count {
|
||||
*slot = None;
|
||||
} else if let Some(s) = slot.as_mut() {
|
||||
s.count -= count;
|
||||
}
|
||||
Some(encoded)
|
||||
}
|
||||
fn fits(&mut self, w: &smp_auction::Whole) -> bool {
|
||||
let Some(stack) = decode_stack(&w.data) else {
|
||||
return false;
|
||||
};
|
||||
let max = self.0.native_max_stack_size(&stack.item).unwrap_or(64).max(1);
|
||||
fits_in(&self.0.inventory, &stack, max)
|
||||
}
|
||||
fn give_whole(&mut self, w: &smp_auction::Whole) -> Option<smp_auction::Whole> {
|
||||
let Some(me) = self.0.native_acting_player() else {
|
||||
return Some(w.clone());
|
||||
};
|
||||
give_whole_to(self.0, me.uuid, w)
|
||||
}
|
||||
}
|
||||
|
||||
const COMMANDS: [&str; 16] = [
|
||||
"balance", "bal", "pay", "baltop", "eco", "money", "worth", "sell", "buy", "shop", "market", "trade", "coinflip",
|
||||
"lottery", "ah", "casino",
|
||||
@@ -1125,6 +1246,25 @@ impl Bag for MenuBag<'_, '_> {
|
||||
}
|
||||
}
|
||||
|
||||
impl smp_auction::Hand for MenuBag<'_, '_> {
|
||||
fn held_whole(&self) -> Option<Result<smp_auction::Whole, String>> {
|
||||
None
|
||||
}
|
||||
fn take_held(&mut self, _held: &smp_auction::Whole, _count: u32) -> Option<smp_auction::Whole> {
|
||||
None
|
||||
}
|
||||
fn fits(&mut self, w: &smp_auction::Whole) -> bool {
|
||||
let Some(stack) = decode_stack(&w.data) else {
|
||||
return false;
|
||||
};
|
||||
let max = self.game.native_max_stack_size(&stack.item).unwrap_or(64).max(1);
|
||||
fits_in(&self.stacks(), &stack, max)
|
||||
}
|
||||
fn give_whole(&mut self, w: &smp_auction::Whole) -> Option<smp_auction::Whole> {
|
||||
give_whole_to(self.game, self.uuid, w)
|
||||
}
|
||||
}
|
||||
|
||||
/// The shop menu's cells as items.
|
||||
fn shop_items(smp: &Smp, uuid: u128) -> Vec<Option<ItemStack>> {
|
||||
let economy = smp.economy.as_ref().ok();
|
||||
@@ -1717,12 +1857,24 @@ fn ah_items(smp: &Smp, uuid: u128) -> Vec<Option<ItemStack>> {
|
||||
C::Listing(id) => {
|
||||
let l = a.listing(id)?;
|
||||
let color = if l.auction { "gold" } else { "white" };
|
||||
named_stack(&l.item, l.count, &a.label(l, now, &money), color)
|
||||
let label = a.label(l, now, &money);
|
||||
match l.stack.as_deref().and_then(decode_stack) {
|
||||
// The stack itself (its name, enchantments, contents
|
||||
// in the tooltip), the listing's facts under it.
|
||||
Some(stack) => {
|
||||
let lines: Vec<(String, &str)> = label.split(" · ").map(|p| (p.to_owned(), color)).collect();
|
||||
with_lore(stack, &lines)
|
||||
}
|
||||
None => named_stack(&l.item, l.count, &label, color),
|
||||
}
|
||||
}
|
||||
C::Parcel(i) => {
|
||||
let p = a.parcel(uuid, i)?;
|
||||
let name = format!("Claim {} {} ({})", p.count, smp_shop::title(&p.item), p.why);
|
||||
named_stack(&p.item, p.count, &name, "green")
|
||||
match p.stack.as_deref().and_then(decode_stack) {
|
||||
Some(stack) => with_lore(stack, &[(name, "green")]),
|
||||
None => named_stack(&p.item, p.count, &name, "green"),
|
||||
}
|
||||
}
|
||||
C::Confirm(id) => {
|
||||
let l = a.listing(id)?;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[mod]
|
||||
id = "smp"
|
||||
version = "0.30.1"
|
||||
version = "0.31.0"
|
||||
priority = 100
|
||||
|
||||
# The commit of horizon it was tested on: it needs horizon#6106 (merged at 975c07d): native_inventory,
|
||||
|
||||
@@ -13,11 +13,14 @@
|
||||
// action on its one server thread, under the SMP's lock: of two players who
|
||||
// buy the same listing at once, the second finds it gone.
|
||||
//
|
||||
// Plain items only for now (not worn, named or enchanted): the hook point
|
||||
// that gives a whole stack with its components (`native_give_stack`) isn't
|
||||
// stamped yet. No game types: the glue lends the items (`Bag`) and draws the
|
||||
// menu from `Cell`s. Added to the game as `game::smp_auction`; also a module
|
||||
// of this crate, for its tests.
|
||||
// A listing carries the whole stack: its enchantments, name, damage,
|
||||
// contents (a shulker box's, a bundle's) and every other component, kept as
|
||||
// the glue encodes it (`Whole::data`) and given back exactly so
|
||||
// (`native_give_stack`). Listings and mail from before 0.31 hold plain items
|
||||
// by id and count (no `stack`) and are given back as they were. No game
|
||||
// types: the glue lends the items (`Hand`) and draws the 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, parse_amount};
|
||||
use super::smp_market::{Bag, item_name, valid_id};
|
||||
@@ -98,6 +101,10 @@ pub struct Listing {
|
||||
pub seller_name: String,
|
||||
pub item: String,
|
||||
pub count: u32,
|
||||
/// The whole stack as the glue encodes it; None: `count` plain `item`
|
||||
/// (a listing from before 0.31).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub stack: Option<String>,
|
||||
/// The price (fixed), or the starting bid (auction).
|
||||
pub price: i64,
|
||||
pub auction: bool,
|
||||
@@ -119,6 +126,9 @@ impl Listing {
|
||||
pub struct Parcel {
|
||||
pub item: String,
|
||||
pub count: u32,
|
||||
/// The whole stack, as `Listing::stack`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub stack: Option<String>,
|
||||
/// `expired`, `won`, `cancelled`...
|
||||
pub why: String,
|
||||
}
|
||||
@@ -154,6 +164,91 @@ pub struct Data {
|
||||
pub today: Today,
|
||||
}
|
||||
|
||||
/// A whole stack as the glue lends it: its id and count (what lines and
|
||||
/// menus show) and the stack itself in the glue's encoding, every component
|
||||
/// kept. The glue's encoding is the truth; `item` and `count` repeat it.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Whole {
|
||||
pub item: String,
|
||||
pub count: u32,
|
||||
pub data: String,
|
||||
}
|
||||
|
||||
/// The acting player's items, whole stacks too.
|
||||
pub trait Hand: Bag {
|
||||
/// The main hand's stack (None: empty hand), or why it can't be sold.
|
||||
fn held_whole(&self) -> Option<Result<Whole, String>>;
|
||||
/// Takes `count` of the main hand's stack while it is still exactly
|
||||
/// `held`: the part taken (the rest stays in the hand), or None and
|
||||
/// nothing moved.
|
||||
fn take_held(&mut self, held: &Whole, count: u32) -> Option<Whole>;
|
||||
/// Whether `stack` fits whole in the inventory.
|
||||
fn fits(&mut self, stack: &Whole) -> bool;
|
||||
/// Gives `stack` where it fits: what didn't fit (None: all of it given).
|
||||
fn give_whole(&mut self, stack: &Whole) -> Option<Whole>;
|
||||
}
|
||||
|
||||
/// Items on their way: plain (`stack` None) or one whole stack.
|
||||
struct Goods {
|
||||
item: String,
|
||||
count: u32,
|
||||
stack: Option<String>,
|
||||
}
|
||||
|
||||
impl Goods {
|
||||
fn of(l: &Listing) -> Self {
|
||||
Self {
|
||||
item: l.item.clone(),
|
||||
count: l.count,
|
||||
stack: l.stack.clone(),
|
||||
}
|
||||
}
|
||||
fn whole(&self) -> Option<Whole> {
|
||||
self.stack.as_ref().map(|data| Whole {
|
||||
item: self.item.clone(),
|
||||
count: self.count,
|
||||
data: data.clone(),
|
||||
})
|
||||
}
|
||||
/// Whether they fit in `bag` now.
|
||||
fn fit(&self, bag: &mut dyn Hand) -> bool {
|
||||
match self.whole() {
|
||||
Some(w) => bag.fits(&w),
|
||||
None => bag.room(&self.item) >= self.count,
|
||||
}
|
||||
}
|
||||
/// Gives them: how many went, and what is left (None: all went).
|
||||
fn give(&self, bag: &mut dyn Hand) -> (u32, Option<Goods>) {
|
||||
match self.whole() {
|
||||
Some(w) => match bag.give_whole(&w) {
|
||||
None => (self.count, None),
|
||||
Some(rest) => (
|
||||
self.count.saturating_sub(rest.count),
|
||||
Some(Goods {
|
||||
item: rest.item,
|
||||
count: rest.count,
|
||||
stack: Some(rest.data),
|
||||
}),
|
||||
),
|
||||
},
|
||||
None => {
|
||||
let given = if self.count > 0 {
|
||||
bag.give(&self.item, self.count)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let left = self.count - given.min(self.count);
|
||||
let rest = (left > 0).then(|| Goods {
|
||||
item: self.item.clone(),
|
||||
count: left,
|
||||
stack: None,
|
||||
});
|
||||
(given, rest)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Which listings a list page shows.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Filter {
|
||||
@@ -342,8 +437,8 @@ impl AuctionHouse {
|
||||
}
|
||||
|
||||
/// Items to `uuid`'s mail.
|
||||
fn post(&mut self, uuid: &str, item: &str, count: u32, why: &str) {
|
||||
if count == 0 {
|
||||
fn post(&mut self, uuid: &str, goods: Goods, why: &str) {
|
||||
if goods.count == 0 {
|
||||
return;
|
||||
}
|
||||
self.data
|
||||
@@ -351,8 +446,9 @@ impl AuctionHouse {
|
||||
.entry(uuid.to_owned())
|
||||
.or_default()
|
||||
.push(Parcel {
|
||||
item: item.to_owned(),
|
||||
count,
|
||||
item: goods.item,
|
||||
count: goods.count,
|
||||
stack: goods.stack,
|
||||
why: why.to_owned(),
|
||||
});
|
||||
self.dirty = true;
|
||||
@@ -426,7 +522,7 @@ impl AuctionHouse {
|
||||
economy: &mut Economy,
|
||||
me: &Player,
|
||||
trades: bool,
|
||||
bag: &mut dyn Bag,
|
||||
bag: &mut dyn Hand,
|
||||
now: i64,
|
||||
line: &str,
|
||||
) -> Option<Reply> {
|
||||
@@ -510,7 +606,7 @@ impl AuctionHouse {
|
||||
&mut self,
|
||||
economy: &mut Economy,
|
||||
me: &Player,
|
||||
bag: &mut dyn Bag,
|
||||
bag: &mut dyn Hand,
|
||||
price: &str,
|
||||
count: Option<u32>,
|
||||
hours: Option<i64>,
|
||||
@@ -534,41 +630,55 @@ impl AuctionHouse {
|
||||
self.config.max_listings
|
||||
));
|
||||
}
|
||||
let Some((item, held, plain)) = bag.held() else {
|
||||
return err("Hold the items you want to sell.".into());
|
||||
let held = match bag.held_whole() {
|
||||
None => return err("Hold the items you want to sell.".into()),
|
||||
Some(Err(why)) => return err(why),
|
||||
Some(Ok(h)) => h,
|
||||
};
|
||||
if !plain || !valid_id(&item) {
|
||||
return err(
|
||||
"The auction house takes only plain items for now (not worn, named or enchanted)."
|
||||
.into(),
|
||||
);
|
||||
if !valid_id(&held.item) {
|
||||
return err("That item can't be sold here.".into());
|
||||
}
|
||||
let count = count.unwrap_or(held);
|
||||
if count == 0 || count > held {
|
||||
return err(format!("You hold {held} {}.", item_name(&item)));
|
||||
let count = count.unwrap_or(held.count);
|
||||
if count == 0 || count > held.count {
|
||||
return err(format!(
|
||||
"You hold {} {}.",
|
||||
held.count,
|
||||
item_name(&held.item)
|
||||
));
|
||||
}
|
||||
let taken = bag.take(&item, count);
|
||||
if taken != count {
|
||||
bag.give(&item, taken);
|
||||
// The stack leaves the hand before anything is written: from here
|
||||
// on it is either listed or given back, never both.
|
||||
let Some(taken) = bag.take_held(&held, count) else {
|
||||
return err("Those items couldn't be taken.".into());
|
||||
}
|
||||
};
|
||||
self.data.next_id += 1;
|
||||
let id = self.data.next_id;
|
||||
let ends = now + hours.unwrap_or(self.config.listing_hours).max(1) * 3_600_000;
|
||||
let entry = serde_json::json!({
|
||||
"kind": "ah_list", "id": id, "seller": mine, "item": &item, "count": count,
|
||||
"price": price, "auction": hours.is_some(), "ends": ends,
|
||||
"kind": "ah_list", "id": id, "seller": mine, "item": &taken.item, "count": taken.count,
|
||||
"stack": &taken.data, "price": price, "auction": hours.is_some(), "ends": ends,
|
||||
});
|
||||
if let Err(e) = economy.record(entry) {
|
||||
bag.give(&item, taken);
|
||||
self.data.next_id -= 1;
|
||||
// Back whole; what doesn't fit (it came from the hand: nothing,
|
||||
// unless the hand filled meanwhile) waits in the mail.
|
||||
if let Some(rest) = bag.give_whole(&taken) {
|
||||
let goods = Goods {
|
||||
item: rest.item,
|
||||
count: rest.count,
|
||||
stack: Some(rest.data),
|
||||
};
|
||||
self.post(&mine, goods, "not listed");
|
||||
}
|
||||
return err(e);
|
||||
}
|
||||
let l = Listing {
|
||||
id,
|
||||
seller: mine,
|
||||
seller_name: me.name.clone(),
|
||||
item,
|
||||
count,
|
||||
item: taken.item,
|
||||
count: taken.count,
|
||||
stack: Some(taken.data),
|
||||
price,
|
||||
auction: hours.is_some(),
|
||||
bid: None,
|
||||
@@ -580,7 +690,7 @@ impl AuctionHouse {
|
||||
"Listed #{id}: {} for {} ({:.2} each), {}h.",
|
||||
Self::what(&l),
|
||||
economy.money(price),
|
||||
price as f64 / f64::from(count),
|
||||
price as f64 / f64::from(l.count.max(1)),
|
||||
self.config.listing_hours
|
||||
),
|
||||
Some(h) => format!(
|
||||
@@ -609,7 +719,7 @@ impl AuctionHouse {
|
||||
&mut self,
|
||||
economy: &mut Economy,
|
||||
me: &Player,
|
||||
bag: &mut dyn Bag,
|
||||
bag: &mut dyn Hand,
|
||||
id: u64,
|
||||
now: i64,
|
||||
) -> Vec<Out> {
|
||||
@@ -638,12 +748,12 @@ impl AuctionHouse {
|
||||
let Some(seller) = parse_uuid(&l.seller) else {
|
||||
return err("That listing is damaged.".into());
|
||||
};
|
||||
if bag.room(&l.item) < l.count {
|
||||
if !Goods::of(&l).fit(bag) {
|
||||
return err(format!("Make room for {} first.", Self::what(&l)));
|
||||
}
|
||||
let entry = serde_json::json!({
|
||||
"kind": "ah_buy", "id": id, "buyer": uuid_string(me.uuid), "seller": &l.seller,
|
||||
"item": &l.item, "count": l.count, "price": l.price,
|
||||
"item": &l.item, "count": l.count, "stack": &l.stack, "price": l.price,
|
||||
});
|
||||
if let Err(e) = economy.record(entry) {
|
||||
return err(e);
|
||||
@@ -651,12 +761,14 @@ impl AuctionHouse {
|
||||
// The listing leaves first: nothing can sell it twice.
|
||||
self.data.listings.remove(i);
|
||||
self.dirty = true;
|
||||
let given = bag.give(&l.item, l.count);
|
||||
let (given, rest) = Goods::of(&l).give(bag);
|
||||
if given == 0 {
|
||||
self.data.listings.insert(i, l);
|
||||
return err("The items couldn't be given: nothing was bought.".into());
|
||||
}
|
||||
self.post(&uuid_string(me.uuid), &l.item, l.count - given, "bought");
|
||||
if let Some(rest) = rest {
|
||||
self.post(&uuid_string(me.uuid), rest, "bought");
|
||||
}
|
||||
if let Err(e) = Self::pay_seller(economy, Ok(me.uuid), seller, l.price) {
|
||||
self.problems.push(format!("auction house sale #{id}: {e}"));
|
||||
}
|
||||
@@ -785,13 +897,13 @@ impl AuctionHouse {
|
||||
return vec![Out::Error("Someone bid on it: it runs to its end.".into())];
|
||||
}
|
||||
let l = self.data.listings.remove(i);
|
||||
self.post(&l.seller, &l.item, l.count, "withdrawn");
|
||||
self.post(&l.seller, Goods::of(&l), "withdrawn");
|
||||
vec![Out::Reply(format!("Took #{id} ({}) back.", Self::what(&l)))]
|
||||
}
|
||||
|
||||
/// Gives `uuid` their mail (one parcel, or all of it), as much as fits;
|
||||
/// the rest stays.
|
||||
pub fn claim(&mut self, uuid: u128, bag: &mut dyn Bag, only: Option<usize>) -> Vec<Out> {
|
||||
pub fn claim(&mut self, uuid: u128, bag: &mut dyn Hand, only: Option<usize>) -> Vec<Out> {
|
||||
let key = uuid_string(uuid);
|
||||
let Some(mut parcels) = self.data.mail.remove(&key) else {
|
||||
return vec![Out::Reply("Your mail is empty.".into())];
|
||||
@@ -802,9 +914,33 @@ impl AuctionHouse {
|
||||
if only.is_some_and(|o| o != i) {
|
||||
continue;
|
||||
}
|
||||
let n = p.count.min(bag.room(&p.item));
|
||||
let given = if n > 0 { bag.give(&p.item, n) } else { 0 };
|
||||
p.count -= given.min(p.count);
|
||||
let goods = Goods {
|
||||
item: p.item.clone(),
|
||||
count: p.count,
|
||||
stack: p.stack.clone(),
|
||||
};
|
||||
let given = match &goods.stack {
|
||||
// Plain: as much as there is room for.
|
||||
None => {
|
||||
let n = p.count.min(bag.room(&p.item));
|
||||
let given = if n > 0 { bag.give(&p.item, n) } else { 0 };
|
||||
p.count -= given.min(p.count);
|
||||
given
|
||||
}
|
||||
// A whole stack: once it fits whole; what didn't go stays.
|
||||
Some(_) if !goods.fit(bag) => 0,
|
||||
Some(_) => {
|
||||
let (given, rest) = goods.give(bag);
|
||||
match rest {
|
||||
None => p.count = 0,
|
||||
Some(r) => {
|
||||
p.count = r.count;
|
||||
p.stack = r.stack;
|
||||
}
|
||||
}
|
||||
given
|
||||
}
|
||||
};
|
||||
full |= p.count > 0;
|
||||
if given > 0 {
|
||||
given_all.push(format!("{given} {}", item_name(&p.item)));
|
||||
@@ -858,7 +994,7 @@ impl AuctionHouse {
|
||||
for l in ended {
|
||||
let Some(b) = l.bid.clone() else {
|
||||
let why = if l.auction { "no bids" } else { "expired" };
|
||||
self.post(&l.seller, &l.item, l.count, why);
|
||||
self.post(&l.seller, Goods::of(&l), why);
|
||||
let line = format!(
|
||||
"#{} ({}) ended with {why}: the items are in your mail (/ah claim).",
|
||||
l.id,
|
||||
@@ -872,7 +1008,7 @@ impl AuctionHouse {
|
||||
self.problems
|
||||
.push(format!("auction #{} payment: {e}", l.id));
|
||||
}
|
||||
self.post(&b.uuid, &l.item, l.count, "won");
|
||||
self.post(&b.uuid, Goods::of(&l), "won");
|
||||
self.record_sale(&l, b.amount, now);
|
||||
let line = format!(
|
||||
"You won #{} ({}) for {}: the items are in your mail (/ah claim).",
|
||||
@@ -1132,15 +1268,89 @@ mod tests {
|
||||
const NOTCH: u128 = 3;
|
||||
const T0: i64 = 1_793_577_600_000;
|
||||
|
||||
/// A player's inventory: plain stacks by id, 36 slots of 64.
|
||||
/// A player's inventory: plain stacks by id, 36 slots of 64, and whole
|
||||
/// stacks (`id|count|components`, the glue's encoding stands in), one a
|
||||
/// slot.
|
||||
#[derive(Default)]
|
||||
struct Inv {
|
||||
hand: Option<(String, u32)>,
|
||||
/// A whole stack in the hand (instead of `hand`).
|
||||
hand_whole: Option<String>,
|
||||
items: BTreeMap<String, u32>,
|
||||
wholes: Vec<String>,
|
||||
slots_free: u32,
|
||||
fail_give: bool,
|
||||
}
|
||||
|
||||
/// `id|count|components` as a `Whole`.
|
||||
fn whole(data: &str) -> Whole {
|
||||
let mut parts = data.splitn(3, '|');
|
||||
let item = parts.next().unwrap().to_owned();
|
||||
let count = parts.next().unwrap().parse().unwrap();
|
||||
Whole {
|
||||
item,
|
||||
count,
|
||||
data: data.to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
fn with_count(data: &str, n: u32) -> String {
|
||||
let p: Vec<&str> = data.splitn(3, '|').collect();
|
||||
format!("{}|{n}|{}", p[0], p[2])
|
||||
}
|
||||
|
||||
fn is_plain(data: &str) -> bool {
|
||||
data.ends_with('|')
|
||||
}
|
||||
|
||||
impl Hand for Inv {
|
||||
fn held_whole(&self) -> Option<Result<Whole, String>> {
|
||||
if let Some(d) = &self.hand_whole {
|
||||
return Some(Ok(whole(d)));
|
||||
}
|
||||
self.held()
|
||||
.map(|(item, n, _)| Ok(whole(&format!("{item}|{n}|"))))
|
||||
}
|
||||
fn take_held(&mut self, held: &Whole, count: u32) -> Option<Whole> {
|
||||
if let Some(d) = self.hand_whole.clone() {
|
||||
if d != held.data || count > held.count {
|
||||
return None;
|
||||
}
|
||||
let left = held.count - count;
|
||||
self.hand_whole = (left > 0).then(|| with_count(&d, left));
|
||||
return Some(whole(&with_count(&d, count)));
|
||||
}
|
||||
let taken = self.take(&held.item, count);
|
||||
if taken != count {
|
||||
self.give(&held.item, taken);
|
||||
return None;
|
||||
}
|
||||
Some(whole(&with_count(&held.data, count)))
|
||||
}
|
||||
fn fits(&mut self, stack: &Whole) -> bool {
|
||||
if is_plain(&stack.data) {
|
||||
self.room(&stack.item) >= stack.count
|
||||
} else {
|
||||
self.slots_free > 0
|
||||
}
|
||||
}
|
||||
fn give_whole(&mut self, stack: &Whole) -> Option<Whole> {
|
||||
if self.fail_give {
|
||||
return Some(stack.clone());
|
||||
}
|
||||
if is_plain(&stack.data) {
|
||||
let n = self.give(&stack.item, stack.count);
|
||||
return (n < stack.count).then(|| whole(&with_count(&stack.data, stack.count - n)));
|
||||
}
|
||||
if self.slots_free == 0 {
|
||||
return Some(stack.clone());
|
||||
}
|
||||
self.slots_free -= 1;
|
||||
self.wholes.push(stack.data.clone());
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
impl Bag for Inv {
|
||||
fn held(&self) -> Option<(String, u32, bool)> {
|
||||
self.hand
|
||||
@@ -1183,10 +1393,63 @@ mod tests {
|
||||
hand: Some((format!("minecraft:{item}"), n)),
|
||||
items: BTreeMap::from([(format!("minecraft:{item}"), n)]),
|
||||
slots_free: 30,
|
||||
fail_give: false,
|
||||
..Inv::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn holding_whole(data: &str) -> Inv {
|
||||
Inv {
|
||||
hand_whole: Some(data.into()),
|
||||
slots_free: 30,
|
||||
..Inv::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// An enchanted, named, worn sword (one stack).
|
||||
const SWORD: &str = "minecraft:diamond_sword|1|custom_name=Excalibur;enchantments={sharpness:5,unbreaking:3};damage=12";
|
||||
/// A shulker box with its contents.
|
||||
const SHULKER: &str = "minecraft:shulker_box|1|container=[{slot:0,id:diamond,count:64},{slot:1,id:bundle,count:1,bundle_contents=[{id:emerald,count:8}]}]";
|
||||
|
||||
/// Where the copies of `data` are: in bags, listings and mail.
|
||||
fn copies(ah: &AuctionHouse, bags: &[&Inv], data: &str) -> usize {
|
||||
let listed = ah
|
||||
.data
|
||||
.listings
|
||||
.iter()
|
||||
.filter(|l| l.stack.as_deref() == Some(data))
|
||||
.count();
|
||||
let mailed = ah
|
||||
.data
|
||||
.mail
|
||||
.values()
|
||||
.flatten()
|
||||
.filter(|p| p.stack.as_deref() == Some(data))
|
||||
.count();
|
||||
let held = bags
|
||||
.iter()
|
||||
.map(|b| {
|
||||
b.wholes.iter().filter(|w| *w == data).count()
|
||||
+ usize::from(b.hand_whole.as_deref() == Some(data))
|
||||
})
|
||||
.sum::<usize>();
|
||||
listed + mailed + held
|
||||
}
|
||||
|
||||
/// Saves everything and opens it again from disk, as a restart (or a
|
||||
/// crash right after the save) does.
|
||||
fn reopen(
|
||||
ah: &mut AuctionHouse,
|
||||
e: &mut Economy,
|
||||
dir: &std::path::Path,
|
||||
) -> (Economy, AuctionHouse) {
|
||||
ah.store(e);
|
||||
e.save().unwrap();
|
||||
let mut e = Economy::open(dir, EcoConfig::default()).unwrap();
|
||||
e.now = T0;
|
||||
let ah = AuctionHouse::open(&e, Config::default());
|
||||
(e, ah)
|
||||
}
|
||||
|
||||
fn empty() -> Inv {
|
||||
Inv {
|
||||
slots_free: 30,
|
||||
@@ -1567,4 +1830,191 @@ mod tests {
|
||||
.starts_with("#4 32 Oak Log · 16 coins (0.50 each) · Alex · 2d 0h left")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_enchanted_named_sword_comes_back_identical_after_a_purchase_an_expiry_an_auction_and_a_claim()
|
||||
{
|
||||
let (mut e, mut ah, dir) = setup("whole");
|
||||
let (alex, steve, notch) = (p(ALEX, "Alex"), p(STEVE, "Steve"), p(NOTCH, "Notch"));
|
||||
ah.online = [ALEX, STEVE, NOTCH].into();
|
||||
// Listed whole: the stack leaves the hand, the listing keeps it all.
|
||||
let mut a = holding_whole(SWORD);
|
||||
let out = run(&mut ah, &mut e, &alex, &mut a, T0, "ah sell 500");
|
||||
assert!(
|
||||
matches!(&out[0], Out::Reply(s) if s.starts_with("Listed #1: 1 diamond sword")),
|
||||
"{out:?}"
|
||||
);
|
||||
assert_eq!(a.hand_whole, None);
|
||||
assert_eq!(ah.listing(1).unwrap().stack.as_deref(), Some(SWORD));
|
||||
// Bought: Steve gets the very stack.
|
||||
let mut s = empty();
|
||||
ah.buy(&mut e, &steve, &mut s, 1, T0 + 1);
|
||||
assert_eq!(s.wholes, [SWORD]);
|
||||
assert_eq!((e.balance(ALEX), e.balance(STEVE)), (1500, 500));
|
||||
// Steve lists it; nobody buys; a restart; it comes back by mail.
|
||||
let mut s = holding_whole(SWORD);
|
||||
run(&mut ah, &mut e, &steve, &mut s, T0, "ah sell 900");
|
||||
ah.tick(&mut e, &[STEVE], T0 + 48 * 3_600_000);
|
||||
let (mut e, mut ah) = reopen(&mut ah, &mut e, &dir);
|
||||
assert_eq!(ah.mail_of(STEVE)[0].stack.as_deref(), Some(SWORD));
|
||||
let mut s = empty();
|
||||
assert_eq!(
|
||||
ah.claim(STEVE, &mut s, None),
|
||||
[Out::Reply("From your mail: 1 diamond sword.".into())]
|
||||
);
|
||||
assert_eq!(s.wholes, [SWORD]);
|
||||
// Auctioned: Notch wins it, claims it after a restart.
|
||||
let mut s = holding_whole(SWORD);
|
||||
run(&mut ah, &mut e, &steve, &mut s, T0, "ah auction 100 1");
|
||||
ah.bid(&mut e, ¬ch, 3, None, T0);
|
||||
ah.tick(&mut e, &[STEVE, NOTCH], T0 + 3_600_000);
|
||||
let (_e, mut ah) = reopen(&mut ah, &mut e, &dir);
|
||||
let mut n = empty();
|
||||
ah.claim(NOTCH, &mut n, None);
|
||||
assert_eq!(n.wholes, [SWORD]);
|
||||
assert_eq!(copies(&ah, &[&a, &s, &n], SWORD), 1);
|
||||
// A shulker box keeps its contents (a bundle inside, with its own).
|
||||
let (mut e, mut ah, _dir) = setup("shulker");
|
||||
let mut a = holding_whole(SHULKER);
|
||||
run(&mut ah, &mut e, &alex, &mut a, T0, "ah sell 10");
|
||||
let mut s = empty();
|
||||
ah.buy(&mut e, &steve, &mut s, 1, T0);
|
||||
assert_eq!(s.wholes, [SHULKER]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn part_of_a_named_stack_is_listed_and_the_rest_stays_in_the_hand() {
|
||||
let (mut e, mut ah, _dir) = setup("part");
|
||||
let pearls = "minecraft:ender_pearl|16|custom_name=Warp";
|
||||
let mut a = holding_whole(pearls);
|
||||
run(
|
||||
&mut ah,
|
||||
&mut e,
|
||||
&p(ALEX, "Alex"),
|
||||
&mut a,
|
||||
T0,
|
||||
"ah sell 60 6",
|
||||
);
|
||||
assert_eq!(
|
||||
a.hand_whole.as_deref(),
|
||||
Some("minecraft:ender_pearl|10|custom_name=Warp")
|
||||
);
|
||||
let l = ah.listing(1).unwrap();
|
||||
assert_eq!(
|
||||
(l.count, l.stack.as_deref()),
|
||||
(6, Some("minecraft:ender_pearl|6|custom_name=Warp"))
|
||||
);
|
||||
assert_eq!(
|
||||
run(
|
||||
&mut ah,
|
||||
&mut e,
|
||||
&p(ALEX, "Alex"),
|
||||
&mut a,
|
||||
T0,
|
||||
"ah sell 60 11"
|
||||
),
|
||||
[Out::Error("You hold 10 ender pearl.".into())]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failure_or_a_crash_between_steps_never_dupes_or_loses_the_stack() {
|
||||
// The audit log can't be written: the listing isn't made and the
|
||||
// sword is back in the inventory, once.
|
||||
let dir = std::env::temp_dir().join(format!("smp-ah-crash-log-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
fs::create_dir_all(dir.join("audit.log")).unwrap();
|
||||
let mut e = Economy::open(&dir, EcoConfig::default()).unwrap();
|
||||
let mut ah = AuctionHouse::open(&e, Config::default());
|
||||
let mut a = holding_whole(SWORD);
|
||||
let out = run(&mut ah, &mut e, &p(ALEX, "Alex"), &mut a, T0, "ah sell 500");
|
||||
assert!(matches!(&out[0], Out::Error(_)), "{out:?}");
|
||||
assert!(ah.data.listings.is_empty() && ah.data.mail.is_empty());
|
||||
assert_eq!(
|
||||
(a.hand_whole, &a.wholes[..]),
|
||||
(None, &[SWORD.to_owned()][..])
|
||||
);
|
||||
|
||||
// Each step saved and read back (a restart or a crash after the
|
||||
// save): the sword is in exactly one place each time.
|
||||
let (mut e, mut ah, dir) = setup("crash");
|
||||
let (alex, steve) = (p(ALEX, "Alex"), p(STEVE, "Steve"));
|
||||
let mut a = holding_whole(SWORD);
|
||||
let mut s = empty();
|
||||
run(&mut ah, &mut e, &alex, &mut a, T0, "ah sell 500");
|
||||
let (mut e, mut ah) = reopen(&mut ah, &mut e, &dir);
|
||||
assert_eq!(copies(&ah, &[&a, &s], SWORD), 1, "listed");
|
||||
// A give that fails: nothing bought, nothing moved.
|
||||
let mut broken = Inv {
|
||||
slots_free: 5,
|
||||
fail_give: true,
|
||||
..Inv::default()
|
||||
};
|
||||
assert!(matches!(
|
||||
&ah.buy(&mut e, &steve, &mut broken, 1, T0)[0],
|
||||
Out::Error(_)
|
||||
));
|
||||
assert_eq!((ah.listing(1).is_some(), e.balance(STEVE)), (true, 1000));
|
||||
// No room: refused before anything moves.
|
||||
let mut full = Inv::default();
|
||||
assert!(
|
||||
matches!(&ah.buy(&mut e, &steve, &mut full, 1, T0)[0], Out::Error(s) if s.starts_with("Make room"))
|
||||
);
|
||||
ah.buy(&mut e, &steve, &mut s, 1, T0);
|
||||
let (mut e, mut ah) = reopen(&mut ah, &mut e, &dir);
|
||||
assert_eq!(copies(&ah, &[&a, &s], SWORD), 1, "bought");
|
||||
let mut s = holding_whole(SWORD);
|
||||
run(&mut ah, &mut e, &steve, &mut s, T0, "ah sell 500");
|
||||
ah.tick(&mut e, &[STEVE], T0 + 48 * 3_600_000);
|
||||
let (mut e, mut ah) = reopen(&mut ah, &mut e, &dir);
|
||||
assert_eq!(copies(&ah, &[&a, &s], SWORD), 1, "expired to the mail");
|
||||
// A full inventory: the parcel waits, whole.
|
||||
let mut full = Inv::default();
|
||||
ah.claim(STEVE, &mut full, None);
|
||||
let (_e, mut ah) = reopen(&mut ah, &mut e, &dir);
|
||||
assert_eq!(copies(&ah, &[&a, &s, &full], SWORD), 1, "claim refused");
|
||||
ah.claim(STEVE, &mut s, None);
|
||||
assert_eq!(
|
||||
ah.claim(STEVE, &mut s, None),
|
||||
[Out::Reply("Your mail is empty.".into())]
|
||||
);
|
||||
assert_eq!(
|
||||
(copies(&ah, &[&a, &s], SWORD), &s.wholes[..]),
|
||||
(1, &[SWORD.to_owned()][..])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn listings_and_mail_from_0_30_load_unchanged_and_give_plain_items() {
|
||||
let (mut e, _ah, dir) = setup("old");
|
||||
let old = serde_json::json!({
|
||||
"next_id": 7,
|
||||
"listings": [{"id": 7, "seller": uuid_string(ALEX), "seller_name": "Alex", "item": "minecraft:wheat",
|
||||
"count": 64, "price": 100, "auction": false, "created": T0, "ends": T0 + 3_600_000}],
|
||||
"mail": {uuid_string(STEVE): [{"item": "minecraft:diamond", "count": 3, "why": "won"}]},
|
||||
"notes": {}, "sales": [], "today": {"day": 0, "sales": 0, "volume": 0},
|
||||
});
|
||||
e.set_ext("auction", old.clone());
|
||||
e.save().unwrap();
|
||||
let e2 = Economy::open(&dir, EcoConfig::default()).unwrap();
|
||||
let mut ah = AuctionHouse::open(&e2, Config::default());
|
||||
assert_eq!(
|
||||
serde_json::to_value(&ah.data).unwrap(),
|
||||
old,
|
||||
"read and written back the same"
|
||||
);
|
||||
let mut e = e2;
|
||||
e.now = T0;
|
||||
let mut s = empty();
|
||||
assert_eq!(
|
||||
ah.claim(STEVE, &mut s, None),
|
||||
[Out::Reply("From your mail: 3 diamond.".into())]
|
||||
);
|
||||
ah.buy(&mut e, &p(STEVE, "Steve"), &mut s, 7, T0);
|
||||
assert_eq!(
|
||||
(s.items["minecraft:diamond"], s.items["minecraft:wheat"]),
|
||||
(3, 64)
|
||||
);
|
||||
assert!(s.wholes.is_empty());
|
||||
}
|
||||
}
|
||||
+10
-4
@@ -170,7 +170,9 @@ def cargo(stage_dir, action):
|
||||
|
||||
|
||||
def package(m):
|
||||
"""A deterministic .hnmod: horizon-native.json and the bundle's .rs and .toml files."""
|
||||
"""A deterministic .hnmod: horizon-native.json, the bundle's .rs and .toml files (its crates' too) and the mod's
|
||||
LICENSE as mixins/<id>/LICENSE, so the licence's notice travels with the sources (Horizon's bundle reader
|
||||
accepts LICENSE* files there)."""
|
||||
manifest = {"schema": 1, "id": m.id, "version": m.version, "game": m.compat["game"],
|
||||
"horizon": m.compat["horizon_revision"], "mixins": m.compat["horizon_mixins_revision"],
|
||||
"hooks": m.compat["hooks"]}
|
||||
@@ -178,9 +180,13 @@ def package(m):
|
||||
if pinned != m.compat["horizon_revision"]:
|
||||
sys.exit(f"{m.id}: horizon-mixin.toml pins {pinned}, compatibility.json {m.compat['horizon_revision']}")
|
||||
files = {"horizon-native.json": (json.dumps(manifest, indent=2, sort_keys=True) + "\n").encode()}
|
||||
for path in sorted(m.bundle.iterdir()):
|
||||
if path.suffix in (".rs", ".toml"):
|
||||
files[f"mixins/{m.id}/{path.name}"] = path.read_bytes()
|
||||
# The helpers and modules, and the sources of the bundle's own crates (horizon-mixins 0.6.0).
|
||||
for path in sorted(m.bundle.rglob("*")):
|
||||
if path.is_file() and path.suffix in (".rs", ".toml"):
|
||||
files[f"mixins/{m.id}/{path.relative_to(m.bundle).as_posix()}"] = path.read_bytes()
|
||||
licence = m.root / "LICENSE"
|
||||
if licence.is_file():
|
||||
files.setdefault(f"mixins/{m.id}/LICENSE", licence.read_bytes())
|
||||
out = TARGET / f"{m.id}-{m.version}.hnmod"
|
||||
TARGET.mkdir(exist_ok=True)
|
||||
with zipfile.ZipFile(out, "w", compression=zipfile.ZIP_DEFLATED) as archive:
|
||||
|
||||
Generated
+252
@@ -0,0 +1,252 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "86e055fa6e3e40ad6187ba25b6855eeb635d5271f8e0f0e1e9a5826b8d75bea0"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"shlex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48"
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"wasi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.191"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.107"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ring"
|
||||
version = "0.17.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cfg-if",
|
||||
"getrandom",
|
||||
"libc",
|
||||
"untrusted",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.154"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shlex"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
|
||||
|
||||
[[package]]
|
||||
name = "voicechat_server_relay"
|
||||
version = "1.0.0"
|
||||
dependencies = [
|
||||
"ring",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasi"
|
||||
version = "0.11.1+wasi-snapshot-preview1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.52.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
|
||||
dependencies = [
|
||||
"windows-targets",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
|
||||
dependencies = [
|
||||
"windows_aarch64_gnullvm",
|
||||
"windows_aarch64_msvc",
|
||||
"windows_i686_gnu",
|
||||
"windows_i686_gnullvm",
|
||||
"windows_i686_msvc",
|
||||
"windows_x86_64_gnu",
|
||||
"windows_x86_64_gnullvm",
|
||||
"windows_x86_64_msvc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
@@ -0,0 +1,4 @@
|
||||
# The relay crate the bundle adds to the game (mixins/voicechat_server/relay), with its tests.
|
||||
[workspace]
|
||||
members = ["mixins/voicechat_server/relay"]
|
||||
resolver = "3"
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Ludovic Marie and the Horizon contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,53 @@
|
||||
# Voice chat server (Horizon native mod)
|
||||
|
||||
The server half of proximity voice chat on a Horizon dedicated server. Players who have a voice chat client mod
|
||||
(azetech's Complex Voice Chat, any client that speaks Simple Voice Chat 2.6.x's protocol over Horizon's mod
|
||||
messages) talk to the players near them, or to their group from anywhere. Players without one join and play as on
|
||||
any server: the server sends them nothing.
|
||||
|
||||
- **The handshake** goes over Horizon's mod messages (`native_net_from_client` / `native_net_send_to_player`, the
|
||||
`horizon:mod_net` channel) on `voicechat:` channel names, the same messages and layouts as Simple Voice Chat 2.6.x
|
||||
(compatibility version 20). A client gets a fresh 16-byte secret when it asks; nothing is sent to anyone before.
|
||||
- **Voices** go over UDP, through the server, never from player to player: no player learns another's address.
|
||||
Each player's packets are sealed with their own secret (AES-128-GCM). The server relays a voice to the players
|
||||
in the same dimension within `distance` blocks (`whisper_distance` for a whisper), and to the speaker's group
|
||||
wherever they are. Groups: normal (members aren't heard outside but hear who is near), open (heard and hearing
|
||||
nearby), isolated (members only).
|
||||
- **Limits:** 75 voice packets a second a player (burst 30), Opus frames up to 1275 bytes, 128 groups, names and
|
||||
passwords up to 24 characters, group and state changes 2 a second a player (burst 10), one secret a second a
|
||||
player (more requests meanwhile get one answer), 24 messages a player a tick and at most 512 waiting.
|
||||
- **Replays:** a packet that doesn't open with the player's key is dropped without an answer. A secret belongs to
|
||||
the first address that authenticates with it: a handshake copied and sent from elsewhere moves nothing. Exact
|
||||
copies are dropped too (the last 64 control packets' nonces, and voice packets' sequence numbers). A client the
|
||||
server stops hearing for 10 keep-alives (its NAT rebound, say) gets a fresh secret over the game connection and
|
||||
comes back from its new address with it; the old key then opens nothing.
|
||||
- **No port is opened** until the first voice chat client asks. Then the relay binds `bind:port` (UDP).
|
||||
|
||||
## Config
|
||||
|
||||
`config/voicechat-server.json`, beside `server.properties`, written with its defaults the first time a client asks:
|
||||
|
||||
| Key | Default | |
|
||||
|---|---|---|
|
||||
| `enabled` | `true` | `false`: no port, no answer to clients |
|
||||
| `port` | `24454` | the UDP port. A host that gives one port for the game usually forwards UDP on it too: use the game's port |
|
||||
| `bind` | `0.0.0.0` | |
|
||||
| `voice_host` | `""` | where clients send UDP if not the game server's address: a port, a host, or host:port |
|
||||
| `distance` | `48.0` | blocks a voice carries |
|
||||
| `whisper_distance` | `24.0` | |
|
||||
| `groups` | `true` | players may make and join groups |
|
||||
| `keep_alive_ms` | `1000` | a client silent 10 times this long shows as disconnected |
|
||||
| `mtu` | `1024` | |
|
||||
|
||||
## Files
|
||||
|
||||
| File | What it is |
|
||||
|---|---|
|
||||
| `mixins/voicechat_server/horizon-mixin.toml` | The bundle: the relay crate, the glue module and four HEAD hooks (mod messages, server tick, player logged out, world closing) |
|
||||
| `mixins/voicechat_server/relay/` | The relay crate (`voicechat_server_relay`): the wire format (`proto.rs`), the handshake, groups and UDP relay (`lib.rs`) and its tests over real UDP sockets (`tests.rs`). Depends on `ring` (AES-GCM) and `serde`, both in Horizon's `Cargo.lock` |
|
||||
| `mixins/voicechat_server/glue.rs` | Added to the game as `game::voicechat_server`: the config, starting the relay, the players each tick |
|
||||
|
||||
It needs horizon-mixins 0.6.0 (bundle crates) and hook points API 57. Tests: `cargo test` here.
|
||||
|
||||
Not made with or endorsed by Simple Voice Chat or its author: it speaks the same protocol, written from its packet
|
||||
layouts, with none of its code.
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"game": "26.3",
|
||||
"hooks": 57,
|
||||
"horizon_repository": "https://github.com/horizon-rust/horizon",
|
||||
"horizon_revision": "f4833621f9037e08c487b1779e8c030487e65cc2",
|
||||
"horizon_mixins_repository": "https://github.com/horizon-rust/horizon-mixins",
|
||||
"horizon_mixins_revision": "d9c83d7096b7ea5b83678da2efdfd6161f74997e"
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
// Voice chat's server half in the game (`game::voicechat_server`): the relay
|
||||
// (crate voicechat_server_relay) opens its UDP port when the first voice chat
|
||||
// client asks for a secret, hears the clients' messages through Horizon's mod
|
||||
// messages, and learns each tick where every player is. A player without a
|
||||
// voice chat client is never sent anything.
|
||||
|
||||
use crate::game::Game;
|
||||
use std::sync::Mutex;
|
||||
use std::time::{Duration, Instant};
|
||||
use voicechat_server_relay::{Config, Player, Server, proto};
|
||||
|
||||
/// Messages to one player a tick at most (mod messages allow 32, shared with other mods).
|
||||
const PER_TICK: usize = 24;
|
||||
/// The config file in the server's `config/` folder.
|
||||
const FILE: &str = "voicechat-server.json";
|
||||
/// After the port failed to open, wait this long before trying again.
|
||||
const RETRY: Duration = Duration::from_secs(60);
|
||||
|
||||
enum Relay {
|
||||
Off,
|
||||
Running(Server),
|
||||
Failed(Instant),
|
||||
}
|
||||
|
||||
static RELAY: Mutex<Relay> = Mutex::new(Relay::Off);
|
||||
|
||||
fn relay() -> std::sync::MutexGuard<'static, Relay> {
|
||||
RELAY.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
fn players(game: &Game<'_>) -> Vec<Player> {
|
||||
game.native_server_players()
|
||||
.into_iter()
|
||||
.map(|p| Player {
|
||||
uuid: p.uuid,
|
||||
name: p.name,
|
||||
dimension: p.dimension,
|
||||
position: p.position,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `config/voicechat-server.json`, written with its defaults the first time
|
||||
/// and again with the keys a newer version adds (values kept). A file that
|
||||
/// doesn't read is left alone and the defaults apply.
|
||||
fn config() -> Config {
|
||||
let Ok(dir) = Game::native_config_dir() else {
|
||||
return Config::default();
|
||||
};
|
||||
let path = dir.join(FILE);
|
||||
let config = match std::fs::read_to_string(&path) {
|
||||
Ok(text) => match serde_json::from_str::<Config>(&text) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"Voice chat: {} doesn't read ({e}); using the defaults",
|
||||
path.display()
|
||||
);
|
||||
return Config::default();
|
||||
}
|
||||
},
|
||||
Err(_) => Config::default(),
|
||||
};
|
||||
if let Ok(text) = serde_json::to_string_pretty(&config) {
|
||||
let _ = std::fs::write(&path, text + "\n");
|
||||
}
|
||||
config
|
||||
}
|
||||
|
||||
/// A message from a player's client: only `voicechat:` channels are ours.
|
||||
pub(super) fn message(game: &mut Game<'_>, player: u128, channel: &str, data: &[u8]) {
|
||||
if !channel.starts_with("voicechat:") {
|
||||
return;
|
||||
}
|
||||
let mut relay = relay();
|
||||
if !matches!(*relay, Relay::Running(_)) {
|
||||
if channel != proto::REQUEST_SECRET {
|
||||
return;
|
||||
}
|
||||
if let Relay::Failed(at) = *relay
|
||||
&& at.elapsed() < RETRY
|
||||
{
|
||||
return;
|
||||
}
|
||||
let config = config();
|
||||
match Server::start(config.clone()) {
|
||||
Ok(server) => {
|
||||
let at = server
|
||||
.local_addr()
|
||||
.map(|a| a.to_string())
|
||||
.unwrap_or_default();
|
||||
log::info!("Voice chat: relaying voices on UDP {at}");
|
||||
*relay = Relay::Running(server);
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"Voice chat: not started on UDP {}:{}: {e}",
|
||||
config.bind,
|
||||
config.port
|
||||
);
|
||||
*relay = Relay::Failed(Instant::now());
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
let Relay::Running(server) = &*relay else {
|
||||
return;
|
||||
};
|
||||
// A player who joined since the last tick (or a relay that just started):
|
||||
// learn who is on first. Not for every message: it lists every player.
|
||||
if !server.knows(player) {
|
||||
server.tick(players(game));
|
||||
}
|
||||
server.message(player, channel, data);
|
||||
}
|
||||
|
||||
/// Each server tick, once the relay runs.
|
||||
pub(super) fn tick(game: &mut Game<'_>) {
|
||||
let out = {
|
||||
let relay = relay();
|
||||
let Relay::Running(server) = &*relay else {
|
||||
return;
|
||||
};
|
||||
server.tick(players(game));
|
||||
server.outgoing(PER_TICK)
|
||||
};
|
||||
for (player, channel, data) in out {
|
||||
if let Err(e) = game.native_net_send_to_player(player, &channel, &data) {
|
||||
log::debug!("Voice chat: {channel} not sent: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn left(player: u128) {
|
||||
if let Relay::Running(server) = &*relay() {
|
||||
server.left(player);
|
||||
}
|
||||
}
|
||||
|
||||
/// The world closed: the port closes too (it opens again on the next request).
|
||||
pub(super) fn stop() {
|
||||
*relay() = Relay::Off;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
[mod]
|
||||
id = "voicechat_server"
|
||||
version = "1.0.0"
|
||||
priority = 100
|
||||
|
||||
# The commit it was tested on (the official server's engine).
|
||||
[compatibility]
|
||||
horizon_revision = "f4833621f9037e08c487b1779e8c030487e65cc2"
|
||||
|
||||
# The handshake, the groups and the UDP relay: plain Rust with its tests.
|
||||
[[crate]]
|
||||
id = "relay"
|
||||
path = "relay"
|
||||
|
||||
# The edges between the game and the relay.
|
||||
[[module]]
|
||||
id = "glue"
|
||||
file = "crates/horizon-client/src/game.rs"
|
||||
name = "voicechat_server"
|
||||
source = "glue.rs"
|
||||
|
||||
[[injection]]
|
||||
id = "net"
|
||||
kind = "HEAD"
|
||||
target = "horizon_client::game::native_hooks::Game<'_>::native_net_from_client"
|
||||
helper = "net.rs"
|
||||
bindings = [
|
||||
{ name = "game", ty = "&mut crate::game::Game<'_>", value = "&mut *self" },
|
||||
{ name = "player", ty = "u128", value = "player" },
|
||||
{ name = "channel", ty = "&str", value = "channel" },
|
||||
{ name = "data", ty = "&[u8]", value = "data" },
|
||||
]
|
||||
|
||||
[[injection]]
|
||||
id = "tick"
|
||||
kind = "HEAD"
|
||||
target = "horizon_client::game::native_hooks::Game<'_>::native_server_tick"
|
||||
helper = "tick.rs"
|
||||
bindings = [
|
||||
{ name = "game", ty = "&mut crate::game::Game<'_>", value = "&mut *self" },
|
||||
{ name = "phase", ty = "crate::game::native_lifecycle::ServerTickPhase", value = "phase" },
|
||||
]
|
||||
|
||||
[[injection]]
|
||||
id = "logged_out"
|
||||
kind = "HEAD"
|
||||
target = "horizon_client::game::native_hooks::Game<'_>::native_player_logged_out"
|
||||
helper = "logged_out.rs"
|
||||
bindings = [
|
||||
{ name = "player", ty = "u128", value = "player" },
|
||||
]
|
||||
|
||||
[[injection]]
|
||||
id = "lifecycle"
|
||||
kind = "HEAD"
|
||||
target = "horizon_client::game::native_hooks::Game<'_>::native_world_lifecycle"
|
||||
helper = "lifecycle.rs"
|
||||
bindings = [
|
||||
{ name = "phase", ty = "crate::game::native_lifecycle::WorldPhase", value = "phase" },
|
||||
]
|
||||
@@ -0,0 +1,10 @@
|
||||
// The world closing: the relay closes its UDP port.
|
||||
fn voicechat_server_lifecycle(phase: crate::game::native_lifecycle::WorldPhase) {
|
||||
use crate::game::native_lifecycle::WorldPhase;
|
||||
if matches!(
|
||||
phase,
|
||||
WorldPhase::WorldCloseFinal | WorldPhase::WorldCloseFailed
|
||||
) {
|
||||
crate::game::voicechat_server::stop();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
// A player left: the relay forgets them and tells the others.
|
||||
fn voicechat_server_logged_out(player: u128) {
|
||||
crate::game::voicechat_server::left(player);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
// A voice chat client's message (`voicechat:` channels) to the relay.
|
||||
fn voicechat_server_net(
|
||||
game: &mut crate::game::Game<'_>,
|
||||
player: u128,
|
||||
channel: &str,
|
||||
data: &[u8],
|
||||
) {
|
||||
crate::game::voicechat_server::message(game, player, channel, data);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "voicechat_server_relay"
|
||||
version = "1.0.0"
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
publish = false
|
||||
description = "Horizon's voice chat server: the handshake for voice chat clients and the UDP relay of their voices."
|
||||
|
||||
[dependencies]
|
||||
ring = "0.17"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json = "1"
|
||||
@@ -0,0 +1,788 @@
|
||||
//! The server half of proximity voice chat on Horizon: it answers the voice
|
||||
//! chat clients' handshake (sent over Horizon's mod messages) and relays their
|
||||
//! voices over UDP to the players who should hear them, by distance and
|
||||
//! group. Players without a voice chat client never hear of it: nothing is
|
||||
//! sent to a player until their client asks.
|
||||
//!
|
||||
//! Voices go through the server, never player to player, so nobody learns
|
||||
//! another player's address. Each player's packets are sealed with their own
|
||||
//! key (AES-128-GCM), which they get over the game connection.
|
||||
pub mod proto;
|
||||
|
||||
use proto::{FromClient, GroupKind, PlayerState, Request, ToClient};
|
||||
use ring::rand::{SecureRandom, SystemRandom};
|
||||
use std::collections::{HashMap, HashSet, VecDeque};
|
||||
use std::io;
|
||||
use std::net::{SocketAddr, UdpSocket};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex, MutexGuard};
|
||||
use std::thread::JoinHandle;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// `config/voicechat-server.json`.
|
||||
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Config {
|
||||
/// Off: no port is opened and clients get no answer.
|
||||
pub enabled: bool,
|
||||
/// The UDP port voices use. A host that gives one port for the game
|
||||
/// (TCP) usually forwards UDP on it too: use the game's port then.
|
||||
pub port: u16,
|
||||
/// The address the UDP socket binds.
|
||||
pub bind: String,
|
||||
/// Where clients send UDP, when not the game server's own address:
|
||||
/// a port, a host, or host:port.
|
||||
pub voice_host: String,
|
||||
/// How far a voice carries, in blocks.
|
||||
pub distance: f64,
|
||||
/// How far a whisper carries.
|
||||
pub whisper_distance: f64,
|
||||
/// Players may make and join groups.
|
||||
pub groups: bool,
|
||||
pub keep_alive_ms: u32,
|
||||
pub mtu: u32,
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
port: 24454,
|
||||
bind: "0.0.0.0".into(),
|
||||
voice_host: String::new(),
|
||||
distance: 48.0,
|
||||
whisper_distance: 24.0,
|
||||
groups: true,
|
||||
keep_alive_ms: 1000,
|
||||
mtu: 1024,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A player on the game server, as the last tick saw them.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Player {
|
||||
pub uuid: u128,
|
||||
pub name: String,
|
||||
pub dimension: String,
|
||||
pub position: [f64; 3],
|
||||
}
|
||||
|
||||
/// A message for a player's client: (player, channel, data).
|
||||
pub type Message = (u128, String, Vec<u8>);
|
||||
|
||||
/// Groups at most, and voice packets a second a player may send.
|
||||
const MAX_GROUPS: usize = 128;
|
||||
const MIC_PER_SECOND: f64 = 75.0;
|
||||
const MIC_BURST: f64 = 30.0;
|
||||
/// Group and voice state changes a second a player may make.
|
||||
const OPS_PER_SECOND: f64 = 2.0;
|
||||
const OPS_BURST: f64 = 10.0;
|
||||
/// Secrets a player's own requests get at most this often; more requests
|
||||
/// meanwhile are answered once, when the time is up.
|
||||
const SECRET_EVERY: Duration = Duration::from_secs(1);
|
||||
/// Messages waiting for one player at most; more are dropped.
|
||||
const MAX_QUEUED: usize = 512;
|
||||
/// The nonces of a player's last control packets remembered, to drop exact
|
||||
/// replays; and how far behind the newest a voice packet's sequence may be.
|
||||
const SEEN_NONCES: usize = 64;
|
||||
const MIC_WINDOW: i128 = 64;
|
||||
|
||||
/// A player's voice connection, for the life of one secret.
|
||||
///
|
||||
/// The first address that authenticates holds the secret: no other address
|
||||
/// can use it, so a handshake captured and sent again from elsewhere moves
|
||||
/// nothing. A client that moves (a NAT that rebinds) gets a fresh secret
|
||||
/// over the game connection, which the game session authenticates: when the
|
||||
/// server stops hearing it, or when it asks again. The old key then opens
|
||||
/// nothing.
|
||||
struct Client {
|
||||
secret: [u8; 16],
|
||||
cipher: proto::Cipher,
|
||||
addr: Option<SocketAddr>,
|
||||
/// Answered the connection check: its voice connection works.
|
||||
connected: bool,
|
||||
last_keep_alive: Instant,
|
||||
disabled: bool,
|
||||
group: Option<u128>,
|
||||
tokens: f64,
|
||||
refilled: Instant,
|
||||
ops: f64,
|
||||
ops_refilled: Instant,
|
||||
/// When this secret was given, and whether a request for another waits.
|
||||
issued: Instant,
|
||||
asked_again: bool,
|
||||
/// Nonces of the control packets already taken (the newest last).
|
||||
seen: VecDeque<[u8; 12]>,
|
||||
/// The newest voice sequence, and which of the 64 before it arrived.
|
||||
mic_top: Option<i64>,
|
||||
mic_seen: u64,
|
||||
}
|
||||
|
||||
impl Client {
|
||||
fn new(secret: [u8; 16], now: Instant) -> Self {
|
||||
Self {
|
||||
secret,
|
||||
cipher: proto::Cipher::new(&secret),
|
||||
addr: None,
|
||||
connected: false,
|
||||
last_keep_alive: now,
|
||||
disabled: false,
|
||||
group: None,
|
||||
tokens: MIC_BURST,
|
||||
refilled: now,
|
||||
ops: OPS_BURST,
|
||||
ops_refilled: now,
|
||||
issued: now,
|
||||
asked_again: false,
|
||||
seen: VecDeque::new(),
|
||||
mic_top: None,
|
||||
mic_seen: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// A control packet's nonce: false if it was already taken.
|
||||
fn fresh_nonce(&mut self, nonce: [u8; 12]) -> bool {
|
||||
if self.seen.contains(&nonce) {
|
||||
return false;
|
||||
}
|
||||
if self.seen.len() >= SEEN_NONCES {
|
||||
self.seen.pop_front();
|
||||
}
|
||||
self.seen.push_back(nonce);
|
||||
true
|
||||
}
|
||||
|
||||
/// A voice packet's sequence: false if it was already relayed or is too
|
||||
/// old (a replay). A client counts up from 0 for each secret.
|
||||
fn fresh_sequence(&mut self, sequence: i64) -> bool {
|
||||
let Some(top) = self.mic_top else {
|
||||
self.mic_top = Some(sequence);
|
||||
self.mic_seen = 1;
|
||||
return true;
|
||||
};
|
||||
let ahead = i128::from(sequence) - i128::from(top);
|
||||
if ahead > 0 {
|
||||
self.mic_seen = if ahead >= MIC_WINDOW {
|
||||
1
|
||||
} else {
|
||||
(self.mic_seen << ahead) | 1
|
||||
};
|
||||
self.mic_top = Some(sequence);
|
||||
return true;
|
||||
}
|
||||
let back = -ahead;
|
||||
if back >= MIC_WINDOW {
|
||||
return false;
|
||||
}
|
||||
let bit = 1u64 << back;
|
||||
if self.mic_seen & bit != 0 {
|
||||
return false;
|
||||
}
|
||||
self.mic_seen |= bit;
|
||||
true
|
||||
}
|
||||
|
||||
/// One group or state change, if the player has one left.
|
||||
fn spend_op(&mut self, now: Instant) -> bool {
|
||||
self.ops = (self.ops
|
||||
+ now.duration_since(self.ops_refilled).as_secs_f64() * OPS_PER_SECOND)
|
||||
.min(OPS_BURST);
|
||||
self.ops_refilled = now;
|
||||
if self.ops < 1.0 {
|
||||
return false;
|
||||
}
|
||||
self.ops -= 1.0;
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
struct Group {
|
||||
name: String,
|
||||
password: Option<String>,
|
||||
kind: GroupKind,
|
||||
}
|
||||
|
||||
struct State {
|
||||
config: Config,
|
||||
/// The UDP port the socket got.
|
||||
port: u16,
|
||||
clients: HashMap<u128, Client>,
|
||||
players: HashMap<u128, Player>,
|
||||
groups: HashMap<u128, Group>,
|
||||
/// Players whose state changed since the last tick, to tell everyone.
|
||||
changed: HashSet<u128>,
|
||||
outbox: HashMap<u128, VecDeque<(String, Vec<u8>)>>,
|
||||
}
|
||||
|
||||
struct Shared {
|
||||
state: Mutex<State>,
|
||||
socket: UdpSocket,
|
||||
rng: SystemRandom,
|
||||
running: AtomicBool,
|
||||
}
|
||||
|
||||
/// The running voice server: its UDP socket and thread. Dropping it stops them.
|
||||
pub struct Server {
|
||||
shared: Arc<Shared>,
|
||||
thread: Option<JoinHandle<()>>,
|
||||
}
|
||||
|
||||
fn lock(m: &Mutex<State>) -> MutexGuard<'_, State> {
|
||||
m.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
impl Server {
|
||||
/// Opens the UDP port and starts relaying.
|
||||
pub fn start(config: Config) -> io::Result<Self> {
|
||||
if !config.enabled {
|
||||
return Err(io::Error::other("voice chat is turned off in its config"));
|
||||
}
|
||||
let socket = UdpSocket::bind((config.bind.as_str(), config.port))?;
|
||||
socket.set_read_timeout(Some(Duration::from_millis(100)))?;
|
||||
let port = socket.local_addr()?.port();
|
||||
let shared = Arc::new(Shared {
|
||||
state: Mutex::new(State {
|
||||
config,
|
||||
port,
|
||||
clients: HashMap::new(),
|
||||
players: HashMap::new(),
|
||||
groups: HashMap::new(),
|
||||
changed: HashSet::new(),
|
||||
outbox: HashMap::new(),
|
||||
}),
|
||||
socket,
|
||||
rng: SystemRandom::new(),
|
||||
running: AtomicBool::new(true),
|
||||
});
|
||||
let worker = shared.clone();
|
||||
let thread = std::thread::Builder::new()
|
||||
.name("voicechat-relay".into())
|
||||
.spawn(move || relay(&worker))?;
|
||||
Ok(Self {
|
||||
shared,
|
||||
thread: Some(thread),
|
||||
})
|
||||
}
|
||||
|
||||
/// The UDP address voices arrive on.
|
||||
pub fn local_addr(&self) -> io::Result<SocketAddr> {
|
||||
self.shared.socket.local_addr()
|
||||
}
|
||||
|
||||
/// Each server tick: the players on the server now. Voice chat forgets
|
||||
/// the ones who left, and tells everyone what changed.
|
||||
pub fn tick(&self, players: Vec<Player>) {
|
||||
let mut st = lock(&self.shared.state);
|
||||
st.players = players.into_iter().map(|p| (p.uuid, p)).collect();
|
||||
let gone: Vec<u128> = st
|
||||
.clients
|
||||
.keys()
|
||||
.filter(|u| !st.players.contains_key(u))
|
||||
.copied()
|
||||
.collect();
|
||||
for uuid in gone {
|
||||
st.forget(uuid);
|
||||
}
|
||||
// Requests that came too soon after a secret get one now.
|
||||
let due: Vec<u128> = st
|
||||
.clients
|
||||
.iter()
|
||||
.filter(|(_, c)| c.asked_again && c.issued.elapsed() >= SECRET_EVERY)
|
||||
.map(|(u, _)| *u)
|
||||
.collect();
|
||||
for uuid in due {
|
||||
st.give_secret(&self.shared.rng, uuid, false);
|
||||
}
|
||||
let changed: Vec<u128> = st.changed.drain().collect();
|
||||
for uuid in changed {
|
||||
if let Some(s) = st.player_state(uuid) {
|
||||
st.tell_all(proto::STATE, proto::state(&s));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `player` was on the server at the last tick.
|
||||
pub fn knows(&self, player: u128) -> bool {
|
||||
lock(&self.shared.state).players.contains_key(&player)
|
||||
}
|
||||
|
||||
/// A player left the game.
|
||||
pub fn left(&self, player: u128) {
|
||||
lock(&self.shared.state).forget(player);
|
||||
}
|
||||
|
||||
/// A message from `player`'s client on a `voicechat:` channel.
|
||||
pub fn message(&self, player: u128, channel: &str, data: &[u8]) {
|
||||
let Some(Ok(request)) = Request::read(channel, data) else {
|
||||
return;
|
||||
};
|
||||
let mut st = lock(&self.shared.state);
|
||||
if !st.players.contains_key(&player) {
|
||||
return;
|
||||
}
|
||||
match request {
|
||||
Request::Secret { version } => {
|
||||
if version != proto::COMPATIBILITY_VERSION {
|
||||
return;
|
||||
}
|
||||
if let Some(c) = st.clients.get_mut(&player)
|
||||
&& c.issued.elapsed() < SECRET_EVERY
|
||||
{
|
||||
c.asked_again = true;
|
||||
return;
|
||||
}
|
||||
st.give_secret(&self.shared.rng, player, false);
|
||||
}
|
||||
Request::State { disabled } => {
|
||||
if let Some(c) = st.clients.get_mut(&player)
|
||||
&& c.spend_op(Instant::now())
|
||||
&& c.disabled != disabled
|
||||
{
|
||||
c.disabled = disabled;
|
||||
st.changed.insert(player);
|
||||
}
|
||||
}
|
||||
Request::CreateGroup {
|
||||
name,
|
||||
password,
|
||||
kind,
|
||||
} => {
|
||||
let name = name.trim().to_owned();
|
||||
if !st.config.groups
|
||||
|| name.is_empty()
|
||||
|| st.groups.len() >= MAX_GROUPS
|
||||
|| !st.spend_op(player)
|
||||
{
|
||||
return;
|
||||
}
|
||||
let mut id = [0u8; 16];
|
||||
if self.shared.rng.fill(&mut id).is_err() {
|
||||
return;
|
||||
}
|
||||
let id = u128::from_be_bytes(id);
|
||||
let password = password
|
||||
.map(|p| p.trim().to_owned())
|
||||
.filter(|p| !p.is_empty());
|
||||
let add = proto::add_group(id, &name, password.is_some(), kind);
|
||||
st.groups.insert(
|
||||
id,
|
||||
Group {
|
||||
name,
|
||||
password,
|
||||
kind,
|
||||
},
|
||||
);
|
||||
st.tell_all(proto::ADD_GROUP, add);
|
||||
st.join(player, id);
|
||||
}
|
||||
Request::JoinGroup { group, password } => {
|
||||
if !st.config.groups || !st.groups.contains_key(&group) || !st.spend_op(player) {
|
||||
return;
|
||||
}
|
||||
let Some(g) = st.groups.get(&group) else {
|
||||
return;
|
||||
};
|
||||
let ok = g.password.is_none()
|
||||
|| g.password.as_deref() == password.as_deref().map(str::trim);
|
||||
if ok {
|
||||
st.join(player, group);
|
||||
} else {
|
||||
let current = st.clients.get(&player).and_then(|c| c.group);
|
||||
st.tell(
|
||||
player,
|
||||
proto::JOINED_GROUP,
|
||||
proto::joined_group(current, true),
|
||||
);
|
||||
}
|
||||
}
|
||||
Request::LeaveGroup => {
|
||||
let Some(c) = st.clients.get_mut(&player) else {
|
||||
return;
|
||||
};
|
||||
if c.group.is_none() || !c.spend_op(Instant::now()) {
|
||||
return;
|
||||
}
|
||||
let Some(g) = c.group.take() else { return };
|
||||
st.tell(
|
||||
player,
|
||||
proto::JOINED_GROUP,
|
||||
proto::joined_group(None, false),
|
||||
);
|
||||
st.changed.insert(player);
|
||||
st.leave_group_cleanup(g);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What to send to the players' clients now, at most `per_player` messages each (the rest wait for the
|
||||
/// next call).
|
||||
pub fn outgoing(&self, per_player: usize) -> Vec<Message> {
|
||||
let mut st = lock(&self.shared.state);
|
||||
let mut out = Vec::new();
|
||||
st.outbox.retain(|player, queue| {
|
||||
for _ in 0..per_player {
|
||||
let Some((channel, data)) = queue.pop_front() else {
|
||||
break;
|
||||
};
|
||||
out.push((*player, channel, data));
|
||||
}
|
||||
!queue.is_empty()
|
||||
});
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Server {
|
||||
fn drop(&mut self) {
|
||||
self.shared.running.store(false, Ordering::Relaxed);
|
||||
if let Some(t) = self.thread.take() {
|
||||
let _ = t.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl State {
|
||||
/// Queues a message for `player`'s client (dropped when MAX_QUEUED wait).
|
||||
fn tell(&mut self, player: u128, channel: &str, data: Vec<u8>) {
|
||||
let queue = self.outbox.entry(player).or_default();
|
||||
if queue.len() < MAX_QUEUED {
|
||||
queue.push_back((channel.to_owned(), data));
|
||||
}
|
||||
}
|
||||
|
||||
fn spend_op(&mut self, player: u128) -> bool {
|
||||
self.clients
|
||||
.get_mut(&player)
|
||||
.is_some_and(|c| c.spend_op(Instant::now()))
|
||||
}
|
||||
|
||||
/// A fresh secret for `player`: the old one opens nothing any more.
|
||||
/// `rekey`: the server's own, for a client it stopped hearing (it keeps its
|
||||
/// group and state, and gets only the secret). Otherwise the client asked:
|
||||
/// it starts over and gets everyone's state and the groups, instead of
|
||||
/// whatever still waited for it.
|
||||
fn give_secret(&mut self, rng: &SystemRandom, player: u128, rekey: bool) {
|
||||
let mut secret = [0u8; 16];
|
||||
if rng.fill(&mut secret).is_err() {
|
||||
return;
|
||||
}
|
||||
let mut client = Client::new(secret, Instant::now());
|
||||
let old = self.clients.remove(&player);
|
||||
if rekey && let Some(old) = &old {
|
||||
client.group = old.group;
|
||||
client.disabled = old.disabled;
|
||||
client.ops = old.ops;
|
||||
client.ops_refilled = old.ops_refilled;
|
||||
}
|
||||
self.clients.insert(player, client);
|
||||
if !rekey && let Some(g) = old.and_then(|o| o.group) {
|
||||
self.leave_group_cleanup(g);
|
||||
}
|
||||
let c = &self.config;
|
||||
let body = proto::secret(&proto::SecretInfo {
|
||||
secret: &secret,
|
||||
port: self.port,
|
||||
player,
|
||||
mtu: c.mtu.min(i32::MAX as u32) as i32,
|
||||
distance: c.distance,
|
||||
keep_alive_ms: c.keep_alive_ms.clamp(100, 60_000) as i32,
|
||||
groups: c.groups,
|
||||
voice_host: &c.voice_host,
|
||||
});
|
||||
self.changed.insert(player);
|
||||
if rekey {
|
||||
// Past the cap: the secret is what gets the client back.
|
||||
self.outbox
|
||||
.entry(player)
|
||||
.or_default()
|
||||
.push_back((proto::SECRET.to_owned(), body));
|
||||
return;
|
||||
}
|
||||
self.outbox.remove(&player);
|
||||
self.tell(player, proto::SECRET, body);
|
||||
let all: Vec<PlayerState> = self
|
||||
.clients
|
||||
.keys()
|
||||
.filter_map(|u| self.player_state(*u))
|
||||
.collect();
|
||||
self.tell(player, proto::STATES, proto::states(&all));
|
||||
let groups: Vec<Vec<u8>> = self
|
||||
.groups
|
||||
.iter()
|
||||
.map(|(id, g)| proto::add_group(*id, &g.name, g.password.is_some(), g.kind))
|
||||
.collect();
|
||||
for g in groups {
|
||||
self.tell(player, proto::ADD_GROUP, g);
|
||||
}
|
||||
}
|
||||
|
||||
/// To every player with a voice chat client.
|
||||
fn tell_all(&mut self, channel: &str, data: Vec<u8>) {
|
||||
let all: Vec<u128> = self.clients.keys().copied().collect();
|
||||
for p in all {
|
||||
self.tell(p, channel, data.clone());
|
||||
}
|
||||
}
|
||||
|
||||
fn player_state(&self, uuid: u128) -> Option<PlayerState> {
|
||||
let c = self.clients.get(&uuid)?;
|
||||
Some(PlayerState {
|
||||
uuid,
|
||||
name: self
|
||||
.players
|
||||
.get(&uuid)
|
||||
.map(|p| p.name.clone())
|
||||
.unwrap_or_default(),
|
||||
disabled: c.disabled,
|
||||
disconnected: !c.connected,
|
||||
group: c.group,
|
||||
})
|
||||
}
|
||||
|
||||
fn join(&mut self, player: u128, group: u128) {
|
||||
let Some(c) = self.clients.get_mut(&player) else {
|
||||
return;
|
||||
};
|
||||
let old = c.group.replace(group);
|
||||
self.tell(
|
||||
player,
|
||||
proto::JOINED_GROUP,
|
||||
proto::joined_group(Some(group), false),
|
||||
);
|
||||
self.changed.insert(player);
|
||||
if let Some(old) = old
|
||||
&& old != group
|
||||
{
|
||||
self.leave_group_cleanup(old);
|
||||
}
|
||||
}
|
||||
|
||||
/// An empty group goes away.
|
||||
fn leave_group_cleanup(&mut self, group: u128) {
|
||||
if self.clients.values().any(|c| c.group == Some(group)) {
|
||||
return;
|
||||
}
|
||||
if self.groups.remove(&group).is_some() {
|
||||
self.tell_all(proto::REMOVE_GROUP, proto::remove_group(group));
|
||||
}
|
||||
}
|
||||
|
||||
fn forget(&mut self, uuid: u128) {
|
||||
self.outbox.remove(&uuid);
|
||||
self.changed.remove(&uuid);
|
||||
let Some(c) = self.clients.remove(&uuid) else {
|
||||
return;
|
||||
};
|
||||
self.tell_all(proto::REMOVE_STATE, proto::remove_state(uuid));
|
||||
if let Some(g) = c.group {
|
||||
self.leave_group_cleanup(g);
|
||||
}
|
||||
}
|
||||
|
||||
/// Who hears `sender`, and how: (listener, address, positional range or None for the group's voice).
|
||||
fn listeners(&self, sender: u128, whispering: bool) -> Vec<(u128, SocketAddr, Option<f64>)> {
|
||||
let Some(from) = self.clients.get(&sender) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let from_kind = from.group.and_then(|g| self.groups.get(&g)).map(|g| g.kind);
|
||||
let at = self.players.get(&sender);
|
||||
let range = if whispering {
|
||||
self.config.whisper_distance
|
||||
} else {
|
||||
self.config.distance
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
for (uuid, c) in &self.clients {
|
||||
if *uuid == sender || c.disabled || !c.connected {
|
||||
continue;
|
||||
}
|
||||
let Some(addr) = c.addr else { continue };
|
||||
if from.group.is_some() && from.group == c.group {
|
||||
out.push((*uuid, addr, None));
|
||||
continue;
|
||||
}
|
||||
let to_kind = c.group.and_then(|g| self.groups.get(&g)).map(|g| g.kind);
|
||||
// Normal and isolated groups aren't heard outside; isolated ones hear nobody outside.
|
||||
if matches!(from_kind, Some(GroupKind::Normal | GroupKind::Isolated))
|
||||
|| to_kind == Some(GroupKind::Isolated)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let (Some(a), Some(b)) = (at, self.players.get(uuid)) else {
|
||||
continue;
|
||||
};
|
||||
if a.dimension != b.dimension {
|
||||
continue;
|
||||
}
|
||||
let d = (0..3)
|
||||
.map(|i| (a.position[i] - b.position[i]).powi(2))
|
||||
.sum::<f64>()
|
||||
.sqrt();
|
||||
if d <= range {
|
||||
out.push((*uuid, addr, Some(range)));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// The UDP side: handshakes, keep-alives and voices.
|
||||
fn relay(shared: &Shared) {
|
||||
let mut buf = vec![0u8; 4096];
|
||||
let mut last_keep_alive = Instant::now();
|
||||
while shared.running.load(Ordering::Relaxed) {
|
||||
match shared.socket.recv_from(&mut buf) {
|
||||
Ok((n, from)) => datagram(shared, &buf[..n], from),
|
||||
Err(e)
|
||||
if matches!(
|
||||
e.kind(),
|
||||
io::ErrorKind::WouldBlock | io::ErrorKind::TimedOut
|
||||
) => {}
|
||||
// Never spin on an error that comes back at once.
|
||||
Err(_) => std::thread::sleep(Duration::from_millis(10)),
|
||||
}
|
||||
let every = Duration::from_millis(u64::from(
|
||||
lock(&shared.state).config.keep_alive_ms.clamp(100, 60_000),
|
||||
));
|
||||
if last_keep_alive.elapsed() >= every {
|
||||
last_keep_alive = Instant::now();
|
||||
keep_alive(shared, every);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn send(shared: &Shared, cipher: &proto::Cipher, to: SocketAddr, packet: &ToClient) {
|
||||
if let Some(sealed) = cipher.seal(&shared.rng, &packet.write()) {
|
||||
let _ = shared.socket.send_to(&proto::server_datagram(&sealed), to);
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep-alives to every authenticated client. One that stopped answering
|
||||
/// gets a fresh secret over the game connection: if it moved (its NAT
|
||||
/// rebound), it comes back from its new address with that secret.
|
||||
fn keep_alive(shared: &Shared, every: Duration) {
|
||||
let mut st = lock(&shared.state);
|
||||
let mut lost = Vec::new();
|
||||
for (uuid, c) in st.clients.iter_mut() {
|
||||
let Some(addr) = c.addr else { continue };
|
||||
if c.connected && c.last_keep_alive.elapsed() > every * 10 {
|
||||
c.connected = false;
|
||||
lost.push(*uuid);
|
||||
continue;
|
||||
}
|
||||
send(shared, &c.cipher, addr, &ToClient::KeepAlive);
|
||||
}
|
||||
for uuid in lost {
|
||||
st.give_secret(&shared.rng, uuid, true);
|
||||
}
|
||||
}
|
||||
|
||||
fn datagram(shared: &Shared, datagram: &[u8], from: SocketAddr) {
|
||||
let Ok((player, sealed)) = proto::client_datagram(datagram) else {
|
||||
return;
|
||||
};
|
||||
let mut st = lock(&shared.state);
|
||||
let Some(c) = st.clients.get_mut(&player) else {
|
||||
return;
|
||||
};
|
||||
let Some(plain) = c.cipher.open(sealed) else {
|
||||
return;
|
||||
};
|
||||
let Ok(packet) = FromClient::read(&plain) else {
|
||||
return;
|
||||
};
|
||||
// It opened, so it holds at least a nonce and a tag.
|
||||
let mut nonce = [0u8; 12];
|
||||
nonce.copy_from_slice(&sealed[..12]);
|
||||
let now = Instant::now();
|
||||
if let FromClient::Authenticate {
|
||||
player: who,
|
||||
secret,
|
||||
} = packet
|
||||
{
|
||||
if who != player || !constant_eq(&secret, &c.secret) {
|
||||
return;
|
||||
}
|
||||
// The secret belongs to the first address that authenticated with it.
|
||||
if c.addr.is_some_and(|a| a != from) || !c.fresh_nonce(nonce) {
|
||||
return;
|
||||
}
|
||||
c.addr = Some(from);
|
||||
send(shared, &c.cipher, from, &ToClient::AuthenticateAck);
|
||||
return;
|
||||
}
|
||||
// Everything else only from the address that authenticated.
|
||||
if c.addr != Some(from) {
|
||||
return;
|
||||
}
|
||||
if let FromClient::Mic { sequence, .. } = packet {
|
||||
if !c.fresh_sequence(sequence) {
|
||||
return;
|
||||
}
|
||||
} else if !c.fresh_nonce(nonce) {
|
||||
return;
|
||||
}
|
||||
match packet {
|
||||
FromClient::ConnectionCheck => {
|
||||
c.last_keep_alive = now;
|
||||
if !c.connected {
|
||||
c.connected = true;
|
||||
st.changed.insert(player);
|
||||
}
|
||||
let c = &st.clients[&player];
|
||||
send(shared, &c.cipher, from, &ToClient::ConnectionCheckAck);
|
||||
}
|
||||
FromClient::KeepAlive => {
|
||||
c.last_keep_alive = now;
|
||||
if !c.connected {
|
||||
c.connected = true;
|
||||
st.changed.insert(player);
|
||||
}
|
||||
}
|
||||
// The server never pings, so this isn't an answer: not echoed (a
|
||||
// client that echoes pings would otherwise bounce it forever).
|
||||
FromClient::Ping { .. } => {}
|
||||
FromClient::Mic {
|
||||
data,
|
||||
sequence,
|
||||
whispering,
|
||||
} => {
|
||||
if c.disabled || !c.connected {
|
||||
return;
|
||||
}
|
||||
c.tokens = (c.tokens + now.duration_since(c.refilled).as_secs_f64() * MIC_PER_SECOND)
|
||||
.min(MIC_BURST);
|
||||
c.refilled = now;
|
||||
if c.tokens < 1.0 {
|
||||
return;
|
||||
}
|
||||
c.tokens -= 1.0;
|
||||
for (listener, addr, range) in st.listeners(player, whispering) {
|
||||
let packet = match range {
|
||||
Some(r) => ToClient::Positional {
|
||||
sender: player,
|
||||
data: &data,
|
||||
sequence,
|
||||
distance: r as f32,
|
||||
whispering,
|
||||
},
|
||||
None => ToClient::Group {
|
||||
sender: player,
|
||||
data: &data,
|
||||
sequence,
|
||||
},
|
||||
};
|
||||
send(shared, &st.clients[&listener].cipher, addr, &packet);
|
||||
}
|
||||
}
|
||||
FromClient::Authenticate { .. } => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn constant_eq(a: &[u8], b: &[u8]) -> bool {
|
||||
a.len() == b.len() && a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,734 @@
|
||||
//! The voice chat wire format Simple Voice Chat 2.6.x clients speak
|
||||
//! (compatibility version 20), from the server's side: control messages on
|
||||
//! `voicechat:` channels over the game connection, and AES-128-GCM UDP
|
||||
//! datagrams for the voice itself.
|
||||
//!
|
||||
//! Numbers are big-endian, a UUID is 16 bytes (most significant half first),
|
||||
//! strings and byte arrays are a VarInt length then the bytes.
|
||||
|
||||
/// The only version this server speaks.
|
||||
pub const COMPATIBILITY_VERSION: i32 = 20;
|
||||
/// Every UDP datagram starts with it.
|
||||
pub const MAGIC: u8 = 0xFF;
|
||||
/// The largest Opus frame relayed.
|
||||
pub const MAX_OPUS: usize = 1275;
|
||||
/// The largest encrypted body read from a datagram.
|
||||
pub const MAX_BODY: usize = 2048;
|
||||
/// Group names and passwords, in characters.
|
||||
pub const MAX_GROUP_TEXT: usize = 24;
|
||||
|
||||
pub const REQUEST_SECRET: &str = "voicechat:request_secret";
|
||||
pub const SECRET: &str = "voicechat:secret";
|
||||
pub const UPDATE_STATE: &str = "voicechat:update_state";
|
||||
pub const STATE: &str = "voicechat:state";
|
||||
pub const STATES: &str = "voicechat:states";
|
||||
pub const REMOVE_STATE: &str = "voicechat:remove_state";
|
||||
pub const CREATE_GROUP: &str = "voicechat:create_group";
|
||||
pub const SET_GROUP: &str = "voicechat:set_group";
|
||||
pub const LEAVE_GROUP: &str = "voicechat:leave_group";
|
||||
pub const JOINED_GROUP: &str = "voicechat:joined_group";
|
||||
pub const ADD_GROUP: &str = "voicechat:add_group";
|
||||
pub const REMOVE_GROUP: &str = "voicechat:remove_group";
|
||||
|
||||
/// A message that can't be read.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Malformed;
|
||||
|
||||
type Read<T> = Result<T, Malformed>;
|
||||
|
||||
pub struct In<'a> {
|
||||
rest: &'a [u8],
|
||||
}
|
||||
|
||||
impl<'a> In<'a> {
|
||||
pub fn new(data: &'a [u8]) -> Self {
|
||||
Self { rest: data }
|
||||
}
|
||||
pub fn take(&mut self, n: usize) -> Read<&'a [u8]> {
|
||||
if self.rest.len() < n {
|
||||
return Err(Malformed);
|
||||
}
|
||||
let (head, tail) = self.rest.split_at(n);
|
||||
self.rest = tail;
|
||||
Ok(head)
|
||||
}
|
||||
fn fixed<const N: usize>(&mut self) -> Read<[u8; N]> {
|
||||
let mut out = [0; N];
|
||||
out.copy_from_slice(self.take(N)?);
|
||||
Ok(out)
|
||||
}
|
||||
pub fn byte(&mut self) -> Read<u8> {
|
||||
Ok(self.take(1)?[0])
|
||||
}
|
||||
pub fn flag(&mut self) -> Read<bool> {
|
||||
Ok(self.byte()? != 0)
|
||||
}
|
||||
pub fn short(&mut self) -> Read<i16> {
|
||||
Ok(i16::from_be_bytes(self.fixed()?))
|
||||
}
|
||||
pub fn int(&mut self) -> Read<i32> {
|
||||
Ok(i32::from_be_bytes(self.fixed()?))
|
||||
}
|
||||
pub fn long(&mut self) -> Read<i64> {
|
||||
Ok(i64::from_be_bytes(self.fixed()?))
|
||||
}
|
||||
pub fn uuid(&mut self) -> Read<u128> {
|
||||
Ok(u128::from_be_bytes(self.fixed()?))
|
||||
}
|
||||
pub fn varint(&mut self) -> Read<i32> {
|
||||
let mut value = 0u32;
|
||||
for shift in 0..5 {
|
||||
let b = self.byte()?;
|
||||
value |= u32::from(b & 0x7F) << (shift * 7);
|
||||
if b & 0x80 == 0 {
|
||||
return Ok(value as i32);
|
||||
}
|
||||
}
|
||||
Err(Malformed)
|
||||
}
|
||||
/// A VarInt length of at most `max`, then that many bytes.
|
||||
pub fn bytes(&mut self, max: usize) -> Read<&'a [u8]> {
|
||||
let n = usize::try_from(self.varint()?).map_err(|_| Malformed)?;
|
||||
if n > max {
|
||||
return Err(Malformed);
|
||||
}
|
||||
self.take(n)
|
||||
}
|
||||
/// A string of at most `max` characters.
|
||||
pub fn text(&mut self, max: usize) -> Read<String> {
|
||||
let raw = self.bytes(max.saturating_mul(4))?;
|
||||
let s = std::str::from_utf8(raw).map_err(|_| Malformed)?;
|
||||
if s.chars().count() > max {
|
||||
return Err(Malformed);
|
||||
}
|
||||
Ok(s.to_owned())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Out(pub Vec<u8>);
|
||||
|
||||
impl Out {
|
||||
pub fn byte(&mut self, v: u8) -> &mut Self {
|
||||
self.0.push(v);
|
||||
self
|
||||
}
|
||||
pub fn flag(&mut self, v: bool) -> &mut Self {
|
||||
self.byte(u8::from(v))
|
||||
}
|
||||
pub fn short(&mut self, v: i16) -> &mut Self {
|
||||
self.0.extend(v.to_be_bytes());
|
||||
self
|
||||
}
|
||||
pub fn int(&mut self, v: i32) -> &mut Self {
|
||||
self.0.extend(v.to_be_bytes());
|
||||
self
|
||||
}
|
||||
pub fn long(&mut self, v: i64) -> &mut Self {
|
||||
self.0.extend(v.to_be_bytes());
|
||||
self
|
||||
}
|
||||
pub fn float(&mut self, v: f32) -> &mut Self {
|
||||
self.0.extend(v.to_be_bytes());
|
||||
self
|
||||
}
|
||||
pub fn double(&mut self, v: f64) -> &mut Self {
|
||||
self.0.extend(v.to_be_bytes());
|
||||
self
|
||||
}
|
||||
pub fn uuid(&mut self, v: u128) -> &mut Self {
|
||||
self.0.extend(v.to_be_bytes());
|
||||
self
|
||||
}
|
||||
pub fn varint(&mut self, v: i32) -> &mut Self {
|
||||
let mut v = v as u32;
|
||||
while v >= 0x80 {
|
||||
self.0.push((v as u8) | 0x80);
|
||||
v >>= 7;
|
||||
}
|
||||
self.0.push(v as u8);
|
||||
self
|
||||
}
|
||||
pub fn bytes(&mut self, b: &[u8]) -> &mut Self {
|
||||
self.varint(b.len() as i32);
|
||||
self.0.extend_from_slice(b);
|
||||
self
|
||||
}
|
||||
pub fn text(&mut self, s: &str) -> &mut Self {
|
||||
self.bytes(s.as_bytes())
|
||||
}
|
||||
pub fn done(&mut self) -> Vec<u8> {
|
||||
std::mem::take(&mut self.0)
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ the game connection
|
||||
|
||||
/// How a group hears the world.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum GroupKind {
|
||||
/// Members hear each other anywhere and players nearby; nobody outside hears them.
|
||||
Normal,
|
||||
/// Members hear each other anywhere; players nearby hear them and are heard.
|
||||
Open,
|
||||
/// Members hear only each other.
|
||||
Isolated,
|
||||
}
|
||||
|
||||
impl GroupKind {
|
||||
pub fn from_wire(v: i16) -> Self {
|
||||
match v {
|
||||
1 => Self::Open,
|
||||
2 => Self::Isolated,
|
||||
_ => Self::Normal,
|
||||
}
|
||||
}
|
||||
pub fn wire(self) -> i16 {
|
||||
match self {
|
||||
Self::Normal => 0,
|
||||
Self::Open => 1,
|
||||
Self::Isolated => 2,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What a client asks on a `voicechat:` channel.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Request {
|
||||
Secret {
|
||||
version: i32,
|
||||
},
|
||||
State {
|
||||
disabled: bool,
|
||||
},
|
||||
CreateGroup {
|
||||
name: String,
|
||||
password: Option<String>,
|
||||
kind: GroupKind,
|
||||
},
|
||||
JoinGroup {
|
||||
group: u128,
|
||||
password: Option<String>,
|
||||
},
|
||||
LeaveGroup,
|
||||
}
|
||||
|
||||
impl Request {
|
||||
/// None: not a channel a client sends on.
|
||||
pub fn read(channel: &str, data: &[u8]) -> Option<Read<Self>> {
|
||||
let mut r = In::new(data);
|
||||
let parsed = match channel {
|
||||
REQUEST_SECRET => r.int().map(|version| Self::Secret { version }),
|
||||
UPDATE_STATE => r.flag().map(|disabled| Self::State { disabled }),
|
||||
CREATE_GROUP => (|| {
|
||||
let name = r.text(MAX_GROUP_TEXT)?;
|
||||
let password = if r.flag()? {
|
||||
Some(r.text(MAX_GROUP_TEXT)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let kind = GroupKind::from_wire(r.short()?);
|
||||
Ok(Self::CreateGroup {
|
||||
name,
|
||||
password,
|
||||
kind,
|
||||
})
|
||||
})(),
|
||||
SET_GROUP => (|| {
|
||||
let group = r.uuid()?;
|
||||
let password = if r.flag()? {
|
||||
Some(r.text(MAX_GROUP_TEXT)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Ok(Self::JoinGroup { group, password })
|
||||
})(),
|
||||
LEAVE_GROUP => Ok(Self::LeaveGroup),
|
||||
_ => return None,
|
||||
};
|
||||
Some(parsed)
|
||||
}
|
||||
}
|
||||
|
||||
/// `voicechat:secret`: what the client needs to open its voice connection.
|
||||
pub struct SecretInfo<'a> {
|
||||
pub secret: &'a [u8; 16],
|
||||
pub port: u16,
|
||||
pub player: u128,
|
||||
pub mtu: i32,
|
||||
pub distance: f64,
|
||||
pub keep_alive_ms: i32,
|
||||
pub groups: bool,
|
||||
/// Empty: the game server's own address.
|
||||
pub voice_host: &'a str,
|
||||
}
|
||||
|
||||
pub fn secret(s: &SecretInfo) -> Vec<u8> {
|
||||
let mut o = Out::default();
|
||||
o.0.extend_from_slice(s.secret);
|
||||
o.int(i32::from(s.port))
|
||||
.uuid(s.player)
|
||||
.byte(0) // the Opus application: VoIP
|
||||
.int(s.mtu)
|
||||
.double(s.distance)
|
||||
.int(s.keep_alive_ms)
|
||||
.flag(s.groups)
|
||||
.text(s.voice_host)
|
||||
.flag(false) // recording not allowed
|
||||
.done()
|
||||
}
|
||||
|
||||
/// A player's voice chat state, as `voicechat:state` and each of `voicechat:states` carry it.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PlayerState {
|
||||
pub uuid: u128,
|
||||
pub name: String,
|
||||
pub disabled: bool,
|
||||
pub disconnected: bool,
|
||||
pub group: Option<u128>,
|
||||
}
|
||||
|
||||
fn put_state(o: &mut Out, s: &PlayerState) {
|
||||
o.flag(s.disabled)
|
||||
.flag(s.disconnected)
|
||||
.uuid(s.uuid)
|
||||
.text(&s.name)
|
||||
.flag(s.group.is_some());
|
||||
if let Some(g) = s.group {
|
||||
o.uuid(g);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn state(s: &PlayerState) -> Vec<u8> {
|
||||
let mut o = Out::default();
|
||||
put_state(&mut o, s);
|
||||
o.done()
|
||||
}
|
||||
|
||||
pub fn states(all: &[PlayerState]) -> Vec<u8> {
|
||||
let mut o = Out::default();
|
||||
o.int(all.len() as i32);
|
||||
for s in all {
|
||||
put_state(&mut o, s);
|
||||
}
|
||||
o.done()
|
||||
}
|
||||
|
||||
pub fn remove_state(uuid: u128) -> Vec<u8> {
|
||||
Out::default().uuid(uuid).done()
|
||||
}
|
||||
|
||||
pub fn add_group(id: u128, name: &str, has_password: bool, kind: GroupKind) -> Vec<u8> {
|
||||
Out::default()
|
||||
.uuid(id)
|
||||
.text(name)
|
||||
.flag(has_password)
|
||||
.flag(false) // persistent
|
||||
.flag(false) // hidden
|
||||
.short(kind.wire())
|
||||
.done()
|
||||
}
|
||||
|
||||
pub fn remove_group(id: u128) -> Vec<u8> {
|
||||
Out::default().uuid(id).done()
|
||||
}
|
||||
|
||||
pub fn joined_group(group: Option<u128>, wrong_password: bool) -> Vec<u8> {
|
||||
let mut o = Out::default();
|
||||
o.flag(group.is_some());
|
||||
if let Some(g) = group {
|
||||
o.uuid(g);
|
||||
}
|
||||
o.flag(wrong_password).done()
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ UDP
|
||||
|
||||
/// A voice packet a client sends.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum FromClient {
|
||||
/// The microphone: an Opus frame (empty: stopped talking).
|
||||
Mic {
|
||||
data: Vec<u8>,
|
||||
sequence: i64,
|
||||
whispering: bool,
|
||||
},
|
||||
Authenticate {
|
||||
player: u128,
|
||||
secret: [u8; 16],
|
||||
},
|
||||
Ping {
|
||||
id: u128,
|
||||
timestamp: i64,
|
||||
},
|
||||
KeepAlive,
|
||||
ConnectionCheck,
|
||||
}
|
||||
|
||||
impl FromClient {
|
||||
pub fn read(plain: &[u8]) -> Read<Self> {
|
||||
let mut r = In::new(plain);
|
||||
Ok(match r.byte()? {
|
||||
1 => Self::Mic {
|
||||
data: r.bytes(MAX_OPUS)?.to_vec(),
|
||||
sequence: r.long()?,
|
||||
whispering: r.flag()?,
|
||||
},
|
||||
5 => Self::Authenticate {
|
||||
player: r.uuid()?,
|
||||
secret: r.fixed()?,
|
||||
},
|
||||
7 => Self::Ping {
|
||||
id: r.uuid()?,
|
||||
timestamp: r.long()?,
|
||||
},
|
||||
8 => Self::KeepAlive,
|
||||
9 => Self::ConnectionCheck,
|
||||
_ => return Err(Malformed),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A voice packet this server sends.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum ToClient<'a> {
|
||||
/// A voice heard where `sender` stands.
|
||||
Positional {
|
||||
sender: u128,
|
||||
data: &'a [u8],
|
||||
sequence: i64,
|
||||
distance: f32,
|
||||
whispering: bool,
|
||||
},
|
||||
/// A group member's voice, heard everywhere.
|
||||
Group {
|
||||
sender: u128,
|
||||
data: &'a [u8],
|
||||
sequence: i64,
|
||||
},
|
||||
AuthenticateAck,
|
||||
Ping {
|
||||
id: u128,
|
||||
timestamp: i64,
|
||||
},
|
||||
KeepAlive,
|
||||
ConnectionCheckAck,
|
||||
}
|
||||
|
||||
impl ToClient<'_> {
|
||||
pub fn write(&self) -> Vec<u8> {
|
||||
let mut o = Out::default();
|
||||
match self {
|
||||
// The channel is the sender: the client places a positional voice at that entity.
|
||||
Self::Positional {
|
||||
sender,
|
||||
data,
|
||||
sequence,
|
||||
distance,
|
||||
whispering,
|
||||
} => o
|
||||
.byte(2)
|
||||
.uuid(*sender)
|
||||
.uuid(*sender)
|
||||
.bytes(data)
|
||||
.long(*sequence)
|
||||
.float(*distance)
|
||||
.flag(*whispering),
|
||||
Self::Group {
|
||||
sender,
|
||||
data,
|
||||
sequence,
|
||||
} => o
|
||||
.byte(3)
|
||||
.uuid(*sender)
|
||||
.uuid(*sender)
|
||||
.bytes(data)
|
||||
.long(*sequence)
|
||||
.byte(0),
|
||||
Self::AuthenticateAck => o.byte(6),
|
||||
Self::Ping { id, timestamp } => o.byte(7).uuid(*id).long(*timestamp),
|
||||
Self::KeepAlive => o.byte(8),
|
||||
Self::ConnectionCheckAck => o.byte(10),
|
||||
};
|
||||
o.done()
|
||||
}
|
||||
}
|
||||
|
||||
/// AES-128-GCM keyed by a player's secret: a random 12-byte nonce, then the
|
||||
/// ciphertext and its 16-byte tag.
|
||||
pub struct Cipher {
|
||||
key: ring::aead::LessSafeKey,
|
||||
}
|
||||
|
||||
impl Cipher {
|
||||
pub fn new(secret: &[u8; 16]) -> Self {
|
||||
let key =
|
||||
ring::aead::UnboundKey::new(&ring::aead::AES_128_GCM, secret).expect("a 16-byte key");
|
||||
Self {
|
||||
key: ring::aead::LessSafeKey::new(key),
|
||||
}
|
||||
}
|
||||
pub fn seal(&self, rng: &dyn ring::rand::SecureRandom, plain: &[u8]) -> Option<Vec<u8>> {
|
||||
let mut nonce = [0u8; 12];
|
||||
rng.fill(&mut nonce).ok()?;
|
||||
let mut body = plain.to_vec();
|
||||
self.key
|
||||
.seal_in_place_append_tag(
|
||||
ring::aead::Nonce::assume_unique_for_key(nonce),
|
||||
ring::aead::Aad::empty(),
|
||||
&mut body,
|
||||
)
|
||||
.ok()?;
|
||||
let mut out = nonce.to_vec();
|
||||
out.extend(body);
|
||||
Some(out)
|
||||
}
|
||||
pub fn open(&self, sealed: &[u8]) -> Option<Vec<u8>> {
|
||||
if sealed.len() < 12 + 16 {
|
||||
return None;
|
||||
}
|
||||
let (nonce, body) = sealed.split_at(12);
|
||||
let nonce = ring::aead::Nonce::try_assume_unique_for_key(nonce).ok()?;
|
||||
let mut body = body.to_vec();
|
||||
let plain = self
|
||||
.key
|
||||
.open_in_place(nonce, ring::aead::Aad::empty(), &mut body)
|
||||
.ok()?;
|
||||
Some(plain.to_vec())
|
||||
}
|
||||
}
|
||||
|
||||
/// A client's datagram: magic, the player's UUID (in clear, to find their key), the sealed packet.
|
||||
pub fn client_datagram(datagram: &[u8]) -> Read<(u128, &[u8])> {
|
||||
let mut r = In::new(datagram);
|
||||
if r.byte()? != MAGIC {
|
||||
return Err(Malformed);
|
||||
}
|
||||
let player = r.uuid()?;
|
||||
Ok((player, r.bytes(MAX_BODY)?))
|
||||
}
|
||||
|
||||
/// A server datagram: magic, then the sealed packet.
|
||||
pub fn server_datagram(sealed: &[u8]) -> Vec<u8> {
|
||||
Out::default().byte(MAGIC).bytes(sealed).done()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn varints_match_minecraft() {
|
||||
for (v, bytes) in [
|
||||
(0, vec![0]),
|
||||
(127, vec![0x7F]),
|
||||
(128, vec![0x80, 1]),
|
||||
(300, vec![0xAC, 2]),
|
||||
(-1, vec![0xFF, 0xFF, 0xFF, 0xFF, 0x0F]),
|
||||
] {
|
||||
assert_eq!(Out::default().varint(v).done(), bytes, "{v}");
|
||||
assert_eq!(In::new(&bytes).varint().unwrap(), v);
|
||||
}
|
||||
assert_eq!(In::new(&[0x80; 6]).varint(), Err(Malformed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn requests_read_their_layouts() {
|
||||
assert_eq!(
|
||||
Request::read(REQUEST_SECRET, &[0, 0, 0, 20]),
|
||||
Some(Ok(Request::Secret { version: 20 }))
|
||||
);
|
||||
assert_eq!(
|
||||
Request::read(UPDATE_STATE, &[1]),
|
||||
Some(Ok(Request::State { disabled: true }))
|
||||
);
|
||||
// name "ab", a password "x", kind open.
|
||||
assert_eq!(
|
||||
Request::read(CREATE_GROUP, &[2, b'a', b'b', 1, 1, b'x', 0, 1]),
|
||||
Some(Ok(Request::CreateGroup {
|
||||
name: "ab".into(),
|
||||
password: Some("x".into()),
|
||||
kind: GroupKind::Open
|
||||
}))
|
||||
);
|
||||
let mut join = 7u128.to_be_bytes().to_vec();
|
||||
join.push(0);
|
||||
assert_eq!(
|
||||
Request::read(SET_GROUP, &join),
|
||||
Some(Ok(Request::JoinGroup {
|
||||
group: 7,
|
||||
password: None
|
||||
}))
|
||||
);
|
||||
assert_eq!(
|
||||
Request::read(LEAVE_GROUP, &[]),
|
||||
Some(Ok(Request::LeaveGroup))
|
||||
);
|
||||
assert_eq!(
|
||||
Request::read("voicechat:secret", &[]),
|
||||
None,
|
||||
"a server's channel"
|
||||
);
|
||||
let long = [&[30u8][..], &[b'n'; 30]].concat();
|
||||
assert_eq!(
|
||||
Request::read(CREATE_GROUP, &[&long[..], &[0, 0, 0]].concat()),
|
||||
Some(Err(Malformed))
|
||||
);
|
||||
assert_eq!(Request::read(REQUEST_SECRET, &[0, 0]), Some(Err(Malformed)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn secret_layout() {
|
||||
let key = [9u8; 16];
|
||||
let bytes = secret(&SecretInfo {
|
||||
secret: &key,
|
||||
port: 24454,
|
||||
player: 0xAB,
|
||||
mtu: 1024,
|
||||
distance: 48.0,
|
||||
keep_alive_ms: 1000,
|
||||
groups: true,
|
||||
voice_host: "",
|
||||
});
|
||||
// key, port, uuid, codec, mtu, distance, keep-alive, groups, "" , recording
|
||||
assert_eq!(bytes.len(), 16 + 4 + 16 + 1 + 4 + 8 + 4 + 1 + 1 + 1);
|
||||
assert_eq!(&bytes[..16], &key);
|
||||
assert_eq!(&bytes[16..20], &24454i32.to_be_bytes());
|
||||
assert_eq!(&bytes[20..36], &0xABu128.to_be_bytes());
|
||||
assert_eq!(&bytes[41..49], &48.0f64.to_be_bytes());
|
||||
assert_eq!(bytes[53], 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn states_and_groups_layouts() {
|
||||
let a = PlayerState {
|
||||
uuid: 1,
|
||||
name: "a".into(),
|
||||
disabled: true,
|
||||
disconnected: false,
|
||||
group: Some(2),
|
||||
};
|
||||
assert_eq!(
|
||||
state(&a),
|
||||
[
|
||||
&[1u8, 0][..],
|
||||
&1u128.to_be_bytes(),
|
||||
&[1, b'a', 1],
|
||||
&2u128.to_be_bytes()
|
||||
]
|
||||
.concat()
|
||||
);
|
||||
let both = states(&[
|
||||
a.clone(),
|
||||
PlayerState {
|
||||
group: None,
|
||||
..a.clone()
|
||||
},
|
||||
]);
|
||||
assert_eq!(&both[..4], &2i32.to_be_bytes());
|
||||
assert_eq!(both.len(), 4 + 37 + 21);
|
||||
let g = add_group(5, "Base", true, GroupKind::Isolated);
|
||||
assert_eq!(
|
||||
g,
|
||||
[
|
||||
&5u128.to_be_bytes()[..],
|
||||
&[4, b'B', b'a', b's', b'e', 1, 0, 0, 0, 2]
|
||||
]
|
||||
.concat()
|
||||
);
|
||||
assert_eq!(joined_group(None, true), vec![0, 1]);
|
||||
assert_eq!(
|
||||
joined_group(Some(3), false),
|
||||
[&[1u8][..], &3u128.to_be_bytes(), &[0]].concat()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn voice_packets() {
|
||||
let mic = [&[1u8, 2, 0xAA, 0xBB][..], &5i64.to_be_bytes(), &[1]].concat();
|
||||
assert_eq!(
|
||||
FromClient::read(&mic),
|
||||
Ok(FromClient::Mic {
|
||||
data: vec![0xAA, 0xBB],
|
||||
sequence: 5,
|
||||
whispering: true
|
||||
})
|
||||
);
|
||||
let auth = [&[5u8][..], &7u128.to_be_bytes(), &[3; 16]].concat();
|
||||
assert_eq!(
|
||||
FromClient::read(&auth),
|
||||
Ok(FromClient::Authenticate {
|
||||
player: 7,
|
||||
secret: [3; 16]
|
||||
})
|
||||
);
|
||||
assert_eq!(FromClient::read(&[8]), Ok(FromClient::KeepAlive));
|
||||
assert_eq!(FromClient::read(&[9]), Ok(FromClient::ConnectionCheck));
|
||||
assert_eq!(FromClient::read(&[2]), Err(Malformed), "a server's packet");
|
||||
let big = [
|
||||
&[1u8][..],
|
||||
&Out::default().varint(1276).done(),
|
||||
&[0; 1276],
|
||||
&[0; 9],
|
||||
]
|
||||
.concat();
|
||||
assert_eq!(FromClient::read(&big), Err(Malformed));
|
||||
|
||||
let sound = ToClient::Positional {
|
||||
sender: 4,
|
||||
data: &[1, 2],
|
||||
sequence: 9,
|
||||
distance: 48.0,
|
||||
whispering: false,
|
||||
}
|
||||
.write();
|
||||
assert_eq!(sound[0], 2);
|
||||
assert_eq!(&sound[1..17], &4u128.to_be_bytes());
|
||||
assert_eq!(&sound[17..33], &4u128.to_be_bytes());
|
||||
assert_eq!(&sound[33..36], &[2, 1, 2]);
|
||||
assert_eq!(&sound[36..44], &9i64.to_be_bytes());
|
||||
assert_eq!(&sound[44..48], &48.0f32.to_be_bytes());
|
||||
assert_eq!(sound[48], 0);
|
||||
assert_eq!(sound.len(), 49);
|
||||
let group = ToClient::Group {
|
||||
sender: 4,
|
||||
data: &[1],
|
||||
sequence: 2,
|
||||
}
|
||||
.write();
|
||||
assert_eq!(
|
||||
(group[0], group.len(), group[group.len() - 1]),
|
||||
(3, 1 + 32 + 2 + 8 + 1, 0)
|
||||
);
|
||||
assert_eq!(ToClient::AuthenticateAck.write(), vec![6]);
|
||||
assert_eq!(ToClient::ConnectionCheckAck.write(), vec![10]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sealed_bodies_round_trip_and_reject_tampering() {
|
||||
let rng = ring::rand::SystemRandom::new();
|
||||
let c = Cipher::new(&[1; 16]);
|
||||
let sealed = c.seal(&rng, b"hello").unwrap();
|
||||
assert_eq!(sealed.len(), 12 + 5 + 16);
|
||||
assert_eq!(c.open(&sealed).unwrap(), b"hello");
|
||||
let mut bad = sealed.clone();
|
||||
bad[14] ^= 1;
|
||||
assert_eq!(c.open(&bad), None);
|
||||
assert_eq!(Cipher::new(&[2; 16]).open(&sealed), None);
|
||||
assert_ne!(
|
||||
c.seal(&rng, b"hello").unwrap(),
|
||||
sealed,
|
||||
"fresh nonce each time"
|
||||
);
|
||||
|
||||
let datagram = [
|
||||
&[MAGIC][..],
|
||||
&9u128.to_be_bytes(),
|
||||
&Out::default().bytes(&sealed).done(),
|
||||
]
|
||||
.concat();
|
||||
assert_eq!(client_datagram(&datagram), Ok((9, &sealed[..])));
|
||||
assert_eq!(client_datagram(&datagram[1..]), Err(Malformed));
|
||||
assert_eq!(server_datagram(&[1, 2])[..], [MAGIC, 2, 1, 2]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,653 @@
|
||||
//! The relay with real UDP sockets on localhost: clients that speak the
|
||||
//! wire format the way a voice chat client does, written from the layouts.
|
||||
use super::*;
|
||||
use proto::{In, Out};
|
||||
|
||||
const A: u128 = 0xA;
|
||||
const B: u128 = 0xB;
|
||||
const FAR: u128 = 0xC;
|
||||
const NETHER: u128 = 0xD;
|
||||
const PLAIN: u128 = 0xE;
|
||||
|
||||
fn player(uuid: u128, name: &str, dimension: &str, x: f64) -> Player {
|
||||
Player {
|
||||
uuid,
|
||||
name: name.into(),
|
||||
dimension: dimension.into(),
|
||||
position: [x, 64.0, 0.0],
|
||||
}
|
||||
}
|
||||
|
||||
fn world() -> Vec<Player> {
|
||||
vec![
|
||||
player(A, "alice", "minecraft:overworld", 0.0),
|
||||
player(B, "bob", "minecraft:overworld", 10.0),
|
||||
player(FAR, "carol", "minecraft:overworld", 500.0),
|
||||
player(NETHER, "dave", "minecraft:the_nether", 0.0),
|
||||
// Joined without a voice chat client: never asks, never told.
|
||||
player(PLAIN, "erin", "minecraft:overworld", 5.0),
|
||||
]
|
||||
}
|
||||
|
||||
fn server() -> Server {
|
||||
server_with(Config::default())
|
||||
}
|
||||
|
||||
fn server_with(config: Config) -> Server {
|
||||
let s = Server::start(Config {
|
||||
bind: "127.0.0.1".into(),
|
||||
port: 0,
|
||||
..config
|
||||
})
|
||||
.unwrap();
|
||||
s.tick(world());
|
||||
s
|
||||
}
|
||||
|
||||
/// The messages for `who` among `all`.
|
||||
fn for_player(all: &[Message], who: u128) -> Vec<(String, Vec<u8>)> {
|
||||
all.iter()
|
||||
.filter(|m| m.0 == who)
|
||||
.map(|m| (m.1.clone(), m.2.clone()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
struct Voice {
|
||||
uuid: u128,
|
||||
secret: [u8; 16],
|
||||
cipher: proto::Cipher,
|
||||
socket: UdpSocket,
|
||||
server: SocketAddr,
|
||||
rng: SystemRandom,
|
||||
/// The datagrams of its handshake: authenticate, connection check.
|
||||
handshake: Vec<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl Voice {
|
||||
/// Reads the secret the server gave, then authenticates and checks the connection.
|
||||
fn connect(uuid: u128, secret_message: &[u8]) -> Self {
|
||||
let mut r = In::new(secret_message);
|
||||
let secret: [u8; 16] = r.take(16).unwrap().try_into().unwrap();
|
||||
let port = r.int().unwrap() as u16;
|
||||
assert_eq!(r.uuid().unwrap(), uuid);
|
||||
let socket = UdpSocket::bind("127.0.0.1:0").unwrap();
|
||||
socket
|
||||
.set_read_timeout(Some(Duration::from_millis(300)))
|
||||
.unwrap();
|
||||
let mut v = Self {
|
||||
uuid,
|
||||
secret,
|
||||
cipher: proto::Cipher::new(&secret),
|
||||
socket,
|
||||
server: SocketAddr::from(([127, 0, 0, 1], port)),
|
||||
rng: SystemRandom::new(),
|
||||
handshake: Vec::new(),
|
||||
};
|
||||
let auth = v.send(
|
||||
&Out::default()
|
||||
.byte(5)
|
||||
.uuid(uuid)
|
||||
.done()
|
||||
.into_iter()
|
||||
.chain(secret)
|
||||
.collect::<Vec<_>>(),
|
||||
);
|
||||
assert_eq!(v.next(|p| p == [6]), Some(vec![6]), "authenticated");
|
||||
let check = v.send(&[9]);
|
||||
assert_eq!(v.next(|p| p == [10]), Some(vec![10]), "connection checked");
|
||||
v.handshake = vec![auth, check];
|
||||
v
|
||||
}
|
||||
/// Sends a sealed packet; returns the datagram.
|
||||
fn send(&self, plain: &[u8]) -> Vec<u8> {
|
||||
let sealed = self.cipher.seal(&self.rng, plain).unwrap();
|
||||
let datagram = Out::default()
|
||||
.byte(proto::MAGIC)
|
||||
.uuid(self.uuid)
|
||||
.bytes(&sealed)
|
||||
.done();
|
||||
self.socket.send_to(&datagram, self.server).unwrap();
|
||||
datagram
|
||||
}
|
||||
/// Answers keep-alives for a while, as a live client does.
|
||||
fn idle(&self, for_ms: u64) {
|
||||
let until = Instant::now() + Duration::from_millis(for_ms);
|
||||
while Instant::now() < until {
|
||||
self.next(|_| false);
|
||||
}
|
||||
}
|
||||
fn talk(&self, sequence: i64, whispering: bool) -> Vec<u8> {
|
||||
self.send(
|
||||
&Out::default()
|
||||
.byte(1)
|
||||
.bytes(&[0xAB; 40])
|
||||
.long(sequence)
|
||||
.flag(whispering)
|
||||
.done(),
|
||||
)
|
||||
}
|
||||
/// The next packet `wanted` accepts, skipping keep-alives (answered) and others; None after a quiet while.
|
||||
fn next(&self, wanted: impl Fn(&[u8]) -> bool) -> Option<Vec<u8>> {
|
||||
let mut buf = [0u8; 4096];
|
||||
let until = Instant::now() + Duration::from_millis(900);
|
||||
while Instant::now() < until {
|
||||
let Ok((n, _)) = self.socket.recv_from(&mut buf) else {
|
||||
continue;
|
||||
};
|
||||
let mut r = In::new(&buf[..n]);
|
||||
assert_eq!(r.byte().unwrap(), proto::MAGIC);
|
||||
let plain = self
|
||||
.cipher
|
||||
.open(r.bytes(4096).unwrap())
|
||||
.expect("sealed with my key");
|
||||
if plain == [8] {
|
||||
self.send(&[8]);
|
||||
}
|
||||
if wanted(&plain) {
|
||||
return Some(plain);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
fn hears(&self) -> Option<Vec<u8>> {
|
||||
self.next(|p| p[0] == 2 || p[0] == 3)
|
||||
}
|
||||
}
|
||||
|
||||
fn ask_secret(s: &Server, who: u128) -> Vec<u8> {
|
||||
s.message(who, proto::REQUEST_SECRET, &20i32.to_be_bytes());
|
||||
let out = s.outgoing(64);
|
||||
for_player(&out, who)
|
||||
.into_iter()
|
||||
.find(|(c, _)| c == proto::SECRET)
|
||||
.expect("a secret")
|
||||
.1
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_asking_clients_hear_of_voice_chat() {
|
||||
let s = server();
|
||||
assert!(s.outgoing(64).is_empty(), "nothing before anyone asks");
|
||||
// Wrong version: no answer.
|
||||
s.message(A, proto::REQUEST_SECRET, &21i32.to_be_bytes());
|
||||
assert!(s.outgoing(64).is_empty());
|
||||
s.message(A, proto::REQUEST_SECRET, &20i32.to_be_bytes());
|
||||
s.tick(world());
|
||||
let out = s.outgoing(64);
|
||||
assert!(out.iter().all(|m| m.0 == A), "{out:?}");
|
||||
let channels: Vec<String> = for_player(&out, A).into_iter().map(|m| m.0).collect();
|
||||
assert_eq!(channels, [proto::SECRET, proto::STATES, proto::STATE]);
|
||||
// A message from someone not on the server, or garbage, is ignored.
|
||||
s.message(0xF, proto::REQUEST_SECRET, &20i32.to_be_bytes());
|
||||
s.message(B, proto::CREATE_GROUP, &[200]);
|
||||
s.tick(world());
|
||||
assert!(s.outgoing(64).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn voices_reach_who_is_near_in_the_same_dimension() {
|
||||
let s = server();
|
||||
let voices: Vec<Voice> = [A, B, FAR, NETHER]
|
||||
.iter()
|
||||
.map(|&u| Voice::connect(u, &ask_secret(&s, u)))
|
||||
.collect();
|
||||
let [a, b, far, nether] = &voices[..] else {
|
||||
unreachable!()
|
||||
};
|
||||
s.tick(world());
|
||||
let states = s.outgoing(64);
|
||||
assert!(
|
||||
for_player(&states, PLAIN).is_empty(),
|
||||
"the player without voice chat gets nothing"
|
||||
);
|
||||
|
||||
a.talk(0, false);
|
||||
let heard = b.hears().expect("bob, 10 blocks away, hears alice");
|
||||
assert_eq!(heard[0], 2, "positional");
|
||||
assert_eq!(&heard[1..17], &A.to_be_bytes(), "at alice");
|
||||
assert_eq!(&heard[33..34], &[40]);
|
||||
assert_eq!(
|
||||
&heard[heard.len() - 13..heard.len() - 5],
|
||||
&0i64.to_be_bytes()
|
||||
);
|
||||
assert_eq!(far.hears(), None, "500 blocks away");
|
||||
assert_eq!(nether.hears(), None, "another dimension");
|
||||
assert_eq!(a.hears(), None, "not your own voice");
|
||||
|
||||
// A whisper carries 24 blocks: bob at 10 still hears it; at 30 he doesn't.
|
||||
a.talk(1, true);
|
||||
assert_eq!(
|
||||
b.hears().map(|p| p[p.len() - 1]),
|
||||
Some(1),
|
||||
"whispering flag"
|
||||
);
|
||||
let mut moved = world();
|
||||
moved[1].position[0] = 30.0;
|
||||
s.tick(moved);
|
||||
a.talk(2, true);
|
||||
assert_eq!(b.hears(), None);
|
||||
a.talk(3, false);
|
||||
assert!(b.hears().is_some());
|
||||
s.tick(world());
|
||||
|
||||
// Voice chat off: bob neither hears nor is heard.
|
||||
s.message(B, proto::UPDATE_STATE, &[1]);
|
||||
a.talk(4, false);
|
||||
assert_eq!(b.hears(), None);
|
||||
b.talk(0, false);
|
||||
assert_eq!(a.hears(), None);
|
||||
s.message(B, proto::UPDATE_STATE, &[0]);
|
||||
a.talk(5, false);
|
||||
assert!(b.hears().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn groups_follow_their_kind() {
|
||||
let s = server();
|
||||
let a = Voice::connect(A, &ask_secret(&s, A));
|
||||
let b = Voice::connect(B, &ask_secret(&s, B));
|
||||
let far = Voice::connect(FAR, &ask_secret(&s, FAR));
|
||||
s.tick(world());
|
||||
s.outgoing(64);
|
||||
|
||||
// Alice makes an isolated group with a password; carol joins it from 500 blocks.
|
||||
s.message(
|
||||
A,
|
||||
proto::CREATE_GROUP,
|
||||
&Out::default()
|
||||
.text("Base")
|
||||
.flag(true)
|
||||
.text("pw")
|
||||
.short(2)
|
||||
.done(),
|
||||
);
|
||||
let out = s.outgoing(64);
|
||||
let add = for_player(&out, FAR)
|
||||
.into_iter()
|
||||
.find(|m| m.0 == proto::ADD_GROUP)
|
||||
.expect("everyone sees the group")
|
||||
.1;
|
||||
let id = In::new(&add).uuid().unwrap();
|
||||
let joined = for_player(&out, A)
|
||||
.into_iter()
|
||||
.find(|m| m.0 == proto::JOINED_GROUP)
|
||||
.unwrap()
|
||||
.1;
|
||||
assert_eq!(joined, proto::joined_group(Some(id), false));
|
||||
|
||||
s.message(
|
||||
FAR,
|
||||
proto::SET_GROUP,
|
||||
&Out::default().uuid(id).flag(true).text("nope").done(),
|
||||
);
|
||||
let out = s.outgoing(64);
|
||||
assert_eq!(
|
||||
for_player(&out, FAR),
|
||||
vec![(
|
||||
proto::JOINED_GROUP.to_owned(),
|
||||
proto::joined_group(None, true)
|
||||
)]
|
||||
);
|
||||
s.message(
|
||||
FAR,
|
||||
proto::SET_GROUP,
|
||||
&Out::default().uuid(id).flag(true).text("pw").done(),
|
||||
);
|
||||
s.tick(world());
|
||||
s.outgoing(64);
|
||||
|
||||
a.talk(0, false);
|
||||
assert_eq!(
|
||||
far.hears().map(|p| p[0]),
|
||||
Some(3),
|
||||
"a group voice, from anywhere"
|
||||
);
|
||||
assert_eq!(b.hears(), None, "isolated: bob nearby doesn't hear alice");
|
||||
b.talk(0, false);
|
||||
assert_eq!(a.hears(), None, "isolated: alice doesn't hear bob");
|
||||
|
||||
// Both leave: the empty group goes away for everyone.
|
||||
s.message(A, proto::LEAVE_GROUP, &[]);
|
||||
s.message(FAR, proto::LEAVE_GROUP, &[]);
|
||||
let out = s.outgoing(64);
|
||||
assert!(
|
||||
for_player(&out, B).contains(&(proto::REMOVE_GROUP.to_owned(), proto::remove_group(id)))
|
||||
);
|
||||
a.talk(1, false);
|
||||
assert_eq!(b.hears().map(|p| p[0]), Some(2), "back to proximity");
|
||||
|
||||
// An open group: members are heard nearby, and hear who is nearby.
|
||||
s.message(
|
||||
A,
|
||||
proto::CREATE_GROUP,
|
||||
&Out::default().text("Open").flag(false).short(1).done(),
|
||||
);
|
||||
s.tick(world());
|
||||
s.outgoing(64);
|
||||
a.talk(2, false);
|
||||
assert_eq!(b.hears().map(|p| p[0]), Some(2));
|
||||
b.talk(1, false);
|
||||
assert_eq!(a.hears().map(|p| p[0]), Some(2));
|
||||
// A normal one: members aren't heard outside, but hear who is nearby.
|
||||
s.message(
|
||||
A,
|
||||
proto::CREATE_GROUP,
|
||||
&Out::default().text("Normal").flag(false).short(0).done(),
|
||||
);
|
||||
s.tick(world());
|
||||
s.outgoing(64);
|
||||
a.talk(3, false);
|
||||
assert_eq!(b.hears(), None);
|
||||
b.talk(2, false);
|
||||
assert_eq!(a.hears().map(|p| p[0]), Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leaving_and_strangers() {
|
||||
let s = server();
|
||||
let a = Voice::connect(A, &ask_secret(&s, A));
|
||||
let b = Voice::connect(B, &ask_secret(&s, B));
|
||||
s.tick(world());
|
||||
s.outgoing(64);
|
||||
|
||||
// Someone without the key can't speak as alice, nor take her address.
|
||||
let stranger = UdpSocket::bind("127.0.0.1:0").unwrap();
|
||||
let fake = proto::Cipher::new(&[0; 16])
|
||||
.seal(&SystemRandom::new(), &[1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 9, 0])
|
||||
.unwrap();
|
||||
stranger
|
||||
.send_to(
|
||||
&Out::default()
|
||||
.byte(proto::MAGIC)
|
||||
.uuid(A)
|
||||
.bytes(&fake)
|
||||
.done(),
|
||||
b.server,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(b.hears(), None);
|
||||
// A copy of alice's own handshake from elsewhere, while she's active, is refused.
|
||||
let replay = a
|
||||
.cipher
|
||||
.seal(
|
||||
&a.rng,
|
||||
&Out::default()
|
||||
.byte(5)
|
||||
.uuid(A)
|
||||
.done()
|
||||
.into_iter()
|
||||
.chain(a.secret)
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.unwrap();
|
||||
stranger
|
||||
.set_read_timeout(Some(Duration::from_millis(300)))
|
||||
.unwrap();
|
||||
stranger
|
||||
.send_to(
|
||||
&Out::default()
|
||||
.byte(proto::MAGIC)
|
||||
.uuid(A)
|
||||
.bytes(&replay)
|
||||
.done(),
|
||||
b.server,
|
||||
)
|
||||
.unwrap();
|
||||
let mut buf = [0u8; 64];
|
||||
assert!(
|
||||
stranger.recv_from(&mut buf).is_err(),
|
||||
"no answer to a stranger"
|
||||
);
|
||||
a.talk(0, false);
|
||||
assert!(
|
||||
b.hears().is_some(),
|
||||
"alice still talks from her own address"
|
||||
);
|
||||
|
||||
// Bob leaves the game: alice is told, and his voice is gone.
|
||||
let mut without_bob = world();
|
||||
without_bob.retain(|p| p.uuid != B);
|
||||
s.tick(without_bob);
|
||||
let out = s.outgoing(64);
|
||||
assert_eq!(
|
||||
for_player(&out, A),
|
||||
vec![(proto::REMOVE_STATE.to_owned(), proto::remove_state(B))]
|
||||
);
|
||||
b.talk(5, false);
|
||||
assert_eq!(a.hears(), None);
|
||||
s.left(A);
|
||||
assert!(s.outgoing(64).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outgoing_is_paced_per_player() {
|
||||
let s = server();
|
||||
ask_secret(&s, A);
|
||||
for i in 0..10 {
|
||||
s.message(
|
||||
A,
|
||||
proto::CREATE_GROUP,
|
||||
&Out::default()
|
||||
.text(&format!("g{i}"))
|
||||
.flag(false)
|
||||
.short(0)
|
||||
.done(),
|
||||
);
|
||||
}
|
||||
let first = s.outgoing(4);
|
||||
assert_eq!(first.len(), 4);
|
||||
let mut rest = 0;
|
||||
loop {
|
||||
let more = s.outgoing(4);
|
||||
if more.is_empty() {
|
||||
break;
|
||||
}
|
||||
assert!(more.len() <= 4);
|
||||
rest += more.len();
|
||||
}
|
||||
// Each group: its add and alice's joined (the old one, emptied, is removed).
|
||||
assert_eq!(4 + rest, 10 * 2 + 9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_defaults_fill_in() {
|
||||
let c: Config = serde_json::from_str(r#"{"port": 25565}"#).unwrap();
|
||||
assert_eq!((c.port, c.distance, c.enabled), (25565, 48.0, true));
|
||||
assert!(
|
||||
Server::start(Config {
|
||||
enabled: false,
|
||||
..Config::default()
|
||||
})
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
/// A socket elsewhere that copies datagrams it saw and sends them as its own.
|
||||
fn stranger() -> UdpSocket {
|
||||
let socket = UdpSocket::bind("127.0.0.1:0").unwrap();
|
||||
socket
|
||||
.set_read_timeout(Some(Duration::from_millis(300)))
|
||||
.unwrap();
|
||||
socket
|
||||
}
|
||||
|
||||
fn answered(socket: &UdpSocket) -> bool {
|
||||
let mut buf = [0u8; 4096];
|
||||
socket.recv_from(&mut buf).is_ok()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_replayed_handshake_after_a_silence_moves_nothing() {
|
||||
let s = server();
|
||||
let a = Voice::connect(A, &ask_secret(&s, A));
|
||||
let b = Voice::connect(B, &ask_secret(&s, B));
|
||||
s.tick(world());
|
||||
s.outgoing(64);
|
||||
|
||||
// Alice says nothing for longer than the old 5 s move rule (but less than
|
||||
// the 10 s after which she counts as lost).
|
||||
std::thread::sleep(Duration::from_millis(5300));
|
||||
let eve = stranger();
|
||||
for datagram in &a.handshake {
|
||||
eve.send_to(datagram, a.server).unwrap();
|
||||
assert!(!answered(&eve), "no answer to a copied handshake");
|
||||
}
|
||||
b.talk(0, false);
|
||||
assert!(a.hears().is_some(), "bob's voice still goes to alice");
|
||||
assert!(!answered(&eve), "and not to the copier");
|
||||
|
||||
// Exact copies from alice's own address are dropped too.
|
||||
a.socket.send_to(&a.handshake[1], a.server).unwrap();
|
||||
assert_eq!(a.next(|p| p == [10]), None, "a copied check isn't answered");
|
||||
let said = a.talk(0, false);
|
||||
assert!(b.hears().is_some());
|
||||
a.socket.send_to(&said, a.server).unwrap();
|
||||
assert_eq!(b.hears(), None, "a copied voice packet isn't relayed twice");
|
||||
a.talk(1, false);
|
||||
assert!(b.hears().is_some(), "the next one is");
|
||||
|
||||
// Once her handshake has left the nonces remembered, the secret still
|
||||
// belongs to her address alone.
|
||||
for _ in 0..SEEN_NONCES + 4 {
|
||||
a.send(&[8]);
|
||||
}
|
||||
a.idle(200);
|
||||
for datagram in &a.handshake {
|
||||
eve.send_to(datagram, a.server).unwrap();
|
||||
assert!(!answered(&eve), "no answer to an old copy either");
|
||||
}
|
||||
b.talk(1, false);
|
||||
assert!(a.hears().is_some());
|
||||
assert!(!answered(&eve));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_client_the_server_stops_hearing_gets_a_fresh_secret() {
|
||||
let s = server_with(Config {
|
||||
// Lost after 3 s: bob, who answers keep-alives only while a test
|
||||
// reads his socket, must not count as lost too.
|
||||
keep_alive_ms: 300,
|
||||
..Config::default()
|
||||
});
|
||||
let a = Voice::connect(A, &ask_secret(&s, A));
|
||||
let b = Voice::connect(B, &ask_secret(&s, B));
|
||||
s.message(
|
||||
A,
|
||||
proto::CREATE_GROUP,
|
||||
&Out::default().text("Open").flag(false).short(1).done(),
|
||||
);
|
||||
s.tick(world());
|
||||
let out = s.outgoing(64);
|
||||
let group = for_player(&out, A)
|
||||
.into_iter()
|
||||
.find(|m| m.0 == proto::ADD_GROUP)
|
||||
.map(|m| In::new(&m.1).uuid().unwrap())
|
||||
.unwrap();
|
||||
|
||||
// Alice's NAT rebinds: the server hears nothing from her for 10 keep-alives.
|
||||
b.idle(3500);
|
||||
let out = s.outgoing(64);
|
||||
let to_alice = for_player(&out, A);
|
||||
assert_eq!(
|
||||
to_alice.iter().map(|m| m.0.as_str()).collect::<Vec<_>>(),
|
||||
[proto::SECRET],
|
||||
"only a new secret, over the game connection"
|
||||
);
|
||||
let fresh = &to_alice[0].1;
|
||||
assert_ne!(&fresh[..16], &a.secret, "a new key");
|
||||
|
||||
// The old key opens nothing now, from anywhere.
|
||||
let eve = stranger();
|
||||
eve.send_to(&a.handshake[0], a.server).unwrap();
|
||||
assert!(!answered(&eve));
|
||||
a.send(&[9]);
|
||||
assert_eq!(a.next(|p| p == [10]), None);
|
||||
|
||||
// From her new address with the new secret, she is back, in her group.
|
||||
let moved = Voice::connect(A, fresh);
|
||||
s.tick(world());
|
||||
let out = s.outgoing(64);
|
||||
let alice = proto::state(&PlayerState {
|
||||
uuid: A,
|
||||
name: "alice".into(),
|
||||
disabled: false,
|
||||
disconnected: false,
|
||||
group: Some(group),
|
||||
});
|
||||
assert!(
|
||||
for_player(&out, B).contains(&(proto::STATE.to_owned(), alice)),
|
||||
"{out:?}"
|
||||
);
|
||||
b.talk(0, false);
|
||||
assert!(
|
||||
moved.hears().is_some(),
|
||||
"bob's voice reaches her new address"
|
||||
);
|
||||
assert_eq!(a.hears(), None, "and not the old one");
|
||||
moved.talk(0, false);
|
||||
assert!(b.hears().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_secret_requests_and_group_changes_stay_bounded() {
|
||||
let s = server();
|
||||
// 40 players, each in a group of their own.
|
||||
let many: Vec<Player> = (0..40u128)
|
||||
.map(|i| player(0x100 + i, &format!("p{i}"), "minecraft:overworld", 0.0))
|
||||
.collect();
|
||||
s.tick(many.clone());
|
||||
for p in &many {
|
||||
ask_secret(&s, p.uuid);
|
||||
s.message(
|
||||
p.uuid,
|
||||
proto::CREATE_GROUP,
|
||||
&Out::default().text(&p.name).flag(false).short(0).done(),
|
||||
);
|
||||
}
|
||||
s.tick(many.clone());
|
||||
s.outgoing(usize::MAX);
|
||||
|
||||
// A thousand requests in a row: answered once now, once more a second later.
|
||||
let first = many[0].uuid;
|
||||
for _ in 0..1000 {
|
||||
s.message(first, proto::REQUEST_SECRET, &20i32.to_be_bytes());
|
||||
}
|
||||
let count = |out: &[Message], channel: &str| {
|
||||
out.iter()
|
||||
.filter(|m| m.0 == first && m.1 == channel)
|
||||
.count()
|
||||
};
|
||||
let out = s.outgoing(usize::MAX);
|
||||
assert_eq!(count(&out, proto::SECRET), 0, "too soon after the last one");
|
||||
std::thread::sleep(SECRET_EVERY + Duration::from_millis(50));
|
||||
s.tick(many.clone());
|
||||
let out = s.outgoing(usize::MAX);
|
||||
assert_eq!(count(&out, proto::SECRET), 1);
|
||||
// Starting over, it left its own group, which went away.
|
||||
assert_eq!(count(&out, proto::ADD_GROUP), 39, "the groups once");
|
||||
let mine: Vec<String> = for_player(&out, first).into_iter().map(|m| m.0).collect();
|
||||
assert!(mine.len() <= 1 + 1 + 39 + 1, "{mine:?}");
|
||||
s.tick(many.clone());
|
||||
assert_eq!(count(&s.outgoing(usize::MAX), proto::SECRET), 0);
|
||||
|
||||
// Group changes: a burst of 10, then 2 a second.
|
||||
let second = many[1].uuid;
|
||||
for i in 0..100 {
|
||||
s.message(
|
||||
second,
|
||||
proto::CREATE_GROUP,
|
||||
&Out::default()
|
||||
.text(&format!("g{i}"))
|
||||
.flag(false)
|
||||
.short(0)
|
||||
.done(),
|
||||
);
|
||||
}
|
||||
let out = s.outgoing(usize::MAX);
|
||||
let joined = out
|
||||
.iter()
|
||||
.filter(|m| m.0 == second && m.1 == proto::JOINED_GROUP)
|
||||
.count();
|
||||
// Its bucket refilled during the wait: 10 of the 100.
|
||||
assert_eq!(joined, OPS_BURST as usize);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
// The end of each server tick: the relay learns where everyone is, and its
|
||||
// messages leave for the players' clients.
|
||||
fn voicechat_server_tick(
|
||||
game: &mut crate::game::Game<'_>,
|
||||
phase: crate::game::native_lifecycle::ServerTickPhase,
|
||||
) {
|
||||
if matches!(
|
||||
phase,
|
||||
crate::game::native_lifecycle::ServerTickPhase::ServerTickEnd { .. }
|
||||
) {
|
||||
crate::game::voicechat_server::tick(game);
|
||||
}
|
||||
}
|
||||
@@ -4,5 +4,5 @@
|
||||
"horizon_repository": "https://github.com/horizon-rust/horizon",
|
||||
"horizon_revision": "6f8c7a0e5d531d3834c4d0be4659a1db8e7e5c2a",
|
||||
"horizon_mixins_repository": "https://github.com/horizon-rust/horizon-mixins",
|
||||
"horizon_mixins_revision": "0d6b103c70633d8fd9443d8d0acd3aadaacedb06"
|
||||
"horizon_mixins_revision": "d9c83d7096b7ea5b83678da2efdfd6161f74997e"
|
||||
}
|
||||
Reference in new issue
Block a user