mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-09 17:19:15 -10:00
Merge pull request #6106 from horizon-rust/feat/native-inventories
🤖 Agent: queued for merge: pr_ready green on 6deec70 (block above); native helpers for the SMP's /invsee and market (fragment, numbered at stamping).
This commit is contained in:
6 files changed
+446
-2
No files matched your search
@@ -200,6 +200,7 @@ mod native_entities;
|
||||
mod native_explosions;
|
||||
mod native_falling;
|
||||
mod native_hooks;
|
||||
mod native_inventories;
|
||||
mod native_lifecycle;
|
||||
mod native_menus;
|
||||
mod native_mob_draw;
|
||||
|
||||
@@ -0,0 +1,401 @@
|
||||
//! Another player's inventories for server mods (API @API:native_inventories@):
|
||||
//! what an operator's `/invsee` or `/endersee` needs. A player online is read
|
||||
//! and changed live; a player who isn't on the server is read from, and
|
||||
//! written to, their file (`players/data/<uuid>.dat`, as the server saves
|
||||
//! it), never while they are logging in, dead or swapped in. One stack moves
|
||||
//! at a time, checked against what the mod last showed, so a menu built on
|
||||
//! these never copies or loses an item. Server menus are in `native_menus.rs`;
|
||||
//! maps a mod draws in `native_send_map` below.
|
||||
use super::Game;
|
||||
use horizon_client::{
|
||||
items::ItemStack,
|
||||
lan::{guest::Guest, playerdata},
|
||||
save::{Level, read_dat, write_dat},
|
||||
};
|
||||
use std::path::Path;
|
||||
|
||||
/// Which of a player's inventories (`native_inventory`).
|
||||
#[allow(dead_code)]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum NativeInventoryKind {
|
||||
/// 41 slots: 0..9 the hotbar, 9..36 the rest (as `Inventory`), 36..40
|
||||
/// the armour (feet, legs, chest, head), 40 the off hand.
|
||||
Player,
|
||||
/// The ender chest's 27 slots.
|
||||
Ender,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl NativeInventoryKind {
|
||||
pub(crate) fn size(self) -> usize {
|
||||
match self {
|
||||
Self::Player => 41,
|
||||
Self::Ender => 27,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A player's inventory as `native_inventory` read it: its slots, and
|
||||
/// whether it is the live one (the player is on the server) or their file's.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) struct NativeInventory {
|
||||
pub slots: Vec<Option<ItemStack>>,
|
||||
pub online: bool,
|
||||
}
|
||||
|
||||
/// `Level.inventory`'s slot numbers (the file's) for our 41.
|
||||
fn file_slot(i: usize) -> Option<u8> {
|
||||
match i {
|
||||
0..36 => Some(i as u8),
|
||||
36..40 => Some(100 + (i - 36) as u8),
|
||||
40 => Some(150),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn guest_slots(g: &Guest, kind: NativeInventoryKind) -> Vec<Option<ItemStack>> {
|
||||
match kind {
|
||||
NativeInventoryKind::Player => {
|
||||
let mut s = g.inventory.clone();
|
||||
s.resize(36, None);
|
||||
// `armor` is head first.
|
||||
s.extend([3, 2, 1, 0].map(|i| g.armor[i].clone()));
|
||||
s.push(g.offhand.clone());
|
||||
s
|
||||
}
|
||||
NativeInventoryKind::Ender => {
|
||||
let mut s = g.ender_items.clone();
|
||||
s.resize(27, None);
|
||||
s
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn set_guest_slots(g: &mut Guest, kind: NativeInventoryKind, mut s: Vec<Option<ItemStack>>) {
|
||||
match kind {
|
||||
NativeInventoryKind::Player => {
|
||||
g.offhand = s.pop().flatten();
|
||||
for (k, i) in [3, 2, 1, 0].into_iter().enumerate() {
|
||||
g.armor[i] = s[36 + k].take();
|
||||
}
|
||||
s.truncate(36);
|
||||
g.inventory = s;
|
||||
}
|
||||
NativeInventoryKind::Ender => g.ender_items = s,
|
||||
}
|
||||
}
|
||||
|
||||
fn level_slots(level: &Level, kind: NativeInventoryKind) -> Vec<Option<ItemStack>> {
|
||||
match kind {
|
||||
NativeInventoryKind::Player => {
|
||||
let mut s = vec![None; 41];
|
||||
for (slot, item, count, damage) in &level.inventory {
|
||||
if let Some(i) = (0..41).find(|&i| file_slot(i) == Some(*slot)) {
|
||||
s[i] = Some(ItemStack {
|
||||
item: item.as_str().into(),
|
||||
count: (*count).max(1),
|
||||
damage: *damage,
|
||||
extra: level.inventory_components.get(slot).cloned(),
|
||||
});
|
||||
}
|
||||
}
|
||||
s
|
||||
}
|
||||
NativeInventoryKind::Ender => {
|
||||
let mut s = level.ender_items.clone();
|
||||
s.resize(27, None);
|
||||
s
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn set_level_slots(level: &mut Level, kind: NativeInventoryKind, s: Vec<Option<ItemStack>>) {
|
||||
match kind {
|
||||
NativeInventoryKind::Player => {
|
||||
level.inventory.clear();
|
||||
level.inventory_components.clear();
|
||||
for (i, stack) in s.into_iter().enumerate() {
|
||||
if let (Some(slot), Some(st)) = (file_slot(i), stack) {
|
||||
level
|
||||
.inventory
|
||||
.push((slot, st.item.to_string(), st.count, st.damage));
|
||||
if let Some(extra) = st.extra {
|
||||
level.inventory_components.insert(slot, extra);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
NativeInventoryKind::Ender => level.ender_items = s,
|
||||
}
|
||||
}
|
||||
|
||||
/// A saved player's file read as `playerdata::load` reads it, kept whole
|
||||
/// (`player_extra`) so writing it back changes only what was edited.
|
||||
fn read_level(world: &Path, uuid: u128) -> Option<Level> {
|
||||
let file = playerdata::path(world, uuid);
|
||||
let tag = read_dat(&file)?;
|
||||
let mut level = Level {
|
||||
health: 20.0,
|
||||
food: 20,
|
||||
saturation: 5.0,
|
||||
..Level::default()
|
||||
};
|
||||
level.read_player(&tag);
|
||||
level.player_extra = Some(tag);
|
||||
Some(level)
|
||||
}
|
||||
|
||||
/// Where one side of a move lives while it is made.
|
||||
enum Side {
|
||||
/// A guest's index in the hub.
|
||||
Live(usize),
|
||||
/// A player's file, read.
|
||||
File(Box<Level>),
|
||||
}
|
||||
|
||||
/// Another player's inventories, for server mods. API @API:native_inventories@.
|
||||
#[allow(dead_code)]
|
||||
impl Game<'_> {
|
||||
/// The player `uuid`'s inventory `kind`: the live one when they are on
|
||||
/// the server, else their file's. Err while they are logging in, dead,
|
||||
/// swapped in (inside a player's hook: call from `native_menu_clicked` or
|
||||
/// `native_server_tick`), or when they have never played here.
|
||||
pub(super) fn native_inventory(
|
||||
&self,
|
||||
uuid: u128,
|
||||
kind: NativeInventoryKind,
|
||||
) -> Result<NativeInventory, String> {
|
||||
match self.native_inventory_side(uuid)? {
|
||||
Side::Live(i) => {
|
||||
let host = self.lan.as_ref().expect("live");
|
||||
Ok(NativeInventory {
|
||||
slots: guest_slots(&host.hub.guests[i], kind),
|
||||
online: true,
|
||||
})
|
||||
}
|
||||
Side::File(level) => Ok(NativeInventory {
|
||||
slots: level_slots(&level, kind),
|
||||
online: false,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn native_inventory_side(&self, uuid: u128) -> Result<Side, String> {
|
||||
if self.lan_acting.is_some() {
|
||||
return Err("inside a player's turn".into());
|
||||
}
|
||||
let Some(host) = self.lan.as_ref() else {
|
||||
return Err("no server".into());
|
||||
};
|
||||
if let Some(i) = host.hub.guests.iter().position(|g| g.uuid == uuid) {
|
||||
let g = &host.hub.guests[i];
|
||||
return if !g.placed {
|
||||
Err("the player is logging in".into())
|
||||
} else if g.dead {
|
||||
Err("the player is dead".into())
|
||||
} else {
|
||||
Ok(Side::Live(i))
|
||||
};
|
||||
}
|
||||
if self.world_root.as_os_str().is_empty() {
|
||||
return Err("no world".into());
|
||||
}
|
||||
read_level(&self.world_root, uuid)
|
||||
.map(|l| Side::File(Box::new(l)))
|
||||
.ok_or_else(|| "no saved player".into())
|
||||
}
|
||||
|
||||
/// Moves the stack in slot `from_slot` of `from`'s inventory `from_kind`,
|
||||
/// which must still be exactly `expected`, into `to`'s inventory
|
||||
/// `to_kind` where it fits whole (onto equal stacks first, then empty
|
||||
/// slots; for `Player`, the 36 main slots, never armour or off hand), or
|
||||
/// nothing moves and the Err says why. Each side is live or its file
|
||||
/// (`native_inventory`); at most one may be a file, which is written
|
||||
/// before the live side changes. Their screens follow with the tick's
|
||||
/// sync.
|
||||
pub(super) fn native_move_stack(
|
||||
&mut self,
|
||||
from: (u128, NativeInventoryKind, usize),
|
||||
expected: &ItemStack,
|
||||
to: (u128, NativeInventoryKind),
|
||||
) -> Result<(), String> {
|
||||
let (from_uuid, from_kind, from_slot) = from;
|
||||
let (to_uuid, to_kind) = to;
|
||||
if from_uuid == to_uuid && from_kind == to_kind {
|
||||
return Err("the same inventory".into());
|
||||
}
|
||||
let a = self.native_inventory_side(from_uuid)?;
|
||||
let b = if from_uuid == to_uuid {
|
||||
None
|
||||
} else {
|
||||
Some(self.native_inventory_side(to_uuid)?)
|
||||
};
|
||||
if matches!(a, Side::File(_)) && matches!(b, Some(Side::File(_))) {
|
||||
return Err("both players are away".into());
|
||||
}
|
||||
let items = self.res.items();
|
||||
let max = |s: &ItemStack| items.get(&s.item).map_or(64, |i| i.max_stack);
|
||||
let read = |side: &Side, kind| match side {
|
||||
Side::Live(i) => guest_slots(&self.lan.as_ref().expect("live").hub.guests[*i], kind),
|
||||
Side::File(level) => level_slots(level, kind),
|
||||
};
|
||||
let mut src = read(&a, from_kind);
|
||||
let mut dst = read(b.as_ref().unwrap_or(&a), to_kind);
|
||||
match src.get_mut(from_slot) {
|
||||
Some(s) if s.as_ref() == Some(expected) => *s = None,
|
||||
_ => return Err("that stack changed".into()),
|
||||
}
|
||||
let room = match to_kind {
|
||||
NativeInventoryKind::Player => 36,
|
||||
NativeInventoryKind::Ender => 27,
|
||||
};
|
||||
if !super::native_menus::fit(&mut dst[..room], std::slice::from_ref(expected), &max) {
|
||||
return Err("no room".into());
|
||||
}
|
||||
// The file first: when it can't be written, nothing has moved.
|
||||
let world = self.world_root.clone();
|
||||
let write =
|
||||
|side: Side, kind, slots: Vec<Option<ItemStack>>, uuid| -> Result<Side, String> {
|
||||
if let Side::File(mut level) = side {
|
||||
set_level_slots(&mut level, kind, slots);
|
||||
write_dat(&playerdata::path(&world, uuid), level.player_tag())
|
||||
.map_err(|e| format!("the player's file: {e}"))?;
|
||||
return Ok(Side::File(level));
|
||||
}
|
||||
Ok(side)
|
||||
};
|
||||
let (a, b) = match b {
|
||||
None => {
|
||||
// Two inventories of one player: their file or live.
|
||||
let mut both = a;
|
||||
if let Side::File(level) = &mut both {
|
||||
set_level_slots(level, from_kind, src.clone());
|
||||
}
|
||||
let both = write(both, to_kind, dst.clone(), from_uuid)?;
|
||||
(both, None)
|
||||
}
|
||||
Some(b) if matches!(b, Side::File(_)) => {
|
||||
let b = write(b, to_kind, dst.clone(), to_uuid)?;
|
||||
(a, Some(b))
|
||||
}
|
||||
Some(b) => {
|
||||
let a = write(a, from_kind, src.clone(), from_uuid)?;
|
||||
(a, Some(b))
|
||||
}
|
||||
};
|
||||
let host = self.lan.as_mut().expect("checked");
|
||||
if let Side::Live(i) = a {
|
||||
set_guest_slots(&mut host.hub.guests[i], from_kind, src);
|
||||
if b.is_none() {
|
||||
set_guest_slots(&mut host.hub.guests[i], to_kind, dst);
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
if let Some(Side::Live(i)) = b {
|
||||
set_guest_slots(&mut host.hub.guests[i], to_kind, dst);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Sends the player `uuid` the whole picture of map `map_id`: 128 x 128
|
||||
/// colours (`MapColor` ids times 4 plus the shade, row by row), scale 0,
|
||||
/// locked, no decorations, as `ClientboundMapItemDataPacket`. A filled
|
||||
/// map the mod gives with that `minecraft:map_id` then shows it in hand
|
||||
/// and in item frames; the server keeps nothing (a map id with no saved
|
||||
/// map is never sent again by the game), so send it again after the
|
||||
/// player joins. Use ids far from the world's own (they count up from 0).
|
||||
/// False for a player not here, or `colors` not 16384 long.
|
||||
/// API @API:native_inventories@.
|
||||
pub(super) fn native_send_map(&mut self, uuid: u128, map_id: i32, colors: &[u8]) -> bool {
|
||||
use horizon_protocol::packets::play::{Clientbound, MapItemData, MapPatch};
|
||||
if colors.len() != 128 * 128 {
|
||||
return false;
|
||||
}
|
||||
self.native_send(
|
||||
uuid,
|
||||
Clientbound::MapItemData(MapItemData {
|
||||
map_id,
|
||||
scale: 0,
|
||||
locked: true,
|
||||
decorations: None,
|
||||
patch: Some(MapPatch {
|
||||
width: 128,
|
||||
height: 128,
|
||||
start_x: 0,
|
||||
start_y: 0,
|
||||
colors: colors.to_vec(),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn stack(item: &str, count: u32) -> ItemStack {
|
||||
ItemStack {
|
||||
item: item.into(),
|
||||
count,
|
||||
damage: 0,
|
||||
extra: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_files_slots_map_to_the_41_and_back() {
|
||||
let mut level = Level::default();
|
||||
let mut s = vec![None; 41];
|
||||
s[0] = Some(stack("minecraft:stone", 5));
|
||||
s[35] = Some(stack("minecraft:dirt", 1));
|
||||
s[36] = Some(stack("minecraft:iron_boots", 1));
|
||||
s[39] = Some(stack("minecraft:iron_helmet", 1));
|
||||
s[40] = Some(stack("minecraft:shield", 1));
|
||||
set_level_slots(&mut level, NativeInventoryKind::Player, s.clone());
|
||||
let slots: Vec<u8> = level.inventory.iter().map(|e| e.0).collect();
|
||||
assert_eq!(slots, [0, 35, 100, 103, 150]);
|
||||
assert_eq!(level_slots(&level, NativeInventoryKind::Player), s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_saved_player_file_round_trips_its_inventory_and_keeps_the_rest() {
|
||||
let dir = std::env::temp_dir().join(format!("native-inv-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let uuid = 0x1234_u128;
|
||||
let file = playerdata::path(&dir, uuid);
|
||||
std::fs::create_dir_all(file.parent().unwrap()).unwrap();
|
||||
let mut level = Level {
|
||||
health: 13.0,
|
||||
xp_level: 7,
|
||||
..Level::default()
|
||||
};
|
||||
level.ender_items = vec![None; 27];
|
||||
level.ender_items[2] = Some(stack("minecraft:diamond", 3));
|
||||
let mut s = vec![None; 41];
|
||||
s[4] = Some(stack("minecraft:bread", 12));
|
||||
set_level_slots(&mut level, NativeInventoryKind::Player, s);
|
||||
write_dat(&file, level.player_tag()).unwrap();
|
||||
let mut back = read_level(&dir, uuid).unwrap();
|
||||
assert_eq!(back.health, 13.0);
|
||||
assert_eq!(back.xp_level, 7);
|
||||
assert_eq!(
|
||||
level_slots(&back, NativeInventoryKind::Ender)[2],
|
||||
Some(stack("minecraft:diamond", 3))
|
||||
);
|
||||
let mut inv = level_slots(&back, NativeInventoryKind::Player);
|
||||
assert_eq!(inv[4], Some(stack("minecraft:bread", 12)));
|
||||
inv[4] = None;
|
||||
set_level_slots(&mut back, NativeInventoryKind::Player, inv);
|
||||
write_dat(&file, back.player_tag()).unwrap();
|
||||
let again = read_level(&dir, uuid).unwrap();
|
||||
assert!(
|
||||
level_slots(&again, NativeInventoryKind::Player)
|
||||
.iter()
|
||||
.all(Option::is_none)
|
||||
);
|
||||
assert_eq!(again.xp_level, 7);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -31,7 +31,7 @@ pub(crate) fn native_menu_inventory_slot(size: usize, slot: i32) -> Option<usize
|
||||
/// Adds every stack of `gets` to the 36 slots of `inventory`, onto matching
|
||||
/// stacks first, then into empty slots, hotbar first; false when one doesn't
|
||||
/// fit whole. `max` is an item's stack size.
|
||||
fn fit(
|
||||
pub(super) fn fit(
|
||||
inventory: &mut [Option<ItemStack>],
|
||||
gets: &[ItemStack],
|
||||
max: &impl Fn(&ItemStack) -> u32,
|
||||
|
||||
@@ -291,7 +291,7 @@ impl Game<'_> {
|
||||
}
|
||||
/// Sends `packet` to that player, also when they are the acting player
|
||||
/// (their packets then go after the hook); false when they aren't on.
|
||||
fn native_send(&mut self, uuid: u128, packet: C) -> bool {
|
||||
pub(super) fn native_send(&mut self, uuid: u128, packet: C) -> bool {
|
||||
self.native_send_all(uuid, vec![packet])
|
||||
}
|
||||
fn native_send_all(&mut self, uuid: u128, packets: Vec<C>) -> bool {
|
||||
|
||||
@@ -1040,6 +1040,16 @@ fn network_stack(
|
||||
horizon_protocol::item::Component::Text(name.clone()),
|
||||
));
|
||||
}
|
||||
// Lore lines as loaded or as a server mod wrote them (a list of text
|
||||
// components), so a server menu's items can say more than their name.
|
||||
if let Some(horizon_world::nbt::Tag::List(lines)) =
|
||||
s.data().and_then(|d| d.other("minecraft:lore"))
|
||||
{
|
||||
stack.added.push((
|
||||
horizon_protocol::item::component::LORE,
|
||||
horizon_protocol::item::Component::Lore(lines.clone()),
|
||||
));
|
||||
}
|
||||
// The component as loaded, so a translatable name stays one.
|
||||
if let Some(name) = s.data().and_then(|d| d.other("minecraft:item_name")) {
|
||||
stack.added.push((
|
||||
@@ -1141,6 +1151,32 @@ mod tests {
|
||||
use horizon_protocol::item::{Component, component};
|
||||
use horizon_world::nbt::Tag;
|
||||
|
||||
#[test]
|
||||
fn lore_lines_reach_the_client() {
|
||||
let lines = vec![
|
||||
Tag::compound([("text", Tag::String("Buy 400".into()))]),
|
||||
Tag::compound([
|
||||
("text", Tag::String("▲ 2.0%".into())),
|
||||
("color", Tag::String("green".into())),
|
||||
]),
|
||||
];
|
||||
let mut item = ItemStack::new("minecraft:diamond", 1);
|
||||
item.data_mut()
|
||||
.other
|
||||
.push(("minecraft:lore".into(), Tag::List(lines.clone())));
|
||||
let sent = stack_to_network(|_| 7, |_, _| None, &Some(item));
|
||||
let mut writer = Writer::new();
|
||||
horizon_protocol::item::Slot::write(&sent, &mut writer);
|
||||
let mut reader = Reader::new(&writer.data);
|
||||
let received = horizon_protocol::item::Slot::read(&mut reader)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
received.component(component::LORE),
|
||||
Some(&Component::Lore(lines))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn styled_custom_name_survives_inventory_sync() {
|
||||
let name = Tag::compound([
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"hooks": [],
|
||||
"rust_summary": "other players' inventories for server mods, online or from their file (`native_inventory`, `native_move_stack`), and maps a mod draws (`native_send_map`).",
|
||||
"doc_summary": "`native_inventory(uuid, kind)`: a player's inventory (`Player`: 41 slots, main, armour, off hand; `Ender`: 27), live when they are on the server, else from their file, never while they log in or are dead; `native_move_stack((from, kind, slot), expected, (to, kind))`: one stack moved, checked against `expected`, where it fits whole, or nothing moves (at most one side a file, written first); `native_send_map(uuid, map_id, colors)`: a 128 x 128 map picture sent to a player, for a filled map with that `map_id`. What an operator's `/invsee` and a price chart on a map need. Bundles need \"hooks\": @API:native_inventories@.",
|
||||
"comments": ["crates/horizon-client/src/game/native_inventories.rs"]
|
||||
}
|
||||
Reference in new issue
Block a user