smp 0.25.0: /invsee and /endersee for operators

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Ludovic MarieandClaude Opus 5.5 committed 2026-11-01 19:42:27 +01:00
1 parent cacc3360e0
commit 8295b4c1ef
14 files changed
+1014 -10

No files matched your search

+1 -1
View File
@@ -4,7 +4,7 @@ version = 4
[[package]]
name = "horizon-mod-smp"
version = "0.24.0"
version = "0.25.0"
dependencies = [
"serde",
"serde_json",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "horizon-mod-smp"
version = "0.24.0"
version = "0.25.0"
edition = "2024"
license = "MIT"
publish = false
+13
View File
@@ -57,6 +57,7 @@ header and footer).
| `/nick <name>`, `/nick off`, `/nick` | operators of level 3 (with `ops_only`, the default): another name for you in chat and the Tab list, shown as `~name` (hovering it gives your real one); `/nick off` (anyone with a nickname) takes it off; `/nick` alone says how |
| `/nick <player> <name\|off>` | operators of level 3: sets or takes off a player's nickname |
| `/realname <nick>` | who goes by that nickname |
| `/invsee <player>`, `/endersee <player>` | operators of level 3: the player's inventory (main, hotbar, armour, off hand) or ender chest in a chest menu, on or off the server; click one of theirs to take it, one of yours to give it; every move in `audit.log`, a summary in `staff.log` |
| `/sidebar off`, `/sidebar on`, `/sidebar` | hides the scoreboard sidebar for you (remembered), shows it again |
The market trades only plain items (not worn, named or enchanted), and only with players in survival or adventure
@@ -96,6 +97,17 @@ A `config.json` still holding the 18 prices of versions 0.1 to 0.11, never edite
0.12.0; edited prices are kept. Only items that stack to 64 belong in the market: a shift-click buys 64, and what
doesn't fit falls at the player's feet for them.
**/invsee and /endersee.** For operators of level 3 (luzW, bunchy7s...). The menu shows the main inventory in its
first three rows, the hotbar in the fourth, then the armour (head to feet) and the off hand; the ender chest fills
three rows. A click on one of the player's stacks moves it whole into the operator's inventory; a click on one of the
operator's own moves it into the player's (main inventory or ender chest, where it fits whole). A stack moves only if
it is still exactly what the menu showed: the game checks it and moves it, or nothing (no copy, no loss). A player who
isn't on is read from and written to their saved file; while they log in or are dead nothing moves and the menu says
why. The menu is read again once a second while open; nothing runs when none is open. Each move is in `audit.log`
before it is made (`invsee_take`, `invsee_put`: operator, player, inventory, item, count; a refused one again with
its reason); closing the menu writes one line to the server log (`[smp] staff: ...`), `staff.log`, and the chat of
the other operators on. Needs horizon#6106 (`native_inventory`, `native_move_stack`).
**Nicknames.** With `ops_only` (the default since 0.22) only operators of level 3 set nicknames: their own, or a
player's with `/nick <player> <name>`; a player may take off the one given to them (`/nick off`). At each start, a
nickname a player who isn't an operator picked themselves is taken off and logged (`for operators only`). 3 to 16 letters, digits or `_`, shown with a `~` in front (`~Rocky`) so a nickname never passes for
@@ -292,6 +304,7 @@ folder is untouched until the mod first runs on it; it changes no block and no p
| `economy.json` | balances by player UUID, with the name last seen; `held`: coinflip stakes and lottery tickets being decided; `pools`: the Treasury, its insurance fund and other system pools; `treasury`: its income and spending by kind and its last movements; `ext`: other parts' state written in the same step as the money (`tax`: the week collected, land tax owed; `auction`: the listings, bids, mail and notes) |
| `places.json` | the spawn, the warps, each player's homes, and the seconds each player has played (written once a minute) |
| `nicks.json` | each player's nickname and when they last changed it |
| `staff.log` | one line per `/invsee` or `/endersee` menu in which something moved: who, whose, what was taken and put |
| `market.json` | what each player sold today (coins and items), for the daily limits |
| `presence.json` | the players who have joined (no welcome again), and the day each last got the daily reward |
| `board.json` | the players who hid the sidebar (`/sidebar off`) |
+1 -1
View File
@@ -2,7 +2,7 @@
"game": "26.3",
"hooks": 62,
"horizon_repository": "https://github.com/horizon-rust/horizon",
"horizon_revision": "c348d5da9c488f1d2453b0d2b03c2751b2379b05",
"horizon_revision": "975c07d9151847e6af13afc4b5ab37b6bbe78ef5",
"horizon_mixins_repository": "https://github.com/horizon-rust/horizon-mixins",
"horizon_mixins_revision": "0d6b103c70633d8fd9443d8d0acd3aadaacedb06"
}
+3
View File
@@ -5,6 +5,9 @@ fn smp_perform_command(
command: &str,
ran: &mut bool,
) {
if !*ran {
crate::game::smp_invsee_glue::perform(game, player, command, ran);
}
if !*ran {
crate::game::smp_glue::perform(game, player, command, ran);
}
+24 -1
View File
@@ -41,7 +41,7 @@ use std::{path::PathBuf, sync::Mutex};
/// The mod's id, name and version.
pub const ID: &str = "smp";
pub const NAME: &str = "SMP";
pub const VERSION: &str = "0.24.0";
pub const VERSION: &str = "0.25.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).
@@ -1799,6 +1799,29 @@ pub(in crate::game) fn tick(game: &mut Game<'_>) {
show(game, out);
}
/// One line in the SMP's audit log (`/invsee`'s moves), outside a command:
/// an Err when the log can't be written, or the economy is off.
pub(in crate::game) fn audit(dir: &std::path::Path, entry: serde_json::Value) -> Result<(), String> {
let mut guard = state(dir.join(smp_store::DIR));
let smp = guard.as_mut().expect("opened");
match &mut smp.economy {
Ok(e) => e.record(entry),
Err(_) => Err("the audit log is off (economy.json can't be read)".into()),
}
}
/// A player who has played here, by name (any case): online first, then
/// the economy's accounts and `usercache.json`.
pub(in crate::game) fn find_player(game: &Game<'_>, name: &str) -> Option<(u128, String)> {
if let Some(p) = game.native_server_players().into_iter().find(|p| p.name.eq_ignore_ascii_case(name)) {
return Some((p.uuid, p.name));
}
let dir = game.native_world_dir()?;
let guard = state(dir.join(smp_store::DIR));
let economy = guard.as_ref().and_then(|s| s.economy.as_ref().ok());
ServerNames::read(&dir, economy).find(name)
}
/// The names a nickname may not copy: the economy's accounts, the server's
/// `usercache.json` (everyone who has joined) and `ops.json`, beside the
/// world's folder. Read for each `/nick` and each second with changes.
+16 -4
View File
@@ -1,12 +1,12 @@
[mod]
id = "smp"
version = "0.24.0"
version = "0.25.0"
priority = 100
# The commit of horizon main it was tested on. It uses only the native hook points (hooks 62 in
# compatibility.json): it builds on any Horizon that has them.
# The commit of horizon it was tested on: it needs horizon#6106 (merged at 975c07d): native_inventory,
# native_move_stack, native_send_map, lore on the wire.
[compatibility]
horizon_revision = "c348d5da9c488f1d2453b0d2b03c2751b2379b05"
horizon_revision = "975c07d9151847e6af13afc4b5ab37b6bbe78ef5"
[[module]]
id = "store"
@@ -152,6 +152,18 @@ file = "crates/horizon-client/src/game.rs"
name = "smp_claims"
source = "smp_claims.rs"
[[module]]
id = "invsee"
file = "crates/horizon-client/src/game.rs"
name = "smp_invsee"
source = "smp_invsee.rs"
[[module]]
id = "invsee_glue"
file = "crates/horizon-client/src/game.rs"
name = "smp_invsee_glue"
source = "invsee_glue.rs"
[[module]]
id = "glue"
file = "crates/horizon-client/src/game.rs"
+314
View File
@@ -0,0 +1,314 @@
// `/invsee` and `/endersee` in the server's game (smp_invsee's rules): the
// menus open, their clicks and closings, the inventories through the native
// helpers (`native_inventory`, `native_move_stack`), each move in the SMP's
// audit log, the staff's summary in the server log, `staff.log` and the
// chat of the other operators on. Its own state, apart from the SMP's: the
// tick costs one atomic load while no menu is open. Added to the game as
// `game::smp_invsee_glue`.
use crate::game::{
Game,
native_inventories::NativeInventoryKind,
native_menus::native_menu_inventory_slot,
native_server::{NativeCommand, NativeServerPlayer},
smp_economy::{Out, Player},
smp_glue,
smp_invsee::{self, Cell, Invsee, Kind, Move},
smp_store,
};
use horizon_client::{
items::ItemStack,
lan::data::component_nbt,
text::{self, Text},
};
use std::sync::{
Mutex,
atomic::{AtomicBool, AtomicU64, Ordering::Relaxed},
};
struct State {
invsee: Invsee<ItemStack>,
/// Asked in a command, opened at the next tick: (operator, target UUID,
/// real name, which).
pending: Vec<(u128, u128, String, Kind)>,
}
static STATE: Mutex<Option<State>> = Mutex::new(None);
/// A menu open or one to open: the tick has work.
static ACTIVE: AtomicBool = AtomicBool::new(false);
static TICKS: AtomicU64 = AtomicU64::new(0);
fn state() -> std::sync::MutexGuard<'static, Option<State>> {
let mut s = STATE.lock().unwrap_or_else(|e| e.into_inner());
s.get_or_insert_with(|| State {
invsee: Invsee::default(),
pending: Vec::new(),
});
s
}
impl smp_invsee::Stack for ItemStack {
fn id(&self) -> String {
self.item.to_string()
}
fn count(&self) -> u32 {
self.count
}
}
fn native(kind: Kind) -> NativeInventoryKind {
match kind {
Kind::Player => NativeInventoryKind::Player,
Kind::Ender => NativeInventoryKind::Ender,
}
}
/// The game's inventories, for the rules.
struct GameWorld<'a, 'g>(&'a mut Game<'g>);
impl smp_invsee::World<ItemStack> for GameWorld<'_, '_> {
fn read(&self, uuid: u128, kind: Kind) -> Result<(Vec<Option<ItemStack>>, bool), String> {
self.0.native_inventory(uuid, native(kind)).map(|i| (i.slots, i.online))
}
fn move_stack(&mut self, from: (u128, Kind, usize), expected: &ItemStack, to: (u128, Kind)) -> Result<(), String> {
self.0.native_move_stack((from.0, native(from.1), from.2), expected, (to.0, native(to.1)))
}
}
pub fn commands(commands: &mut Vec<NativeCommand>) {
for usage in ["invsee <player:word>", "endersee <player:word>"] {
commands.push(NativeCommand::op(usage, smp_invsee::LEVEL));
}
}
/// `/invsee` and `/endersee`; anything else is left to the SMP.
pub(in crate::game) fn perform(game: &mut Game<'_>, player: &NativeServerPlayer, line: &str, ran: &mut bool) {
let word = line.split(' ').next().unwrap_or_default();
if !smp_invsee::COMMANDS.contains(&word) || !game.native_on(smp_glue::ID) {
return;
}
*ran = true;
let me = Player {
uuid: player.uuid,
name: player.name.clone(),
level: player.permission,
};
let find = |name: &str| smp_glue::find_player(game, name);
let mut guard = state();
let s = guard.as_mut().expect("made");
let (out, open) = s.invsee.command(&me, &find, line).unwrap_or_default();
if let Some((target, name, kind)) = open {
s.pending.retain(|p| p.0 != me.uuid);
s.pending.push((me.uuid, target, name, kind));
ACTIVE.store(true, Relaxed);
}
drop(guard);
for o in out {
match o {
Out::Error(e) => game.native_feedback(Text::literal(e).with_color(text::RED)),
Out::Reply(r) => game.native_feedback(Text::literal(r)),
_ => {}
}
}
}
fn named(item: &str, name: &str, color: &str) -> ItemStack {
let mut stack = ItemStack {
item: item.into(),
count: 1,
damage: 0,
extra: None,
};
let title = serde_json::json!({ "text": name, "italic": false, "color": color });
stack.data_mut().custom_name = Some(component_nbt(&title));
stack
}
fn items(invsee: &Invsee<ItemStack>, op: u128) -> Vec<Option<ItemStack>> {
invsee
.layout(op)
.into_iter()
.map(|c| match c {
Cell::Empty => None,
Cell::Stack(s) => Some(s),
Cell::Filler => Some(named("minecraft:gray_stained_glass_pane", " ", "gray")),
Cell::Label(l) => Some(named("minecraft:light_gray_stained_glass_pane", l, "gray")),
Cell::Close => Some(named("minecraft:barrier", "Close", "red")),
Cell::Info { name, online, busy } => {
let line = match &busy {
Some(b) => format!("{name}: {b} (nothing moves now)"),
None if online => format!("{name} (online): click theirs to take, yours to give"),
None => format!("{name} (offline, their saved file): click theirs to take, yours to give"),
};
Some(named("minecraft:player_head", &line, if busy.is_some() { "red" } else { "yellow" }))
}
})
.collect()
}
fn title(name: &str, kind: Kind) -> Text {
Text::literal(match kind {
Kind::Player => format!("{name}'s inventory"),
Kind::Ender => format!("{name}'s ender chest"),
})
}
/// The audit line of a move: written before it is made ("asked"), and
/// again when the game refused it.
fn audit(dir: &std::path::Path, m: &Move, outcome: &str) -> Result<(), String> {
smp_glue::audit(
dir,
serde_json::json!({
"kind": if m.take { "invsee_take" } else { "invsee_put" },
"op": smp_store::uuid_string(m.op),
"target": smp_store::uuid_string(m.target),
"inventory": match m.kind { Kind::Player => "inventory", Kind::Ender => "ender_chest" },
"item": m.item,
"count": m.count,
"outcome": outcome,
}),
)
}
/// The staff's line: the server log, `staff.log`, the other operators on.
fn staff(game: &mut Game<'_>, op: u128, line: &str) {
log::warn!("[smp] staff: {line}");
if let Some(dir) = game.native_world_dir() {
use std::io::Write;
let path = dir.join(smp_store::DIR).join("staff.log");
let stamp = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ");
if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(&path) {
let _ = writeln!(f, "{stamp} {line}");
}
}
for p in game.native_server_players() {
if p.uuid != op && p.permission >= smp_invsee::LEVEL {
game.native_tell(p.uuid, Text::literal(format!("[Staff] {line}")).with_color(text::GRAY));
}
}
}
/// Each tick: one load while nothing is open. Menus to open now; once a
/// second, every open menu read again, and those of operators gone closed.
pub(in crate::game) fn tick(game: &mut Game<'_>) {
if !ACTIVE.load(Relaxed) {
return;
}
let n = TICKS.fetch_add(1, Relaxed);
let mut guard = state();
let s = guard.as_mut().expect("made");
let mut tell = Vec::new();
for (op, target, name, kind) in std::mem::take(&mut s.pending) {
let opened = s.invsee.open(op, target, &name, kind, &GameWorld(game));
match opened {
Ok(()) => {
let window = game.native_open_menu(op, smp_invsee::ROWS, title(&name, kind), items(&s.invsee, op));
match window {
Some(w) => s.invsee.views.get_mut(&op).expect("opened").window = Some(w),
None => {
s.invsee.views.remove(&op);
}
}
}
Err(e) => tell.push((op, format!("Can't open {name}'s inventory: {e}."))),
}
}
let mut summaries = Vec::new();
if n % 20 == 0 {
let on: Vec<(u128, String)> = game.native_server_players().into_iter().map(|p| (p.uuid, p.name)).collect();
let ops: Vec<u128> = s.invsee.views.keys().copied().collect();
for op in ops {
match on.iter().find(|p| p.0 == op) {
None => summaries.extend(s.invsee.close(op, &smp_store::uuid_string(op)).map(|l| (op, l))),
Some(_) => {
if s.invsee.refresh(op, &GameWorld(game)) {
game.native_set_menu_items(op, items(&s.invsee, op));
}
}
}
}
}
ACTIVE.store(!s.invsee.views.is_empty() || !s.pending.is_empty(), Relaxed);
drop(guard);
for (op, line) in tell {
game.native_tell(op, Text::literal(line).with_color(text::RED));
}
for (op, line) in summaries {
staff(game, op, &line);
}
}
/// A click in an `/invsee` menu: true when it was one.
pub(in crate::game) fn menu_clicked(game: &mut Game<'_>, uuid: u128, window: i32, slot: i32, mode: i32) -> bool {
let mut guard = state();
let s = guard.as_mut().expect("made");
if s.invsee.views.get(&uuid).is_none_or(|v| v.window != Some(window)) {
return false;
}
let Some(dir) = game.native_world_dir() else {
return true;
};
if !matches!(mode, 0 | 1) {
return true;
}
let mut asked: Option<Move> = None;
// Each move is in the audit log before it is made; one the log
// refuses isn't made.
let mut record = |m: &Move| {
audit(&dir, m, "asked")?;
asked = Some(m.clone());
Ok(())
};
let own = native_menu_inventory_slot(smp_invsee::SIZE, slot);
let mut world = GameWorld(game);
let result = match own {
Some(i) => match world.0.native_player_stacks(uuid).and_then(|st| st.get(i).cloned().flatten()) {
Some(stack) => s.invsee.click_own(uuid, i, &stack, &mut world, &mut record).map(|_| false),
None => Ok(false),
},
None => match usize::try_from(slot) {
Ok(m) if m < smp_invsee::SIZE => s.invsee.click_menu(uuid, m, &mut world, &mut record).map(|c| c == Some(true)),
_ => Ok(false),
},
};
if let (Some(m), Err(e)) = (&asked, &result)
&& let Err(err) = audit(&dir, m, &format!("refused: {e}"))
{
log::error!("[smp] invsee audit: {err}");
}
let name = game.native_server_players().into_iter().find(|p| p.uuid == uuid).map(|p| p.name);
let close = matches!(result, Ok(true));
let summary = if close { s.invsee.close(uuid, name.as_deref().unwrap_or("?")) } else { None };
let refreshed = (!close).then(|| items(&s.invsee, uuid));
ACTIVE.store(!s.invsee.views.is_empty() || !s.pending.is_empty(), Relaxed);
drop(guard);
if let Err(e) = &result {
game.native_tell(uuid, Text::literal(format!("Nothing moved: {e}.")).with_color(text::RED));
}
if close {
game.native_close_menu(uuid);
} else if let Some(items) = refreshed {
game.native_set_menu_items(uuid, items);
}
if let Some(line) = summary {
staff(game, uuid, &line);
}
true
}
/// An `/invsee` menu closed: true when it was one.
pub(in crate::game) fn menu_closed(game: &mut Game<'_>, uuid: u128, window: i32) -> bool {
let mut guard = state();
let s = guard.as_mut().expect("made");
if s.invsee.views.get(&uuid).is_none_or(|v| v.window != Some(window)) {
return false;
}
let name = game.native_server_players().into_iter().find(|p| p.uuid == uuid).map(|p| p.name);
let summary = s.invsee.close(uuid, name.as_deref().unwrap_or("?"));
ACTIVE.store(!s.invsee.views.is_empty() || !s.pending.is_empty(), Relaxed);
drop(guard);
if let Some(line) = summary {
staff(game, uuid, &line);
}
true
}
+3 -1
View File
@@ -1,4 +1,6 @@
// A click in a menu a native mod opened: the SMP's trade menu, when it is one.
fn smp_menu_clicked(game: &mut crate::game::Game<'_>, uuid: u128, window: i32, slot: i32, mode: i32) {
crate::game::smp_glue::menu_clicked(game, uuid, window, slot, mode);
if !crate::game::smp_invsee_glue::menu_clicked(game, uuid, window, slot, mode) {
crate::game::smp_glue::menu_clicked(game, uuid, window, slot, mode);
}
}
+3 -1
View File
@@ -1,4 +1,6 @@
// A menu a native mod opened closed: a trade's ends, nothing moved.
fn smp_menu_closed(game: &mut crate::game::Game<'_>, uuid: u128, window: i32) {
crate::game::smp_glue::menu_closed(game, uuid, window);
if !crate::game::smp_invsee_glue::menu_closed(game, uuid, window) {
crate::game::smp_glue::menu_closed(game, uuid, window);
}
}
+1
View File
@@ -1,4 +1,5 @@
// The SMP's commands in every player's command tree.
fn smp_register_commands(commands: &mut Vec<crate::game::native_server::NativeCommand>) {
crate::game::smp_glue::commands(commands);
crate::game::smp_invsee_glue::commands(commands);
}
+631
View File
@@ -0,0 +1,631 @@
// `/invsee <player>` and `/endersee <player>`: an operator of level 3 sees
// another player's inventory (main, hotbar, armour, off hand) or ender chest
// in a chest menu, on or off the server, and moves stacks in and out: a click
// on one of theirs takes it into the operator's inventory, a click on one of
// the operator's own puts it into theirs. One stack moves at a time, and only
// if it is still exactly what the menu showed (the game checks it and moves
// it whole, or nothing): no copy, no loss, whatever the player does
// meanwhile (moving items, leaving, joining, dying). Each move is in the
// audit log; closing the menu writes a one-line summary for the staff. The
// menu is read again once a second while it is open; nothing runs when no
// menu is open. No game types: the stacks are a type parameter, the game
// lends its inventories as a `World`. Added to the game as
// `game::smp_invsee`; also a module of this crate, for its tests.
use super::smp_economy::{Out, Player};
use std::collections::HashMap;
pub const COMMANDS: [&str; 2] = ["invsee", "endersee"];
/// The level that may look.
pub const LEVEL: u8 = 3;
pub const ROWS: u8 = 6;
pub const SIZE: usize = 54;
/// The bottom row's buttons.
const INFO: usize = 49;
const CLOSE: usize = 53;
/// Which inventory: the player's 41 slots (0..36 main, 36..40 armour feet to
/// head, 40 off hand), or the ender chest's 27.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Kind {
Player,
Ender,
}
/// A stack's item and count, for the log.
pub trait Stack: Clone + PartialEq {
fn id(&self) -> String;
fn count(&self) -> u32;
}
/// The game's inventories (the glue's, over the native helpers).
pub trait World<S> {
/// A player's inventory and whether it is live (they are on): Err while
/// they log in, are dead, or never played here.
fn read(&self, uuid: u128, kind: Kind) -> Result<(Vec<Option<S>>, bool), String>;
/// One stack from `from`'s slot, still exactly `expected`, into `to`'s
/// inventory where it fits whole, or nothing moves.
fn move_stack(
&mut self,
from: (u128, Kind, usize),
expected: &S,
to: (u128, Kind),
) -> Result<(), String>;
}
/// One operator's open menu.
#[derive(Clone, Debug)]
pub struct View<S> {
pub target: u128,
pub name: String,
pub kind: Kind,
pub window: Option<i32>,
/// What the menu shows, by inventory slot.
pub shown: Vec<Option<S>>,
pub online: bool,
/// Why the inventory can't be read now (logging in, dead...).
pub busy: Option<String>,
/// What moved: (taken from the player, item, count).
pub moved: Vec<(bool, String, u32)>,
}
/// A cell of the menu.
#[derive(Clone, Debug, PartialEq)]
pub enum Cell<S> {
Empty,
Stack(S),
Filler,
/// The player's name, on or off, and why it can't be read now.
Info {
name: String,
online: bool,
busy: Option<String>,
},
/// Above an armour or off-hand slot.
Label(&'static str),
Close,
}
/// A move the glue logs before it asks the game for it.
#[derive(Clone, Debug, PartialEq)]
pub struct Move {
pub op: u128,
pub target: u128,
pub kind: Kind,
/// Taken from the player (true) or put into their inventory.
pub take: bool,
pub item: String,
pub count: u32,
}
/// The menu slot of each inventory slot. Player: the main inventory in the
/// first three rows, the hotbar in the fourth (as the player sees it), the
/// armour head to feet then the off hand in the fifth.
pub fn menu_slot(kind: Kind, slot: usize) -> Option<usize> {
match (kind, slot) {
(Kind::Player, 9..36) => Some(slot - 9),
(Kind::Player, 0..9) => Some(27 + slot),
(Kind::Player, 36..40) => Some(36 + (39 - slot)),
(Kind::Player, 40) => Some(41),
(Kind::Ender, 0..27) => Some(slot),
_ => None,
}
}
/// The inventory slot a menu slot shows.
pub fn inventory_slot(kind: Kind, menu: usize) -> Option<usize> {
let size = match kind {
Kind::Player => 41,
Kind::Ender => 27,
};
(0..size).find(|&s| menu_slot(kind, s) == Some(menu))
}
pub struct Invsee<S> {
pub views: HashMap<u128, View<S>>,
}
impl<S> Default for Invsee<S> {
fn default() -> Self {
Self {
views: HashMap::new(),
}
}
}
/// What a command asks to open: the player (UUID, real name) and which inventory.
pub type Open = Option<(u128, String, Kind)>;
impl<S: Stack> Invsee<S> {
/// Runs `/invsee` or `/endersee`: the target named, to open at the next
/// tick (the game's inventories aren't reachable inside a command).
/// `find`: a player who has played here by name (UUID, real name).
pub fn command(
&mut self,
me: &Player,
find: &dyn Fn(&str) -> Option<(u128, String)>,
line: &str,
) -> Option<(Vec<Out>, Open)> {
let words: Vec<&str> = line.split_whitespace().collect();
let (&name, args) = words.split_first()?;
let kind = match name {
"invsee" => Kind::Player,
"endersee" => Kind::Ender,
_ => return None,
};
if me.level < LEVEL {
return Some((
vec![Out::Error(format!("Only operators may use /{name}."))],
None,
));
}
let [who] = args else {
return Some((vec![Out::Error(format!("Usage: /{name} <player>"))], None));
};
match find(who) {
Some((uuid, _)) if uuid == me.uuid => {
Some((vec![Out::Error("That is your own inventory.".into())], None))
}
Some((uuid, real)) => Some((Vec::new(), Some((uuid, real, kind)))),
None => Some((
vec![Out::Error(format!(
"No player named {who} has played here."
))],
None,
)),
}
}
/// The view for `op` on `target`, read now (the glue opens the menu).
pub fn open(
&mut self,
op: u128,
target: u128,
name: &str,
kind: Kind,
world: &dyn World<S>,
) -> Result<(), String> {
let (shown, online) = world.read(target, kind)?;
self.views.insert(
op,
View {
target,
name: name.to_owned(),
kind,
window: None,
shown,
online,
busy: None,
moved: Vec::new(),
},
);
Ok(())
}
/// The menu's 54 cells for `op`.
pub fn layout(&self, op: u128) -> Vec<Cell<S>> {
let Some(v) = self.views.get(&op) else {
return Vec::new();
};
let mut cells = vec![Cell::Empty; SIZE];
let used = match v.kind {
Kind::Player => 42,
Kind::Ender => 27,
};
for c in cells.iter_mut().take(45).skip(used) {
*c = Cell::Filler;
}
if v.kind == Kind::Player {
cells[40] = Cell::Label("Off hand →");
}
for (slot, s) in v.shown.iter().enumerate() {
if let (Some(m), Some(s)) = (menu_slot(v.kind, slot), s) {
cells[m] = Cell::Stack(s.clone());
}
}
for c in cells.iter_mut().skip(45) {
*c = Cell::Filler;
}
cells[INFO] = Cell::Info {
name: v.name.clone(),
online: v.online,
busy: v.busy.clone(),
};
cells[CLOSE] = Cell::Close;
cells
}
/// Reads the target's inventory again: whether the menu changed.
pub fn refresh(&mut self, op: u128, world: &dyn World<S>) -> bool {
let Some(v) = self.views.get_mut(&op) else {
return false;
};
let (shown, online, busy) = match world.read(v.target, v.kind) {
Ok((s, on)) => (s, on, None),
Err(e) => (v.shown.clone(), v.online, Some(e)),
};
let changed = shown != v.shown || online != v.online || busy != v.busy;
(v.shown, v.online, v.busy) = (shown, online, busy);
changed
}
/// A click on the menu's slot `menu`: the stack shown there into the
/// operator's inventory. Ok(None): nothing to do; Ok(Some(close)) when
/// the close button. `log` records the move first (the audit log); a
/// move it refuses doesn't happen.
pub fn click_menu(
&mut self,
op: u128,
menu: usize,
world: &mut dyn World<S>,
log: &mut dyn FnMut(&Move) -> Result<(), String>,
) -> Result<Option<bool>, String> {
let Some(v) = self.views.get(&op) else {
return Ok(None);
};
if menu == CLOSE {
return Ok(Some(true));
}
let Some(slot) = inventory_slot(v.kind, menu) else {
return Ok(None);
};
let Some(stack) = v.shown.get(slot).cloned().flatten() else {
return Ok(None);
};
let m = Move {
op,
target: v.target,
kind: v.kind,
take: true,
item: stack.id(),
count: stack.count(),
};
let r = self.apply(op, &m, world, log, |w| {
w.move_stack((m.target, m.kind, slot), &stack, (op, Kind::Player))
});
self.refresh(op, world);
r.map(|_| Some(false))
}
/// A click on the operator's own stack `stack` in inventory slot `slot`
/// (0..36): into the player's inventory.
pub fn click_own(
&mut self,
op: u128,
slot: usize,
stack: &S,
world: &mut dyn World<S>,
log: &mut dyn FnMut(&Move) -> Result<(), String>,
) -> Result<(), String> {
let Some(v) = self.views.get(&op) else {
return Ok(());
};
let m = Move {
op,
target: v.target,
kind: v.kind,
take: false,
item: stack.id(),
count: stack.count(),
};
let r = self.apply(op, &m, world, log, |w| {
w.move_stack((op, Kind::Player, slot), stack, (m.target, m.kind))
});
self.refresh(op, world);
r
}
fn apply(
&mut self,
op: u128,
m: &Move,
world: &mut dyn World<S>,
log: &mut dyn FnMut(&Move) -> Result<(), String>,
go: impl FnOnce(&mut dyn World<S>) -> Result<(), String>,
) -> Result<(), String> {
log(m)?;
go(world)?;
if let Some(v) = self.views.get_mut(&op) {
v.moved.push((m.take, m.item.clone(), m.count));
}
Ok(())
}
/// The operator's menu closed (or they left): the staff's one-line
/// summary when something moved.
pub fn close(&mut self, op: u128, op_name: &str) -> Option<String> {
let v = self.views.remove(&op)?;
if v.moved.is_empty() {
return None;
}
let list = |take: bool| {
let mut sums: Vec<(String, u32)> = Vec::new();
for (t, item, n) in &v.moved {
if *t == take {
let item = item.strip_prefix("minecraft:").unwrap_or(item).to_owned();
match sums.iter_mut().find(|(i, _)| *i == item) {
Some((_, c)) => *c += n,
None => sums.push((item, *n)),
}
}
}
sums.iter()
.map(|(i, n)| format!("{n} {i}"))
.collect::<Vec<_>>()
.join(", ")
};
let what = match v.kind {
Kind::Player => "inventory",
Kind::Ender => "ender chest",
};
let (took, put) = (list(true), list(false));
let mut parts = Vec::new();
if !took.is_empty() {
parts.push(format!("took {took}"));
}
if !put.is_empty() {
parts.push(format!("put {put}"));
}
Some(format!(
"{op_name} edited {}'s {what}{}: {}",
v.name,
if v.online { "" } else { " (offline)" },
parts.join("; ")
))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[derive(Clone, Debug, PartialEq)]
struct S(&'static str, u32);
impl Stack for S {
fn id(&self) -> String {
format!("minecraft:{}", self.0)
}
fn count(&self) -> u32 {
self.1
}
}
#[derive(Clone, Copy, PartialEq)]
enum State {
On,
Off,
LoggingIn,
Dead,
}
/// Two players: the operator (1, on) and Steve (2), whose inventories
/// are live or a file by `state`; moves as the game's helper makes them.
struct Fake {
inv: HashMap<(u128, bool), Vec<Option<S>>>,
state: State,
}
impl Fake {
fn new() -> Self {
let mut steve = vec![None; 41];
steve[0] = Some(S("diamond", 5));
steve[39] = Some(S("iron_helmet", 1));
let mut ender = vec![None; 27];
ender[3] = Some(S("emerald", 9));
let mut inv = HashMap::new();
inv.insert((1, false), vec![None; 41]);
inv.insert((2, false), steve);
inv.insert((2, true), ender);
Self {
inv,
state: State::On,
}
}
fn total(&self) -> u32 {
self.inv.values().flatten().flatten().map(|s| s.1).sum()
}
}
impl World<S> for Fake {
fn read(&self, uuid: u128, kind: Kind) -> Result<(Vec<Option<S>>, bool), String> {
if uuid == 2 {
match self.state {
State::LoggingIn => return Err("the player is logging in".into()),
State::Dead => return Err("the player is dead".into()),
_ => {}
}
}
let online = uuid == 1 || self.state == State::On;
Ok((self.inv[&(uuid, kind == Kind::Ender)].clone(), online))
}
fn move_stack(
&mut self,
from: (u128, Kind, usize),
expected: &S,
to: (u128, Kind),
) -> Result<(), String> {
self.read(from.0, from.1)?;
self.read(to.0, to.1)?;
let src = self.inv.get_mut(&(from.0, from.1 == Kind::Ender)).unwrap();
if src[from.2].as_ref() != Some(expected) {
return Err("that stack changed".into());
}
let s = src[from.2].take().unwrap();
let dst = self.inv.get_mut(&(to.0, to.1 == Kind::Ender)).unwrap();
let room = if to.1 == Kind::Player { 36 } else { 27 };
match dst.iter_mut().take(room).find(|d| d.is_none()) {
Some(d) => *d = Some(s),
None => {
self.inv.get_mut(&(from.0, from.1 == Kind::Ender)).unwrap()[from.2] = Some(s);
return Err("no room".into());
}
}
Ok(())
}
}
fn op() -> Player {
Player {
uuid: 1,
name: "Boss".into(),
level: 4,
}
}
fn opened(kind: Kind) -> (Invsee<S>, Fake, Vec<Move>) {
let mut i = Invsee::default();
let w = Fake::new();
i.open(1, 2, "Steve", kind, &w).unwrap();
(i, w, Vec::new())
}
#[test]
fn only_operators_and_a_known_other_player() {
let mut i: Invsee<S> = Invsee::default();
let find = |n: &str| {
(n == "Steve")
.then(|| (2, "Steve".to_owned()))
.or_else(|| (n == "Boss").then(|| (1, "Boss".into())))
};
let pleb = Player { level: 0, ..op() };
assert_eq!(
i.command(&pleb, &find, "invsee Steve").unwrap().0,
[Out::Error("Only operators may use /invsee.".into())]
);
assert_eq!(
i.command(&op(), &find, "invsee Boss").unwrap().0,
[Out::Error("That is your own inventory.".into())]
);
assert_eq!(
i.command(&op(), &find, "endersee Nobody").unwrap().0,
[Out::Error("No player named Nobody has played here.".into())]
);
assert_eq!(
i.command(&op(), &find, "endersee Steve").unwrap().1,
Some((2, "Steve".into(), Kind::Ender))
);
assert!(i.command(&op(), &find, "balance").is_none());
}
#[test]
fn the_layout_shows_main_hotbar_armour_and_off_hand() {
let (i, _, _) = opened(Kind::Player);
let cells = i.layout(1);
assert_eq!(
cells[27],
Cell::Stack(S("diamond", 5)),
"the hotbar's first slot"
);
assert_eq!(
cells[36],
Cell::Stack(S("iron_helmet", 1)),
"the head first"
);
assert_eq!(cells[44], Cell::Filler);
assert!(matches!(&cells[INFO], Cell::Info { name, online: true, .. } if name == "Steve"));
for s in 0..41 {
assert_eq!(
inventory_slot(Kind::Player, menu_slot(Kind::Player, s).unwrap()),
Some(s)
);
}
}
#[test]
fn taking_and_putting_move_one_stack_and_log_it_first() {
let (mut i, mut w, mut log) = opened(Kind::Player);
let before = w.total();
let mut rec = |m: &Move| {
log.push(m.clone());
Ok(())
};
assert_eq!(i.click_menu(1, 27, &mut w, &mut rec), Ok(Some(false)));
assert_eq!(w.inv[&(1, false)][0], Some(S("diamond", 5)));
assert_eq!(i.layout(1)[27], Cell::Empty, "read again after the move");
i.click_own(1, 0, &S("diamond", 5), &mut w, &mut rec)
.unwrap();
assert_eq!(w.inv[&(2, false)][0], Some(S("diamond", 5)));
assert_eq!(w.total(), before);
assert_eq!(log.len(), 2);
assert!(
log[0].take && !log[1].take && log[0].item == "minecraft:diamond" && log[0].count == 5
);
assert_eq!(
i.close(1, "Boss").as_deref(),
Some("Boss edited Steve's inventory: took 5 diamond; put 5 diamond")
);
assert!(i.views.is_empty());
}
#[test]
fn a_stack_the_player_moved_meanwhile_is_not_copied() {
let (mut i, mut w, _) = opened(Kind::Player);
// Steve moves his diamonds after the menu was read.
let d = w.inv.get_mut(&(2, false)).unwrap()[0].take();
w.inv.get_mut(&(2, false)).unwrap()[5] = d;
let before = w.total();
assert_eq!(
i.click_menu(1, 27, &mut w, &mut |_| Ok(())),
Err("that stack changed".into())
);
assert_eq!(w.total(), before);
assert!(w.inv[&(1, false)].iter().all(Option::is_none));
// The menu shows where they are now.
assert_eq!(i.layout(1)[32], Cell::Stack(S("diamond", 5)));
}
#[test]
fn leaving_joining_and_dying_while_the_menu_is_open() {
let (mut i, mut w, _) = opened(Kind::Ender);
let before = w.total();
// He leaves: his file, the same items, offline in the menu.
w.state = State::Off;
assert!(i.refresh(1, &w));
assert!(matches!(
&i.layout(1)[INFO],
Cell::Info { online: false, .. }
));
assert_eq!(i.click_menu(1, 3, &mut w, &mut |_| Ok(())), Ok(Some(false)));
assert_eq!(w.inv[&(1, false)][0], Some(S("emerald", 9)));
// He is logging in: nothing moves, the menu says why.
w.state = State::LoggingIn;
assert_eq!(
i.click_own(1, 0, &S("emerald", 9), &mut w, &mut |_| Ok(())),
Err("the player is logging in".into())
);
assert!(
matches!(&i.layout(1)[INFO], Cell::Info { busy: Some(b), .. } if b.contains("logging in"))
);
// Dead: the same.
w.state = State::Dead;
assert!(
i.click_own(1, 0, &S("emerald", 9), &mut w, &mut |_| Ok(()))
.is_err()
);
// Back on: the move goes through live.
w.state = State::On;
i.click_own(1, 0, &S("emerald", 9), &mut w, &mut |_| Ok(()))
.unwrap();
assert_eq!(w.total(), before);
assert_eq!(
i.close(1, "Boss").as_deref(),
Some("Boss edited Steve's ender chest: took 9 emerald; put 9 emerald")
);
}
#[test]
fn a_move_the_log_refuses_does_not_happen_and_closing_with_nothing_moved_says_nothing() {
let (mut i, mut w, _) = opened(Kind::Player);
let before = w.inv.clone();
assert_eq!(
i.click_menu(1, 27, &mut w, &mut |_| Err("audit.log: disk full".into())),
Err("audit.log: disk full".into())
);
assert_eq!(w.inv, before);
assert_eq!(
i.click_menu(1, CLOSE, &mut w, &mut |_| Ok(())),
Ok(Some(true))
);
assert_eq!(i.close(1, "Boss"), None);
}
}
+1
View File
@@ -8,5 +8,6 @@ fn smp_server_tick(
crate::game::native_lifecycle::ServerTickPhase::ServerTickEnd { .. }
) {
crate::game::smp_glue::tick(game);
crate::game::smp_invsee_glue::tick(game);
}
}
+2
View File
@@ -23,6 +23,8 @@ pub mod smp_claims;
pub mod smp_economy;
#[path = "../mixins/smp/smp_games.rs"]
pub mod smp_games;
#[path = "../mixins/smp/smp_invsee.rs"]
pub mod smp_invsee;
#[path = "../mixins/smp/smp_jobs.rs"]
pub mod smp_jobs;
#[path = "../mixins/smp/smp_lottery.rs"]