mirror of
https://github.com/horizon-rust/horizon-mods.git
synced 2026-11-07 11:05:58 -10:00
smp 0.25.0: /invsee and /endersee for operators
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
cacc3360e0
commit
8295b4c1ef
14 files changed
+1014
-10
No files matched your search
Generated
+1
-1
@@ -4,7 +4,7 @@ version = 4
|
||||
|
||||
[[package]]
|
||||
name = "horizon-mod-smp"
|
||||
version = "0.24.0"
|
||||
version = "0.25.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "horizon-mod-smp"
|
||||
version = "0.24.0"
|
||||
version = "0.25.0"
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
publish = false
|
||||
|
||||
@@ -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`) |
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
|
||||
Reference in new issue
Block a user