mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 17:21:49 -10:00
Compare commits
27
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d8dd2023dd | ||
|
|
d84d7a7e61 | ||
|
|
1cd0dd8c5d | ||
|
|
7f60a55ca3 | ||
|
|
16db2f44ae | ||
|
|
0b56d62273 | ||
|
|
574e3ca567 | ||
|
|
2a2b4acb5c | ||
|
|
52bc81ca5b | ||
|
|
e1fff77a48 | ||
|
|
265461f2eb | ||
|
|
ba8bdc7179 | ||
|
|
cbc469249e | ||
|
|
4d9baf60d4 | ||
|
|
0b0e4a8aae | ||
|
|
c19418b55d | ||
|
|
8acb46756b | ||
|
|
ee79b8f0cd | ||
|
|
8667cef3a1 | ||
|
|
fe1d544d02 | ||
|
|
0cc3b4a057 | ||
|
|
7dac46ec3c | ||
|
|
8ef418e223 | ||
|
|
b15e5e893e | ||
|
|
63baec3ac8 | ||
|
|
5d2f1804e7 | ||
|
|
c7abc8c718 |
No files matched your search
@@ -0,0 +1,5 @@
|
||||
# Generated files that tests compare byte for byte with what they generate: LF on every OS, also
|
||||
# where Git for Windows' core.autocrlf would check them out with CRLF.
|
||||
docs/launcher-commands.md text eol=lf
|
||||
docs/horizon-ctl-commands.md text eol=lf
|
||||
website/src/tokens.css text eol=lf
|
||||
@@ -61,7 +61,8 @@ jobs:
|
||||
if: steps.rust.outputs.required != 'false'
|
||||
run: |
|
||||
cargo +stable fmt --all -- --check
|
||||
cargo +stable clippy --workspace --all-targets -- -D warnings
|
||||
# With the game's code mods (on in every release build, off by default).
|
||||
cargo +stable clippy --workspace --all-targets --features horizon-client/mods -- -D warnings
|
||||
# The server build (released as horizon-engine): the game without sound or controllers.
|
||||
cargo +stable clippy -p horizon-client --all-targets --no-default-features -- -D warnings
|
||||
# Libraries, binaries and integration tests, then doc tests: not the ~275 examples, which clippy above already
|
||||
@@ -69,8 +70,8 @@ jobs:
|
||||
- name: Test Rust core and voxel kernel correctness
|
||||
if: steps.rust.outputs.required != 'false'
|
||||
run: |
|
||||
cargo +stable test --workspace --lib --bins --tests
|
||||
cargo +stable test --workspace --doc
|
||||
cargo +stable test --workspace --lib --bins --tests --features horizon-client/mods,horizon-mods/pack
|
||||
cargo +stable test --workspace --doc --features horizon-client/mods,horizon-mods/pack
|
||||
parity:
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository
|
||||
uses: ./.github/workflows/parity.yml
|
||||
|
||||
@@ -133,7 +133,8 @@ jobs:
|
||||
if: runner.os == 'Linux'
|
||||
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev
|
||||
- name: Build the client, horizon-ctl, horizon-prepare and horizon-server
|
||||
run: cargo build --release --locked -p horizon-client --bin horizon-client --bin horizon-ctl -p horizon-prepare --bin horizon-prepare -p horizon-server --bin horizon-server
|
||||
# `horizon-client/mods`: every released game runs code mods (the feature is off in dev builds).
|
||||
run: cargo build --release --locked --features horizon-client/mods -p horizon-client --bin horizon-client --bin horizon-ctl -p horizon-prepare --bin horizon-prepare -p horizon-server --bin horizon-server
|
||||
- name: Every program the release folder takes was built
|
||||
run: |
|
||||
for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do
|
||||
@@ -255,8 +256,9 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# After the client is packaged: target/release/horizon-client is rebuilt without the audio and
|
||||
# controllers features, so it needs neither libasound nor libudev (minimal server images lack them).
|
||||
cargo build --release --locked -p horizon-client --no-default-features --bin horizon-client
|
||||
# controllers features, so it needs neither libasound nor libudev (minimal server images lack them),
|
||||
# and with code mods like the client.
|
||||
cargo build --release --locked -p horizon-client --no-default-features --features mods --bin horizon-client
|
||||
cargo run --locked -p xtask -- package-server target/release/horizon-client "dist/${{ matrix.server_dir }}"
|
||||
folder="dist/${{ matrix.server_dir }}"
|
||||
python3 tools/check_imports.py --server "$folder/horizon-engine" "$folder/horizon-server" "$folder/horizon-prepare"
|
||||
|
||||
Generated
-1
@@ -1493,7 +1493,6 @@ dependencies = [
|
||||
name = "horizon-mods"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"horizon-mods",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
@@ -18,7 +18,8 @@ horizon-protocol = { path = "../horizon-protocol" }
|
||||
horizon-server-files = { path = "../horizon-server-files" }
|
||||
horizon-friends = { path = "../horizon-friends" }
|
||||
# Horizon mods: sandboxed WebAssembly components, off unless installed and turned on (docs/mods.md).
|
||||
horizon-mods = { path = "../horizon-mods" }
|
||||
# Their runtime is the `mods` feature.
|
||||
horizon-mods = { path = "../horizon-mods", default-features = false }
|
||||
wgpu = { version = "30.0.1", default-features = false, features = ["std", "parking_lot", "vulkan", "metal", "wgsl"] }
|
||||
winit = "0.30.13"
|
||||
pollster = "0.4"
|
||||
@@ -78,6 +79,9 @@ default = ["audio", "controllers"]
|
||||
audio = ["dep:cpal"]
|
||||
# Controllers: gilrs, and the DualSense's own output. Without it only `horizon-ctl pad`'s virtual pad.
|
||||
controllers = ["dep:gilrs", "dep:horizon-dualsense"]
|
||||
# Runs code mods (wasmtime and cranelift: a quarter of a clean build). On in every release build
|
||||
# (release.yml); the tests and probes don't need it. Content mods load without it.
|
||||
mods = ["horizon-mods/runtime"]
|
||||
# Records the call sites of mob random draws (`mob_ai_probe` traces).
|
||||
rng-trace = ["horizon-core/rng-trace"]
|
||||
|
||||
|
||||
@@ -306,6 +306,7 @@ fn chat(c: &Value, h: i32, atlas: &HudAtlas) -> Vec<GuiDraw> {
|
||||
height_focused: option("chatHeightFocused"),
|
||||
height_unfocused: option("chatHeightUnfocused"),
|
||||
line_spacing: option("chatLineSpacing"),
|
||||
colors: c["chatColors"].as_bool().unwrap_or(true),
|
||||
};
|
||||
for m in c["messages"].as_array().into_iter().flatten() {
|
||||
// GuiMessageTag indicator colours: systemSinglePlayer and chatError.
|
||||
|
||||
@@ -58,11 +58,12 @@ fn state(menu: &Menu) -> String {
|
||||
.map(|(slot, stack)| format!("{slot}:{}", canon_stack(stack.as_ref())))
|
||||
.collect();
|
||||
let line = format!(
|
||||
"{} | {} |{data} | {} | {}",
|
||||
"{} | {} |{data} | {} | {} | {}",
|
||||
slots.join(" "),
|
||||
canon_stack(menu.carried.as_ref()),
|
||||
menu.drops.join(";"),
|
||||
equipped.join(";")
|
||||
equipped.join(";"),
|
||||
menu.player_sounds.join(";")
|
||||
);
|
||||
if menu.kind() != Kind::Lectern {
|
||||
return line;
|
||||
@@ -171,6 +172,7 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
});
|
||||
menu.drops.clear();
|
||||
menu.equipped.clear();
|
||||
menu.player_sounds.clear();
|
||||
out += &format!("init {}\n", state(&menu));
|
||||
for op in case["ops"].as_array().ok_or("ops")? {
|
||||
let slot = op[0].as_i64().unwrap_or(0) as i32;
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
//! `BundleContents.Mutable`: what a bundle takes in and gives back, by
|
||||
//! weight. The menus (`menu`, `lan::menu`) run `BundleItem`'s click
|
||||
//! overrides on it.
|
||||
use crate::items::{ItemStack, Items};
|
||||
use horizon_world::nbt::Tag;
|
||||
|
||||
/// A bundle weight: vanilla's `Fraction`, exact and kept reduced.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
struct Weight {
|
||||
n: i128,
|
||||
d: i128,
|
||||
}
|
||||
|
||||
fn gcd(a: i128, b: i128) -> i128 {
|
||||
let (mut a, mut b) = (a.abs(), b.abs());
|
||||
while b != 0 {
|
||||
(a, b) = (b, a % b);
|
||||
}
|
||||
a.max(1)
|
||||
}
|
||||
|
||||
impl Weight {
|
||||
const ZERO: Self = Self { n: 0, d: 1 };
|
||||
const ONE: Self = Self { n: 1, d: 1 };
|
||||
|
||||
fn new(n: i128, d: i128) -> Self {
|
||||
let g = gcd(n, d);
|
||||
Self { n: n / g, d: d / g }
|
||||
}
|
||||
|
||||
fn add(self, o: Self) -> Self {
|
||||
Self::new(self.n * o.d + o.n * self.d, self.d * o.d)
|
||||
}
|
||||
|
||||
fn sub(self, o: Self) -> Self {
|
||||
self.add(Self { n: -o.n, d: o.d })
|
||||
}
|
||||
|
||||
fn times(self, count: u32) -> Self {
|
||||
Self::new(self.n * i128::from(count), self.d)
|
||||
}
|
||||
|
||||
/// `this.divideBy(item).intValue()` (truncated), at least 0.
|
||||
fn how_many(self, item: Self) -> u32 {
|
||||
let q = (self.n * item.d) / (self.d * item.n);
|
||||
u32::try_from(q.max(0)).unwrap_or(u32::MAX)
|
||||
}
|
||||
}
|
||||
|
||||
/// The sounds of `BundleItem` (`Entity.playSound` at the player): insert
|
||||
/// and remove one at volume 0.8 and pitch `0.8 + random * 0.4`, a failed
|
||||
/// insert at 1, 1.
|
||||
pub const INSERT: &str = "minecraft:item.bundle.insert";
|
||||
pub const INSERT_FAIL: &str = "minecraft:item.bundle.insert_fail";
|
||||
pub const REMOVE_ONE: &str = "minecraft:item.bundle.remove_one";
|
||||
|
||||
fn max_stack(items: &Items, stack: &ItemStack) -> u32 {
|
||||
items.get(&stack.item).map_or(64, |i| i.max_stack).max(1)
|
||||
}
|
||||
|
||||
/// The stack's `bundle_contents`, when it has the component.
|
||||
fn contents_of<'a>(items: &Items, stack: &'a ItemStack) -> Option<&'a [ItemStack]> {
|
||||
let inside = stack.data().map_or(&[][..], |d| &d.bundle_contents[..]);
|
||||
let bundle = items.get(&stack.item).is_some_and(|i| i.bundle);
|
||||
(bundle || !inside.is_empty()).then_some(inside)
|
||||
}
|
||||
|
||||
/// `BundleContents.getWeight`: a nested bundle weighs its contents plus
|
||||
/// 1/16, a beehive with bees 1, the rest 1 / max stack size.
|
||||
fn item_weight(items: &Items, stack: &ItemStack) -> Weight {
|
||||
if let Some(inside) = contents_of(items, stack) {
|
||||
return contents_weight(items, inside).add(Weight::new(1, 16));
|
||||
}
|
||||
let bees = stack
|
||||
.data()
|
||||
.and_then(|d| d.other("minecraft:bees"))
|
||||
.is_some_and(|b| matches!(b, Tag::List(l) if !l.is_empty()));
|
||||
if bees {
|
||||
Weight::ONE
|
||||
} else {
|
||||
Weight::new(1, i128::from(max_stack(items, stack)))
|
||||
}
|
||||
}
|
||||
|
||||
fn contents_weight(items: &Items, inside: &[ItemStack]) -> Weight {
|
||||
inside.iter().fold(Weight::ZERO, |w, s| {
|
||||
w.add(item_weight(items, s).times(s.count))
|
||||
})
|
||||
}
|
||||
|
||||
/// `BundleContents.canItemBeInBundle`: shulker boxes don't fit.
|
||||
pub fn can_be_in_bundle(stack: &ItemStack) -> bool {
|
||||
stack.count > 0 && !stack.item.ends_with("shulker_box")
|
||||
}
|
||||
|
||||
/// `BundleContents.Mutable`.
|
||||
pub struct Contents {
|
||||
items: Vec<ItemStack>,
|
||||
weight: Weight,
|
||||
}
|
||||
|
||||
impl Contents {
|
||||
/// The bundle's contents; None when the stack is no bundle.
|
||||
pub fn of(items: &Items, stack: &ItemStack) -> Option<Self> {
|
||||
let inside = contents_of(items, stack)?.to_vec();
|
||||
let weight = contents_weight(items, &inside);
|
||||
Some(Self {
|
||||
items: inside,
|
||||
weight,
|
||||
})
|
||||
}
|
||||
|
||||
/// `getMaxAmountToAdd` for `stack`'s kind of item.
|
||||
pub fn room_for(&self, items: &Items, stack: &ItemStack) -> u32 {
|
||||
Weight::ONE
|
||||
.sub(self.weight)
|
||||
.how_many(item_weight(items, stack))
|
||||
}
|
||||
|
||||
/// `tryInsert`: what fits moves to the front, merged with its kind.
|
||||
pub fn try_insert(&mut self, items: &Items, stack: &mut ItemStack) -> u32 {
|
||||
if !can_be_in_bundle(stack) {
|
||||
return 0;
|
||||
}
|
||||
let weight = item_weight(items, stack);
|
||||
let amount = stack
|
||||
.count
|
||||
.min(Weight::ONE.sub(self.weight).how_many(weight));
|
||||
if amount == 0 {
|
||||
return 0;
|
||||
}
|
||||
self.weight = self.weight.add(weight.times(amount));
|
||||
// ItemStack.isStackable: a damaged stack is damageable.
|
||||
let stackable = max_stack(items, stack) > 1 && stack.damage == 0;
|
||||
let same = self
|
||||
.items
|
||||
.iter()
|
||||
.position(|s| stackable && s.same_item_same_components(stack));
|
||||
let added = match same {
|
||||
Some(i) => {
|
||||
let mut merged = self.items.remove(i);
|
||||
merged.count += amount;
|
||||
stack.count -= amount;
|
||||
merged
|
||||
}
|
||||
None => {
|
||||
let mut part = stack.clone();
|
||||
part.count = amount;
|
||||
stack.count -= amount;
|
||||
part
|
||||
}
|
||||
};
|
||||
self.items.insert(0, added);
|
||||
amount
|
||||
}
|
||||
|
||||
/// `removeOne`: the first stack (Horizon keeps no selected item).
|
||||
pub fn remove_one(&mut self, items: &Items) -> Option<ItemStack> {
|
||||
if self.items.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let stack = self.items.remove(0);
|
||||
self.weight = self
|
||||
.weight
|
||||
.sub(item_weight(items, &stack).times(stack.count));
|
||||
Some(stack)
|
||||
}
|
||||
|
||||
/// `toImmutable`, written back into the bundle's component.
|
||||
pub fn store(self, bundle: &mut ItemStack) {
|
||||
bundle.data_mut().bundle_contents = self.items;
|
||||
bundle.tidy();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::Weight;
|
||||
|
||||
#[test]
|
||||
fn weights_add_up_exactly() {
|
||||
let w = Weight::new(1, 64)
|
||||
.times(32)
|
||||
.add(Weight::new(1, 16).times(4));
|
||||
assert_eq!(w, Weight::new(3, 4));
|
||||
assert_eq!(Weight::ONE.sub(w).how_many(Weight::new(1, 64)), 16);
|
||||
assert_eq!(Weight::ONE.sub(Weight::ONE).how_many(Weight::ONE), 0);
|
||||
// An over-full bundle (made by a command) has no room.
|
||||
let over = Weight::ONE.sub(Weight::new(5, 4));
|
||||
assert_eq!(over.how_many(Weight::new(1, 64)), 0);
|
||||
}
|
||||
}
|
||||
@@ -29,10 +29,14 @@ pub struct ChatOptions {
|
||||
pub height_focused: f64,
|
||||
pub height_unfocused: f64,
|
||||
pub line_spacing: f64,
|
||||
/// Chat Colors (`chatColors`): legacy `§` codes format the text, or are
|
||||
/// left out.
|
||||
pub colors: bool,
|
||||
}
|
||||
impl Default for ChatOptions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
colors: true,
|
||||
opacity: 1.0,
|
||||
background_opacity: 0.5,
|
||||
scale: 1.0,
|
||||
@@ -136,7 +140,8 @@ impl Chat {
|
||||
}
|
||||
fn add_lines(&mut self, atlas: &HudAtlas, message: &Message, focused: bool) {
|
||||
let width = self.max_width();
|
||||
for line in atlas.font.wrap_components(&message.content, width) {
|
||||
let content = self.displayed(&message.content);
|
||||
for line in atlas.font.wrap_components(&content, width) {
|
||||
if focused && self.scroll > 0 {
|
||||
self.new_since_scroll = true;
|
||||
self.scroll_by(1, focused);
|
||||
@@ -152,6 +157,16 @@ impl Chat {
|
||||
}
|
||||
self.lines.truncate(100);
|
||||
}
|
||||
/// What a message's lines wrap (ComponentRenderUtils.wrapComponents):
|
||||
/// without Chat Colors its `§` codes are stripped (stripColor); with
|
||||
/// them the font draws them as formatting.
|
||||
fn displayed(&self, content: &Text) -> Text {
|
||||
if self.options.colors {
|
||||
content.resolve_legacy()
|
||||
} else {
|
||||
content.strip_legacy()
|
||||
}
|
||||
}
|
||||
/// Re-wrap after a width or scale change (ChatComponent.rescaleChat).
|
||||
pub fn rescale(&mut self, atlas: &HudAtlas) {
|
||||
self.scroll = 0;
|
||||
@@ -308,6 +323,21 @@ impl Chat {
|
||||
x: f64,
|
||||
y: f64,
|
||||
) -> Option<ClickEvent> {
|
||||
self.style_at(font, height, x, y)?.click
|
||||
}
|
||||
/// The hover text under a GUI-space cursor while chat is focused
|
||||
/// (ChatScreen.render's `renderComponentHoverEffect`).
|
||||
pub fn hover_at(&self, font: &crate::font::Font, height: i32, x: f64, y: f64) -> Option<Text> {
|
||||
self.style_at(font, height, x, y)?.hover.map(|h| *h)
|
||||
}
|
||||
/// ChatComponent.getClickedComponentStyleAt.
|
||||
fn style_at(
|
||||
&self,
|
||||
font: &crate::font::Font,
|
||||
height: i32,
|
||||
x: f64,
|
||||
y: f64,
|
||||
) -> Option<crate::text::Style> {
|
||||
let scale = self.options.scale as f32;
|
||||
let mut x = x / f64::from(scale) - 4.0;
|
||||
let bottom = hud_layout::floor((height - 40) as f32 / scale);
|
||||
@@ -324,7 +354,7 @@ impl Chat {
|
||||
for (c, style) in content.chars() {
|
||||
x -= f64::from(font.advance(c, style));
|
||||
if x < 0.0 {
|
||||
return style.click.clone();
|
||||
return Some(style.clone());
|
||||
}
|
||||
}
|
||||
None
|
||||
@@ -337,6 +367,14 @@ impl Chat {
|
||||
.map(|m| m.content.plain())
|
||||
.collect()
|
||||
}
|
||||
/// The newest messages as components, styles included (control socket).
|
||||
pub fn recent_components(&self, n: usize) -> Vec<serde_json::Value> {
|
||||
self.messages
|
||||
.iter()
|
||||
.take(n)
|
||||
.map(|m| m.content.component())
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// `CommandSuggestions.SuggestionsList`.
|
||||
|
||||
@@ -285,3 +285,45 @@ fn ask_server_nodes_wait_for_the_servers_completions() {
|
||||
screen.type_text(&m, &c, "/bossbar get pt");
|
||||
assert_eq!(screen.take_request(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_codes_colour_the_line_or_vanish_without_chat_colours() {
|
||||
let mut chat = Chat::default();
|
||||
let red = Text::literal("\u{a7}cRED");
|
||||
let shown = chat.displayed(&red);
|
||||
assert_eq!(shown.plain(), "RED");
|
||||
assert!(shown.chars().all(|(_, s)| s.color == Some(0xFF5555)));
|
||||
chat.options.colors = false;
|
||||
let shown = chat.displayed(&red);
|
||||
assert_eq!(shown.plain(), "RED");
|
||||
assert!(shown.chars().all(|(_, s)| s.color.is_none()));
|
||||
// A lone section sign or an unknown code stays as written.
|
||||
assert_eq!(
|
||||
chat.displayed(&Text::literal("a\u{a7}zb")).plain(),
|
||||
"a\u{a7}zb"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_server_component_keeps_its_click_and_hover_events() {
|
||||
let font = font();
|
||||
let json = serde_json::json!({
|
||||
"text": "CLICK", "color": "green", "bold": true,
|
||||
"click_event": {"action": "suggest_command", "command": "/say clicked"},
|
||||
"hover_event": {"action": "show_text", "value": {"text": "Tip", "color": "aqua"}},
|
||||
});
|
||||
let line = crate::component::to_text(&json, &mut |s| Text::literal(s));
|
||||
let mut chat = Chat::default();
|
||||
add(&mut chat, &font, line);
|
||||
let x = 4.0 + f64::from(font.width("CL"));
|
||||
let Some(ClickEvent::SuggestCommand(command)) = chat.click_at(&font, 360, x, 315.0) else {
|
||||
panic!("the line must suggest its command");
|
||||
};
|
||||
assert_eq!(command, "/say clicked");
|
||||
let hover = chat.hover_at(&font, 360, x, 315.0).expect("a hover text");
|
||||
assert_eq!(hover.plain(), "Tip");
|
||||
assert_eq!(hover.spans[0].style.color, Some(text::AQUA));
|
||||
// Past the (bold, so wider) text, nothing.
|
||||
let past = 4.0 + f64::from(font.width("CLICK")) + 20.0;
|
||||
assert!(chat.hover_at(&font, 360, past, 315.0).is_none());
|
||||
}
|
||||
@@ -168,6 +168,25 @@ fn object_style(o: &serde_json::Map<String, Value>) -> Style {
|
||||
style.strikethrough = flag("strikethrough");
|
||||
style.obfuscated = flag("obfuscated");
|
||||
style.font = o.get("font").and_then(crate::text::font_id);
|
||||
// ClickEvent.CODEC: the actions a chat line acts on here. A component
|
||||
// from a server never opens a file.
|
||||
if let Some(e) = o.get("click_event").and_then(Value::as_object) {
|
||||
let text = |k: &str| e.get(k).and_then(Value::as_str).map(str::to_owned);
|
||||
style.click = match e.get("action").and_then(Value::as_str) {
|
||||
Some("suggest_command") => text("command").map(crate::text::ClickEvent::SuggestCommand),
|
||||
Some("copy_to_clipboard") => {
|
||||
text("value").map(crate::text::ClickEvent::CopyToClipboard)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
}
|
||||
// HoverEvent.ShowText.
|
||||
if let Some(e) = o.get("hover_event").and_then(Value::as_object)
|
||||
&& e.get("action").and_then(Value::as_str) == Some("show_text")
|
||||
&& let Some(value) = e.get("value")
|
||||
{
|
||||
style.hover = Some(Box::new(to_text(value, &mut |s| Text::literal(s))));
|
||||
}
|
||||
style
|
||||
}
|
||||
/// `ComponentSerialization.CODEC` decoding into text. `selector` components
|
||||
|
||||
@@ -137,6 +137,48 @@ pub fn item_pose(left: bool, inverse_height: f32, attack: f32, pose: ItemPose) -
|
||||
m
|
||||
}
|
||||
|
||||
/// How `submitArmWithItem` holds a stack with a `minecraft:map_id`: in both
|
||||
/// hands when it is the main hand's and the off hand is empty
|
||||
/// (`renderTwoHandedMap`), else in its own hand (`renderOneHandedMap`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum MapHold {
|
||||
TwoHanded,
|
||||
OneHanded,
|
||||
}
|
||||
impl MapHold {
|
||||
pub fn of(hand: Hand, off_hand_empty: bool) -> Self {
|
||||
if hand == Hand::Main && off_hand_empty {
|
||||
Self::TwoHanded
|
||||
} else {
|
||||
Self::OneHanded
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `renderOneHandedMap` up to `renderMap`: the pose of a map in the hand on
|
||||
/// the `left` or right arm (the arm under it is not drawn).
|
||||
pub fn one_handed_map_pose(left: bool, inverse_height: f32, attack: f32) -> glam::Mat4 {
|
||||
use glam::{Mat4, Vec3};
|
||||
use horizon_core::geometry::trig::Mth;
|
||||
let invert = if left { -1.0 } else { 1.0 };
|
||||
let pi = std::f32::consts::PI;
|
||||
let root = attack.sqrt();
|
||||
let x_swing = (root * pi).msin();
|
||||
Mat4::from_translation(Vec3::new(invert * 0.125, -0.125, 0.0))
|
||||
* Mat4::from_translation(Vec3::new(
|
||||
invert * 0.51,
|
||||
-0.08 + inverse_height * -1.2,
|
||||
-0.75,
|
||||
))
|
||||
* Mat4::from_translation(Vec3::new(
|
||||
invert * -0.5 * x_swing,
|
||||
0.4 * (root * std::f32::consts::TAU).msin() - 0.3 * x_swing,
|
||||
-0.3 * (attack * pi).msin(),
|
||||
))
|
||||
* Mat4::from_rotation_x((x_swing * -45.0).to_radians())
|
||||
* Mat4::from_rotation_y((invert * x_swing * -30.0).to_radians())
|
||||
}
|
||||
|
||||
/// `FirstPersonHandsAndItems.evaluateWhichHandsToRender`: whether the main
|
||||
/// and the off hand show, from what each holds (item and whether it is a
|
||||
/// charged crossbow) and the item in use with its hand. A bow or crossbow
|
||||
@@ -819,6 +861,84 @@ mod tests {
|
||||
mod item_pose_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn a_map_is_two_handed_only_in_the_main_hand_with_an_empty_off_hand() {
|
||||
assert_eq!(MapHold::of(Hand::Main, true), MapHold::TwoHanded);
|
||||
assert_eq!(MapHold::of(Hand::Main, false), MapHold::OneHanded);
|
||||
assert_eq!(MapHold::of(Hand::Off, true), MapHold::OneHanded);
|
||||
assert_eq!(MapHold::of(Hand::Off, false), MapHold::OneHanded);
|
||||
}
|
||||
#[test]
|
||||
fn one_handed_maps_follow_render_one_handed_map() {
|
||||
// PoseStack.last().pose() from vanilla's renderOneHandedMap
|
||||
// transforms (column-major), for (left, inverse height, attack).
|
||||
let cases: [(bool, f32, f32, [f32; 16]); 3] = [
|
||||
(
|
||||
false,
|
||||
0.0,
|
||||
0.0,
|
||||
[
|
||||
1.0, 0.0, 0.0, 0.0, 0.0, 1.0, -0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.635, -0.205,
|
||||
-0.75, 1.0,
|
||||
],
|
||||
),
|
||||
(
|
||||
true,
|
||||
0.25,
|
||||
0.3,
|
||||
[
|
||||
0.86894435,
|
||||
-0.34686038,
|
||||
-0.35302067,
|
||||
0.0,
|
||||
0.0,
|
||||
0.713303,
|
||||
-0.70085573,
|
||||
0.0,
|
||||
0.4949098,
|
||||
0.6090046,
|
||||
0.61982065,
|
||||
0.0,
|
||||
-0.1406033,
|
||||
-0.9197687,
|
||||
-0.9926984,
|
||||
1.0,
|
||||
],
|
||||
),
|
||||
(
|
||||
false,
|
||||
0.6,
|
||||
0.85,
|
||||
[
|
||||
0.9919303,
|
||||
0.024030307,
|
||||
0.12448644,
|
||||
0.0,
|
||||
0.0,
|
||||
0.9818737,
|
||||
-0.18953651,
|
||||
0.0,
|
||||
-0.12678458,
|
||||
0.18800701,
|
||||
0.97395027,
|
||||
0.0,
|
||||
0.5136029,
|
||||
-1.1862278,
|
||||
-0.88621765,
|
||||
1.0,
|
||||
],
|
||||
),
|
||||
];
|
||||
for (left, inverse_height, attack, want) in cases {
|
||||
let got = one_handed_map_pose(left, inverse_height, attack).to_cols_array();
|
||||
for (g, w) in got.iter().zip(want) {
|
||||
assert!(
|
||||
(g - w).abs() < 1e-5,
|
||||
"{left} {inverse_height} {attack}: {got:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn ranged_draw_uses_native_charge_curves_and_caps() {
|
||||
for (ticks, stretch) in [
|
||||
(0.0, 1.0),
|
||||
|
||||
@@ -5499,6 +5499,20 @@ impl Game<'_> {
|
||||
Some(true)
|
||||
})
|
||||
}
|
||||
/// A sound a menu click made the player play (a bundle's insert or
|
||||
/// remove): `Entity.playSound` at the player.
|
||||
pub(super) fn player_menu_sound(&mut self, sound: &str) {
|
||||
let Some(p) = self.player.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let at = [p.position.x, p.position.y, p.position.z];
|
||||
let (volume, pitch) = if sound == horizon_client::bundle::INSERT_FAIL {
|
||||
(1.0, 1.0)
|
||||
} else {
|
||||
(0.8, 0.8 + self.world.random.next_float() * 0.4)
|
||||
};
|
||||
self.audio.play(sound, Some(at), volume, pitch);
|
||||
}
|
||||
/// The open menu as the verified engine (`horizon_client::menu`): the
|
||||
/// game's slots are copied in, `run` acts on it, and the slots, carried
|
||||
/// stack, menu data and drops are copied back when it returns Some(true)
|
||||
@@ -5888,6 +5902,11 @@ impl Game<'_> {
|
||||
let carried = menu.carried.take();
|
||||
let dropped = std::mem::take(&mut menu.dropped);
|
||||
let sounds = std::mem::take(&mut menu.sounds);
|
||||
// A bundle's sounds are the player's own (`Entity.playSound`): the
|
||||
// client plays them as it clicks, predicting or not.
|
||||
for sound in std::mem::take(&mut menu.player_sounds) {
|
||||
self.player_menu_sound(sound);
|
||||
}
|
||||
let crafted = std::mem::take(&mut menu.crafted);
|
||||
let equipped = std::mem::take(&mut menu.equipped);
|
||||
for (i, stack) in after.into_iter().enumerate() {
|
||||
@@ -6132,6 +6151,17 @@ impl Game<'_> {
|
||||
}
|
||||
return;
|
||||
}
|
||||
// mouseClicked: an empty hand's PICKUP is the menu's click at once
|
||||
// (a right click on a bundle takes its first stack out).
|
||||
if let (Some(input), Some(_)) = (input, slot)
|
||||
&& self.remote.is_none()
|
||||
&& self.menu_click(slot, i32::from(right), input)
|
||||
{
|
||||
if let Some(key) = self.container {
|
||||
self.container_changed(key);
|
||||
}
|
||||
return;
|
||||
}
|
||||
self.click_inventory(right);
|
||||
}
|
||||
/// Mouse movement while a quick craft drag is running.
|
||||
@@ -8816,20 +8846,15 @@ impl Game<'_> {
|
||||
);
|
||||
let packed = (u32::from(light >> 4) * 16) | ((u32::from(light & 15) * 16) << 8);
|
||||
let deg = |d: f32| d.to_radians();
|
||||
if self.held_map_geometry(to_world, attacks[0], inverse_heights[0], packed, &mut out) {
|
||||
return out;
|
||||
}
|
||||
let shown = self.hands_to_render();
|
||||
let used_hand = self.used_hand();
|
||||
let spinning = self
|
||||
.player
|
||||
.as_ref()
|
||||
.is_some_and(|p| p.auto_spin_attack_ticks > 0);
|
||||
for (i, (hand, item)) in Hand::BOTH
|
||||
.into_iter()
|
||||
.zip(self.visible_hand_items())
|
||||
.enumerate()
|
||||
{
|
||||
let items = self.visible_hand_items();
|
||||
let off_hand_empty = items[1].is_none();
|
||||
for (i, (hand, item)) in Hand::BOTH.into_iter().zip(items).enumerate() {
|
||||
if !shown[i] {
|
||||
continue;
|
||||
}
|
||||
@@ -8892,6 +8917,26 @@ impl Game<'_> {
|
||||
);
|
||||
continue;
|
||||
};
|
||||
if let Some(id) = maps::map_id(&stack) {
|
||||
use horizon_client::first_person::{MapHold, one_handed_map_pose};
|
||||
match MapHold::of(hand, off_hand_empty) {
|
||||
MapHold::TwoHanded => self.two_handed_map_geometry(
|
||||
to_world,
|
||||
attack,
|
||||
inverse_height,
|
||||
id,
|
||||
packed,
|
||||
&mut out,
|
||||
),
|
||||
MapHold::OneHanded => self.held_map(
|
||||
to_world * one_handed_map_pose(left, inverse_height, attack),
|
||||
id,
|
||||
packed,
|
||||
&mut out,
|
||||
),
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let mut owner = self.player_owner(Some(&stack));
|
||||
// The other hand may hold an equal stack: only the used
|
||||
// hand's item is in use, only the main hand's selected.
|
||||
|
||||
@@ -678,6 +678,8 @@ impl Game<'_> {
|
||||
json!({
|
||||
"chat": self.remote_chat(n).unwrap_or_else(|| self.ui.chat.recent_lines(n)),
|
||||
"order": "newest first",
|
||||
// The chat lines as drawn: colours, click and hover events.
|
||||
"styled": self.ui.chat.recent_components(n),
|
||||
"title": (titles.time > 0).then(|| titles.title.as_ref().map(|t| t.plain())).flatten(),
|
||||
"subtitle": (titles.time > 0).then(|| titles.subtitle.as_ref().map(|t| t.plain())).flatten(),
|
||||
"actionbar": (titles.overlay_time > 0).then(|| titles.overlay.as_ref().map(|t| t.plain())).flatten(),
|
||||
|
||||
@@ -450,11 +450,11 @@ impl Game<'_> {
|
||||
// Connected to a server (single player's own included), the chat on
|
||||
// screen is the connection's: the game's own messages join it.
|
||||
if self.remote.is_some() {
|
||||
self.chat_line(text.plain());
|
||||
self.chat_line(text);
|
||||
return;
|
||||
}
|
||||
let focused = self.screen == Screen::Chat;
|
||||
let heard = self.mods.mods.is_running().then(|| text.plain());
|
||||
let heard = self.mods.running().then(|| text.plain());
|
||||
self.ui.chat.add(
|
||||
&self.gui.atlas,
|
||||
text,
|
||||
@@ -583,7 +583,7 @@ impl Game<'_> {
|
||||
&[Text::literal(PLAYER_NAME), Text::literal(message)],
|
||||
);
|
||||
let focused = self.screen == Screen::Chat;
|
||||
let heard = self.mods.mods.is_running().then(|| line.plain());
|
||||
let heard = self.mods.running().then(|| line.plain());
|
||||
self.ui
|
||||
.chat
|
||||
.add(&self.gui.atlas, line, None, self.ticks, focused);
|
||||
@@ -681,6 +681,16 @@ impl Game<'_> {
|
||||
let draws = screen.draw(&m, mouse, blink, background);
|
||||
horizon_client::screen_draw::paint(batch, &self.gui.atlas, &draws);
|
||||
self.ui.chat_screen = Some(screen);
|
||||
// The hovered message part's tooltip, one row per line of its text.
|
||||
if let Some(hover) = self.ui.chat.hover_at(
|
||||
&self.gui.atlas.font,
|
||||
batch.height as i32,
|
||||
f64::from(self.cursor.0),
|
||||
f64::from(self.cursor.1),
|
||||
) {
|
||||
let lines = hover_rows(&hover);
|
||||
self.draw_tooltip_lines(batch, &lines, self.cursor.0, self.cursor.1);
|
||||
}
|
||||
}
|
||||
/// Advance the overworld clock (ServerClockManager.tick) when
|
||||
/// `advance_time` allows it.
|
||||
@@ -949,3 +959,26 @@ fn write_difficulty(world: &std::path::Path, difficulty: u8) -> horizon_client::
|
||||
fn owner_permission(allow_commands: bool) -> u8 {
|
||||
if allow_commands { 4 } else { 0 }
|
||||
}
|
||||
|
||||
/// A hover text's rows for the tooltip: split at line breaks, each in the
|
||||
/// colour of its first styled character (white without one).
|
||||
fn hover_rows(hover: &horizon_client::text::Text) -> Vec<(String, u32)> {
|
||||
let mut rows = vec![(String::new(), None)];
|
||||
for (c, style) in hover.resolve_legacy().chars() {
|
||||
if c == '\n' {
|
||||
rows.push((String::new(), None));
|
||||
continue;
|
||||
}
|
||||
let row = rows.last_mut().expect("one row");
|
||||
if row.0.is_empty() {
|
||||
row.1 = style.color;
|
||||
}
|
||||
row.0.push(c);
|
||||
}
|
||||
rows.into_iter()
|
||||
.map(|(text, color)| {
|
||||
let [_, r, g, b] = color.unwrap_or(0xFF_FFFF).to_be_bytes();
|
||||
(text, horizon_client::hud::rgba(r, g, b, 255))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -1039,6 +1039,11 @@ impl Game<'_> {
|
||||
.play("minecraft:entity.item.pickup", Some(p), 0.2, pitch);
|
||||
}
|
||||
}
|
||||
// containerMenu.broadcastChanges: InventoryChangeTrigger
|
||||
// (and its announcement) comes before the command's message.
|
||||
if g.player.is_some() {
|
||||
g.check_inventory_trigger();
|
||||
}
|
||||
});
|
||||
}
|
||||
// The prototype stack: one item.
|
||||
|
||||
@@ -237,7 +237,7 @@ impl Game<'_> {
|
||||
/// the caller). The top left corner gives way to the debug screen.
|
||||
pub(super) fn draw_extras(&mut self, batch: &mut HudBatch) {
|
||||
let e = &self.settings.extras;
|
||||
let mod_panels = self.mods.mods.panels().next().is_some();
|
||||
let mod_panels = self.mods.has_panels();
|
||||
if !e.fps && !e.keystrokes && !e.minimap && !mod_panels {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -468,8 +468,10 @@ impl Game<'_> {
|
||||
host = self.lan.take().expect("a command puts the hub back");
|
||||
self.leave_guest(&mut host, swap);
|
||||
host.hub.sync_all_effects();
|
||||
// A teleport into another level happens before its feedback is sent.
|
||||
// A teleport into another level happens before its feedback is
|
||||
// sent, and what the command earned is announced before it too.
|
||||
self.run_level_teleports(&mut host);
|
||||
self.announce_guest_advancements(&mut host);
|
||||
for line in lines {
|
||||
out.push(line.plain());
|
||||
host.hub.send_to(
|
||||
|
||||
@@ -87,7 +87,7 @@ pub(super) struct GuestCommand {
|
||||
conn: u32,
|
||||
command: String,
|
||||
permission: u8,
|
||||
/// An integrated server's: no admin feedback or level teleports (yet).
|
||||
/// An integrated server's: no level teleports (yet).
|
||||
integrated: bool,
|
||||
}
|
||||
|
||||
@@ -160,6 +160,9 @@ impl GuestPlay {
|
||||
|
||||
struct ItemRules<'a>(&'a horizon_client::items::Items);
|
||||
impl Rules for ItemRules<'_> {
|
||||
fn items(&self) -> &horizon_client::items::Items {
|
||||
self.0
|
||||
}
|
||||
fn max_stack(&self, item: &str) -> u32 {
|
||||
self.0.get(item).map_or(64, |i| i.max_stack)
|
||||
}
|
||||
@@ -1419,6 +1422,8 @@ impl Game<'_> {
|
||||
// A teleport into another level happens before its feedback is sent.
|
||||
self.run_level_teleports(&mut host);
|
||||
}
|
||||
// What the command earned is announced before its feedback.
|
||||
self.announce_guest_advancements(&mut host);
|
||||
for line in lines {
|
||||
host.hub.send_to(
|
||||
c.conn,
|
||||
@@ -1428,9 +1433,8 @@ impl Game<'_> {
|
||||
}),
|
||||
);
|
||||
}
|
||||
if !c.integrated {
|
||||
self.send_admin_feedback(&mut host, Some(c.conn));
|
||||
}
|
||||
// The other operators hear of it; never the player whose command it was.
|
||||
self.send_admin_feedback(&mut host, Some(c.conn));
|
||||
self.lan = Some(host);
|
||||
}
|
||||
}
|
||||
@@ -1819,7 +1823,7 @@ impl Game<'_> {
|
||||
|
||||
/// `PlayerList.broadcastSystemMessage` of the advancements guests made
|
||||
/// (their own actions, commands and ticks).
|
||||
fn announce_guest_advancements(&mut self, host: &mut LanHost) {
|
||||
pub(super) fn announce_guest_advancements(&mut self, host: &mut LanHost) {
|
||||
for text in std::mem::take(&mut self.adv.announced) {
|
||||
host.hub
|
||||
.system_message(horizon_client::lan::data::component_nbt(&text.component()));
|
||||
|
||||
@@ -918,21 +918,17 @@ impl Game<'_> {
|
||||
);
|
||||
}
|
||||
}
|
||||
/// `renderTwoHandedMap`: the held map (main hand, empty off hand) in view space.
|
||||
pub(super) fn held_map_geometry(
|
||||
/// `renderTwoHandedMap`: map `id` held in both hands, in view space (the
|
||||
/// arms under it are not drawn).
|
||||
pub(super) fn two_handed_map_geometry(
|
||||
&mut self,
|
||||
to_world: glam::Mat4,
|
||||
attack: f32,
|
||||
inverse_height: f32,
|
||||
id: i32,
|
||||
light: u32,
|
||||
out: &mut Vec<Vertex>,
|
||||
) -> bool {
|
||||
let Some(id) = self.inventory[self.selected].as_ref().and_then(map_id) else {
|
||||
return false;
|
||||
};
|
||||
if self.offhand.is_some() {
|
||||
return false;
|
||||
}
|
||||
) {
|
||||
let deg = |d: f32| d.to_radians();
|
||||
let sqrt_attack = attack.sqrt();
|
||||
let y_swing = -0.2 * (attack * std::f32::consts::PI).sin();
|
||||
@@ -949,10 +945,16 @@ impl Game<'_> {
|
||||
))
|
||||
* glam::Mat4::from_rotation_x(deg(tilt * -85.0));
|
||||
let swing = (sqrt_attack * std::f32::consts::PI).sin();
|
||||
// renderMap: background (with 7-pixel border) then the map.
|
||||
let m = base
|
||||
* glam::Mat4::from_rotation_x(deg(swing * 20.0))
|
||||
* glam::Mat4::from_scale(glam::Vec3::splat(2.0))
|
||||
* glam::Mat4::from_scale(glam::Vec3::splat(2.0));
|
||||
self.held_map(m, id, light, out);
|
||||
}
|
||||
/// `renderMap`: map `id` at pose `m`, its background (with a 7-pixel
|
||||
/// border) then the map.
|
||||
pub(super) fn held_map(&mut self, m: glam::Mat4, id: i32, light: u32, out: &mut Vec<Vertex>) {
|
||||
let deg = |d: f32| d.to_radians();
|
||||
let m = m
|
||||
* glam::Mat4::from_rotation_y(deg(180.0))
|
||||
* glam::Mat4::from_rotation_z(deg(180.0))
|
||||
* glam::Mat4::from_scale(glam::Vec3::splat(0.38))
|
||||
@@ -984,7 +986,6 @@ impl Game<'_> {
|
||||
if has_data {
|
||||
self.map_vertices(id, m, light, out);
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,43 +2,76 @@
|
||||
//! their tick, frame, chat and command hooks, their HUD panels and what
|
||||
//! they print. With no mod on, `horizon_mods::Mods` holds no engine and
|
||||
//! every hook here returns after one check.
|
||||
use super::{Game, Screen};
|
||||
//!
|
||||
//! Code mods run only in builds with the `mods` feature (release builds):
|
||||
//! without it the game links no wasmtime, and every hook does nothing.
|
||||
//! Content mods (`content_root`) load in every build.
|
||||
use super::Game;
|
||||
use horizon_client::{
|
||||
command::{Dispatcher, vanilla},
|
||||
extras::Corner,
|
||||
extras_hud::{self, Corners},
|
||||
extras_hud::Corners,
|
||||
hud::HudBatch,
|
||||
mod_list::{self, Installed},
|
||||
text::{self, Text},
|
||||
mod_list,
|
||||
};
|
||||
use horizon_mods::{Clock, Mods, Output, Player, Snapshot, Status};
|
||||
use std::{
|
||||
path::PathBuf,
|
||||
time::{Duration, Instant},
|
||||
use std::{path::PathBuf, time::Duration};
|
||||
#[cfg(feature = "mods")]
|
||||
use {
|
||||
super::Screen,
|
||||
horizon_client::{
|
||||
extras::Corner,
|
||||
extras_hud,
|
||||
mod_list::Installed,
|
||||
text::{self, Text},
|
||||
},
|
||||
horizon_mods::{Clock, Mods, Output, Player, Snapshot, Status},
|
||||
std::time::Instant,
|
||||
};
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct ModsState {
|
||||
pub(super) mods: Mods,
|
||||
#[cfg(feature = "mods")]
|
||||
mods: Mods,
|
||||
/// `--dev-mod PATH`: always on, reloaded when rebuilt.
|
||||
pub(super) dev: Vec<PathBuf>,
|
||||
/// When dev mods were last checked for a rebuild.
|
||||
#[cfg(feature = "mods")]
|
||||
checked: Option<Instant>,
|
||||
/// The chat's command tree while a running mod owns commands.
|
||||
#[cfg(feature = "mods")]
|
||||
tree: Option<Dispatcher>,
|
||||
}
|
||||
impl ModsState {
|
||||
/// The chat's command tree: vanilla's, plus the running mods' commands.
|
||||
pub(super) fn dispatcher(&self) -> &Dispatcher {
|
||||
self.tree
|
||||
.as_ref()
|
||||
.unwrap_or_else(|| vanilla::game_dispatcher())
|
||||
#[cfg(feature = "mods")]
|
||||
if let Some(tree) = &self.tree {
|
||||
return tree;
|
||||
}
|
||||
vanilla::game_dispatcher()
|
||||
}
|
||||
/// Whether a mod runs: the chat keeps its lines for them.
|
||||
pub(super) fn running(&self) -> bool {
|
||||
#[cfg(feature = "mods")]
|
||||
if self.mods.is_running() {
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
/// Whether a mod draws a HUD panel.
|
||||
pub(super) fn has_panels(&self) -> bool {
|
||||
#[cfg(feature = "mods")]
|
||||
if self.mods.panels().next().is_some() {
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
/// How often dev mods are checked for a rebuild.
|
||||
#[cfg(feature = "mods")]
|
||||
const RELOAD_CHECK: Duration = Duration::from_secs(1);
|
||||
|
||||
/// A vanilla command name: mods may not take it.
|
||||
#[cfg(feature = "mods")]
|
||||
fn vanilla_command(name: &str) -> bool {
|
||||
let d = vanilla::game_dispatcher();
|
||||
d.nodes[0]
|
||||
@@ -112,6 +145,30 @@ pub(super) fn content_root(
|
||||
}
|
||||
}
|
||||
|
||||
/// A build without the `mods` feature: no mod runs.
|
||||
#[cfg(not(feature = "mods"))]
|
||||
impl Game<'_> {
|
||||
/// Says why the installed and dev mods don't run.
|
||||
pub(super) fn start_mods(&mut self) {
|
||||
let dir = Self::options_path().with_file_name(mod_list::DIR);
|
||||
let found = horizon_mods::find(&dir).len() + self.mods.dev.len();
|
||||
if found > 0 {
|
||||
log::warn!(
|
||||
"Horizon: {found} mod(s) not started: this build has no mod runtime (build with --features mods)"
|
||||
);
|
||||
}
|
||||
}
|
||||
pub(super) fn sync_mods(&mut self) {}
|
||||
pub(super) fn tick_mods(&mut self) {}
|
||||
pub(super) fn mods_frame(&mut self, _elapsed: Duration) {}
|
||||
pub(super) fn mod_command(&mut self, _command: &str) -> bool {
|
||||
false
|
||||
}
|
||||
pub(super) fn mods_chat(&mut self, _line: &str) {}
|
||||
pub(super) fn draw_mod_panels(&self, _batch: &mut HudBatch, _corners: &mut Corners) {}
|
||||
}
|
||||
|
||||
#[cfg(feature = "mods")]
|
||||
impl Game<'_> {
|
||||
/// Reads `horizon-mods/` and the dev mods, and starts the ones turned on.
|
||||
pub(super) fn start_mods(&mut self) {
|
||||
|
||||
@@ -38,7 +38,7 @@ use horizon_protocol::{
|
||||
play::{self, Clientbound as C, Serverbound as S},
|
||||
},
|
||||
registry::{Builtin, DimensionType, Dynamic, ceil_log2},
|
||||
text::{Language, format_java, plain},
|
||||
text::{Language, plain},
|
||||
};
|
||||
use horizon_world::{
|
||||
nbt::Tag,
|
||||
@@ -694,11 +694,14 @@ impl Game<'_> {
|
||||
}
|
||||
Some(stack)
|
||||
}
|
||||
pub(super) fn chat_line(&mut self, text: String) {
|
||||
/// A line in the connection's chat, with its styles, click and hover
|
||||
/// events (ChatComponent.addMessage).
|
||||
pub(super) fn chat_line(&mut self, content: horizon_client::text::Text) {
|
||||
let text = content.plain();
|
||||
log::info!("[chat] {text}");
|
||||
self.ui.chat.add(
|
||||
&self.gui.atlas,
|
||||
horizon_client::text::Text::literal(text.clone()),
|
||||
content,
|
||||
None,
|
||||
self.ticks,
|
||||
self.screen == Screen::Chat,
|
||||
@@ -2381,42 +2384,27 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
C::SystemChat(p) => {
|
||||
let text = plain(
|
||||
&p.content,
|
||||
&self
|
||||
.remote
|
||||
.as_ref()
|
||||
.map(|r| r.lang.clone())
|
||||
.unwrap_or_default(),
|
||||
);
|
||||
// ChatListener.handleOverlay.
|
||||
// The component as sent, translated in the client's own
|
||||
// language. ChatListener.handleOverlay for the action bar.
|
||||
let text = remote_text(&p.content);
|
||||
if p.overlay {
|
||||
self.titles.set_overlay(remote_text(&p.content), false);
|
||||
self.titles.set_overlay(text, false);
|
||||
} else {
|
||||
self.chat_line(text);
|
||||
}
|
||||
}
|
||||
C::PlayerChat(p) => {
|
||||
let lang = self
|
||||
.remote
|
||||
.as_ref()
|
||||
.map(|r| r.lang.clone())
|
||||
.unwrap_or_default();
|
||||
use horizon_client::scoreboard::tag_to_json;
|
||||
let content = p
|
||||
.unsigned_content
|
||||
.as_ref()
|
||||
.map_or_else(|| p.content.clone(), |t| plain(t, &lang));
|
||||
let line = self.format_chat(&p.chat_type, &content);
|
||||
.map_or_else(|| serde_json::Value::from(p.content.as_str()), tag_to_json);
|
||||
let line = self.format_chat(&p.chat_type, content);
|
||||
self.chat_line(line);
|
||||
}
|
||||
C::DisguisedChat(p) => {
|
||||
let lang = self
|
||||
.remote
|
||||
.as_ref()
|
||||
.map(|r| r.lang.clone())
|
||||
.unwrap_or_default();
|
||||
let content = plain(&p.message, &lang);
|
||||
let line = self.format_chat(&p.chat_type, &content);
|
||||
let content = horizon_client::scoreboard::tag_to_json(&p.message);
|
||||
let line = self.format_chat(&p.chat_type, content);
|
||||
self.chat_line(line);
|
||||
}
|
||||
C::PlayerInfoUpdate(p) => {
|
||||
@@ -2848,11 +2836,17 @@ impl Game<'_> {
|
||||
true
|
||||
}
|
||||
/// ChatType decoration: `chat.translation_key` with sender/content parameters.
|
||||
fn format_chat(&self, bound: &play::ChatTypeBound, content: &str) -> String {
|
||||
/// ChatType.Bound.decorate: the chat type's translation, with the
|
||||
/// sender, the target and the message as its parameters, in its style.
|
||||
fn format_chat(
|
||||
&self,
|
||||
bound: &play::ChatTypeBound,
|
||||
content: serde_json::Value,
|
||||
) -> horizon_client::text::Text {
|
||||
use horizon_client::scoreboard::{comp_text, tag_to_json};
|
||||
let Some(r) = self.remote.as_ref() else {
|
||||
return content.to_owned();
|
||||
return comp_text(&content);
|
||||
};
|
||||
let sender = plain(&bound.name, &r.lang);
|
||||
let decoration = r
|
||||
.registries
|
||||
.data("minecraft:chat_type", bound.chat_type)
|
||||
@@ -2861,30 +2855,31 @@ impl Game<'_> {
|
||||
Some(Tag::String(s)) => s.to_string_lossy(),
|
||||
_ => "chat.type.text".into(),
|
||||
};
|
||||
let params: Vec<String> = match decoration.and_then(|d| d.get("parameters")) {
|
||||
let parameter = |name: &str| match name {
|
||||
"sender" => tag_to_json(&bound.name),
|
||||
"target" => bound
|
||||
.target
|
||||
.as_ref()
|
||||
.map_or_else(|| serde_json::Value::from(""), tag_to_json),
|
||||
_ => content.clone(),
|
||||
};
|
||||
let with: Vec<serde_json::Value> = match decoration.and_then(|d| d.get("parameters")) {
|
||||
Some(Tag::List(list)) => list
|
||||
.iter()
|
||||
.map(|p| match p {
|
||||
Tag::String(s) => match s.to_string_lossy().as_str() {
|
||||
"sender" => sender.clone(),
|
||||
"target" => bound
|
||||
.target
|
||||
.as_ref()
|
||||
.map(|t| plain(t, &r.lang))
|
||||
.unwrap_or_default(),
|
||||
_ => content.to_owned(),
|
||||
},
|
||||
_ => String::new(),
|
||||
Tag::String(s) => parameter(&s.to_string_lossy()),
|
||||
_ => serde_json::Value::from(""),
|
||||
})
|
||||
.collect(),
|
||||
_ => vec![sender, content.to_owned()],
|
||||
_ => vec![parameter("sender"), parameter("content")],
|
||||
};
|
||||
let pattern = r
|
||||
.lang
|
||||
.get(&key)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "<%s> %s".into());
|
||||
format_java(&pattern, ¶ms)
|
||||
let mut component = match decoration.and_then(|d| d.get("style")) {
|
||||
Some(style @ Tag::Compound(_)) => tag_to_json(style),
|
||||
_ => serde_json::json!({}),
|
||||
};
|
||||
component["translate"] = key.into();
|
||||
component["with"] = with.into();
|
||||
comp_text(&component)
|
||||
}
|
||||
fn remote_item_data(&mut self, id: i32, stack: &Option<Stack>) {
|
||||
let Some(stack) = self.to_stack(stack) else {
|
||||
@@ -4692,7 +4687,9 @@ impl Game<'_> {
|
||||
self.send(S::ContainerClose(play::ContainerClose {
|
||||
container: p.container,
|
||||
}));
|
||||
self.chat_line(format!("(Horizon cannot show {menu} screens yet)"));
|
||||
self.chat_line(horizon_client::text::Text::literal(format!(
|
||||
"(Horizon cannot show {menu} screens yet)"
|
||||
)));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -120,6 +120,8 @@ pub struct ItemInfo {
|
||||
pub dye: Option<String>,
|
||||
/// `block_transformer`: the registry entry an axe, hoe or shovel uses on blocks.
|
||||
pub block_transformer: Option<String>,
|
||||
/// Has a default `bundle_contents` (the bundles, `BundleItem`).
|
||||
pub bundle: bool,
|
||||
}
|
||||
/// `Item.Properties.craftRemainder` (not a data component, so not in the
|
||||
/// default-component report): what an item leaves in the grid when crafted with.
|
||||
@@ -593,6 +595,7 @@ impl Items {
|
||||
jukebox_song: c["minecraft:jukebox_playable"].as_str().map(str::to_owned),
|
||||
dye: c["minecraft:dye"].as_str().map(str::to_owned),
|
||||
block_transformer: c["minecraft:block_transformer"].as_str().map(str::to_owned),
|
||||
bundle: c.get("minecraft:bundle_contents").is_some(),
|
||||
crafting_remainder: CRAFT_REMAINDERS
|
||||
.iter()
|
||||
.find(|(item, _)| *item == name)
|
||||
|
||||
@@ -4,10 +4,15 @@
|
||||
//! clone, throw, quick craft (drag) and pickup all; items thrown out of a
|
||||
//! menu are returned for the game to drop.
|
||||
use super::guest::Guest;
|
||||
use crate::{items::ItemStack, world::World};
|
||||
use crate::{
|
||||
bundle::{Contents, can_be_in_bundle},
|
||||
items::{ItemStack, Items},
|
||||
world::World,
|
||||
};
|
||||
|
||||
/// What the menus need to know about items.
|
||||
pub trait Rules {
|
||||
fn items(&self) -> &Items;
|
||||
fn max_stack(&self, item: &str) -> u32;
|
||||
/// The armor slot (0 head .. 3 feet) an item can be worn in.
|
||||
fn armor_index(&self, item: &str) -> Option<usize>;
|
||||
@@ -364,6 +369,9 @@ fn pickup(view: &mut View, slot: i32, button: i32, rules: &dyn Rules) {
|
||||
return;
|
||||
}
|
||||
let right = button == 1;
|
||||
if bundle_click(view, i, right, rules) {
|
||||
return;
|
||||
}
|
||||
let (slot_item, carried) = (view.get(i), view.guest.carried.clone());
|
||||
match (slot_item, carried) {
|
||||
(None, None) => {}
|
||||
@@ -410,6 +418,84 @@ fn pickup(view: &mut View, slot: i32, button: i32, rules: &dyn Rules) {
|
||||
}
|
||||
}
|
||||
|
||||
/// `tryItemClickBehaviourOverride`: the carried stack's
|
||||
/// `BundleItem.overrideStackedOnOther`, then the clicked one's
|
||||
/// `overrideOtherStackedOnMe`. True when a bundle took the click.
|
||||
fn bundle_click(view: &mut View, i: usize, right: bool, rules: &dyn Rules) -> bool {
|
||||
let items = rules.items();
|
||||
let other = view.get(i);
|
||||
if let Some(mut carried) = view.guest.carried.clone()
|
||||
&& let Some(mut contents) = Contents::of(items, &carried)
|
||||
&& other.is_some() != right
|
||||
{
|
||||
match other {
|
||||
// tryTransfer: as much as fits, through `safeTake`.
|
||||
Some(mut other) => {
|
||||
if can_be_in_bundle(&other) {
|
||||
let amount = other.count.min(contents.room_for(items, &other));
|
||||
if amount > 0 {
|
||||
let mut taken = other.clone();
|
||||
taken.count = amount;
|
||||
other.count -= amount;
|
||||
view.set(i, (other.count > 0).then_some(other));
|
||||
contents.try_insert(items, &mut taken);
|
||||
}
|
||||
}
|
||||
}
|
||||
// removeOne into the empty slot (`safeInsert`); the rest goes back.
|
||||
None => {
|
||||
if let Some(mut stack) = contents.remove_one(items) {
|
||||
if view.may_place(i, &stack, rules) {
|
||||
let amount = stack.count.min(view.slot_max(i, &stack, rules));
|
||||
view.set(
|
||||
i,
|
||||
Some(ItemStack {
|
||||
count: amount,
|
||||
..stack.clone()
|
||||
}),
|
||||
);
|
||||
stack.count -= amount;
|
||||
}
|
||||
if stack.count > 0 {
|
||||
contents.try_insert(items, &mut stack);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
contents.store(&mut carried);
|
||||
view.guest.carried = Some(carried);
|
||||
return true;
|
||||
}
|
||||
let Some(mut bundle) = other else {
|
||||
return false;
|
||||
};
|
||||
let Some(mut contents) = Contents::of(items, &bundle) else {
|
||||
return false;
|
||||
};
|
||||
// Slot.allowModification: mayPickup and mayPlace of its item.
|
||||
let allowed = view.may_place(i, &bundle, rules);
|
||||
match (view.guest.carried.take(), right) {
|
||||
(Some(mut carried), false) => {
|
||||
if allowed {
|
||||
contents.try_insert(items, &mut carried);
|
||||
}
|
||||
view.guest.carried = (carried.count > 0).then_some(carried);
|
||||
}
|
||||
(None, true) => {
|
||||
if allowed {
|
||||
view.guest.carried = contents.remove_one(items);
|
||||
}
|
||||
}
|
||||
(carried, _) => {
|
||||
view.guest.carried = carried;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
contents.store(&mut bundle);
|
||||
view.set(i, Some(bundle));
|
||||
true
|
||||
}
|
||||
|
||||
fn swap(view: &mut View, slot: i32, button: i32, rules: &dyn Rules) {
|
||||
let Ok(i) = usize::try_from(slot) else {
|
||||
return;
|
||||
@@ -538,3 +624,116 @@ fn quick_craft(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::lan::{Joined, PlayerSender};
|
||||
use horizon_protocol::packets::configuration::ClientInformation;
|
||||
use std::sync::{Arc, atomic::AtomicI32, mpsc};
|
||||
|
||||
struct ItemRules(Items);
|
||||
impl Rules for ItemRules {
|
||||
fn items(&self) -> &Items {
|
||||
&self.0
|
||||
}
|
||||
fn max_stack(&self, item: &str) -> u32 {
|
||||
self.0.get(item).map_or(64, |i| i.max_stack)
|
||||
}
|
||||
fn armor_index(&self, _: &str) -> Option<usize> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn guest() -> Guest {
|
||||
let (tx, _rx) = mpsc::channel();
|
||||
Guest::new(
|
||||
Joined {
|
||||
conn: 2,
|
||||
uuid: 2,
|
||||
name: "Alex".into(),
|
||||
properties: Vec::new(),
|
||||
info: ClientInformation::new(8),
|
||||
brand: "test".into(),
|
||||
sender: PlayerSender::new(tx, Arc::default()),
|
||||
latency: Arc::new(AtomicI32::new(0)),
|
||||
address: "127.0.0.1:25565".parse().unwrap(),
|
||||
},
|
||||
2,
|
||||
[0.0, 64.0, 0.0],
|
||||
8,
|
||||
)
|
||||
}
|
||||
|
||||
fn contents(stack: &Option<ItemStack>) -> Vec<(String, u32)> {
|
||||
let inside = stack.as_ref().and_then(|s| s.data());
|
||||
inside.map_or(Vec::new(), |d| {
|
||||
d.bundle_contents
|
||||
.iter()
|
||||
.map(|s| (s.item.to_string(), s.count))
|
||||
.collect()
|
||||
})
|
||||
}
|
||||
|
||||
/// A guest's chest clicks run `BundleItem`'s overrides: a carried
|
||||
/// bundle takes a slot's stack and drops its first one into an empty
|
||||
/// slot; a stack clicked onto a bundle goes in, as far as it fits.
|
||||
#[test]
|
||||
fn guest_chest_clicks_fill_and_empty_bundles() {
|
||||
let root = std::env::var_os("HORIZON_RESOURCES")
|
||||
.map(std::path::PathBuf::from)
|
||||
.unwrap_or_else(|| {
|
||||
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION)
|
||||
});
|
||||
if !root.join("items.json").exists() {
|
||||
eprintln!("skipping resource-backed bundle check: {}", root.display());
|
||||
return;
|
||||
}
|
||||
let registry = horizon_world::level::data::load_registry(&root).unwrap();
|
||||
let facts = crate::blocks::BlockFacts::load(&root, ®istry).unwrap();
|
||||
let rules = ItemRules(Items::load(&root, ®istry, &facts).unwrap());
|
||||
let mut g = guest();
|
||||
let mut chest = vec![
|
||||
Some(ItemStack::new("minecraft:stone", 48)),
|
||||
None,
|
||||
Some(ItemStack::new("minecraft:bundle", 1)),
|
||||
];
|
||||
let mut drag = None;
|
||||
let mut view = View {
|
||||
guest: &mut g,
|
||||
container: Some(&mut chest),
|
||||
};
|
||||
view.guest.carried = Some(ItemStack::new("minecraft:bundle", 1));
|
||||
// Left click with the bundle: the stone goes in.
|
||||
click(&mut view, &mut drag, 0, 0, input::PICKUP, &rules);
|
||||
assert_eq!(view.get(0), None);
|
||||
assert_eq!(
|
||||
contents(&view.guest.carried),
|
||||
[("minecraft:stone".into(), 48)]
|
||||
);
|
||||
// Right click on an empty slot: the stone comes back out.
|
||||
click(&mut view, &mut drag, 1, 1, input::PICKUP, &rules);
|
||||
assert_eq!(view.get(1).map(|s| s.count), Some(48));
|
||||
assert_eq!(contents(&view.guest.carried), []);
|
||||
// Ender pearls (1/16 each) onto the bundle in the chest: 16 fit.
|
||||
view.guest.carried = Some(ItemStack::new("minecraft:ender_pearl", 16));
|
||||
click(&mut view, &mut drag, 2, 0, input::PICKUP, &rules);
|
||||
assert_eq!(view.guest.carried, None);
|
||||
let bundle = view.get(2);
|
||||
assert_eq!(contents(&bundle), [("minecraft:ender_pearl".into(), 16)]);
|
||||
// Full: more stone stays in hand.
|
||||
view.guest.carried = Some(ItemStack::new("minecraft:stone", 5));
|
||||
click(&mut view, &mut drag, 2, 0, input::PICKUP, &rules);
|
||||
assert_eq!(view.guest.carried.as_ref().map(|s| s.count), Some(5));
|
||||
// Right click on it with an empty hand takes the pearls out.
|
||||
view.guest.carried = None;
|
||||
click(&mut view, &mut drag, 2, 1, input::PICKUP, &rules);
|
||||
assert_eq!(
|
||||
view.guest.carried.as_ref().map(|s| (&*s.item, s.count)),
|
||||
Some(("minecraft:ender_pearl", 16))
|
||||
);
|
||||
assert_eq!(contents(&view.get(2)), []);
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@ pub mod border_render;
|
||||
pub mod bossbar;
|
||||
pub mod breaking;
|
||||
pub mod brewing;
|
||||
pub mod bundle;
|
||||
pub mod camera;
|
||||
pub mod cauldron;
|
||||
pub mod chat;
|
||||
|
||||
@@ -16,6 +16,7 @@ use crate::{
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
mod bundle;
|
||||
pub mod place;
|
||||
|
||||
/// What a menu needs to know about items and recipes.
|
||||
@@ -276,6 +277,9 @@ pub struct Menu {
|
||||
pub loom_patterns: Vec<Arc<str>>,
|
||||
/// Sounds the menu plays (`ui.loom.take_result`), for the caller.
|
||||
pub sounds: Vec<&'static str>,
|
||||
/// Sounds the player makes (a bundle's insert and remove), for the
|
||||
/// caller to play at the player as `Entity.playSound`.
|
||||
pub player_sounds: Vec<&'static str>,
|
||||
/// Recipes whose result was taken (`awardUsedRecipes`), for the caller.
|
||||
pub crafted: Vec<String>,
|
||||
/// `LivingEntity.onEquipItem` calls that change the item: the
|
||||
@@ -671,6 +675,7 @@ impl Menu {
|
||||
quick_slots: Vec::new(),
|
||||
loom_patterns: Vec::new(),
|
||||
sounds: Vec::new(),
|
||||
player_sounds: Vec::new(),
|
||||
crafted: Vec::new(),
|
||||
equipped: Vec::new(),
|
||||
merchant: None,
|
||||
@@ -931,6 +936,23 @@ impl Menu {
|
||||
}
|
||||
}
|
||||
|
||||
/// `slotsChanged(player.getInventory())`, which a bundle click calls
|
||||
/// (`BundleItem.broadcastChangesOnContainerMenu`): the menus that don't
|
||||
/// check which container changed recompute from their inputs.
|
||||
fn inventory_slots_changed(&mut self, env: &Env) {
|
||||
match self.kind {
|
||||
Kind::Inventory | Kind::Crafting => self.recompute(env),
|
||||
Kind::Loom => self.loom_changed(env),
|
||||
Kind::Stonecutter => self.stone_changed(env),
|
||||
Kind::Cartography => self.carto_changed(env),
|
||||
Kind::Merchant => self.merchant_update(env),
|
||||
// ItemCombinerMenu.slotsChanged only for its inputs; the
|
||||
// smithing table's hasRecipeError always.
|
||||
Kind::Smithing => self.smith_error(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// `EnchantmentMenu.clickMenuButton`'s checks: the lapis, the cost and
|
||||
/// the levels (creative pays nothing). The enchanting itself needs a
|
||||
/// level and is the caller's (`enchant_button`).
|
||||
@@ -2536,10 +2558,12 @@ impl Menu {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The PICKUP branch of `doClick` (without the bundle overrides), ending
|
||||
/// with `slot.setChanged()`.
|
||||
/// The PICKUP branch of `doClick`: a bundle's override, else the
|
||||
/// plain click, ending with `slot.setChanged()`.
|
||||
fn pickup(&mut self, env: &Env, s: usize, primary: bool) {
|
||||
self.pickup_inner(env, s, primary);
|
||||
if !self.bundle_click(env, s, primary) {
|
||||
self.pickup_inner(env, s, primary);
|
||||
}
|
||||
self.slot_changed(env, s);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
//! `BundleItem`'s click overrides in a PICKUP click
|
||||
//! (`tryItemClickBehaviourOverride`).
|
||||
use super::{Env, Menu, tidy};
|
||||
use crate::bundle::{Contents, INSERT, INSERT_FAIL, REMOVE_ONE, can_be_in_bundle};
|
||||
|
||||
impl Menu {
|
||||
/// The carried stack's `overrideStackedOnOther`, then the clicked
|
||||
/// one's `overrideOtherStackedOnMe`. True when a bundle took the click.
|
||||
pub(super) fn bundle_click(&mut self, env: &Env, s: usize, primary: bool) -> bool {
|
||||
let took = self.carried_bundle_click(env, s, primary)
|
||||
|| self.clicked_bundle_click(env, s, primary);
|
||||
if took {
|
||||
// broadcastChangesOnContainerMenu: the open menu's
|
||||
// slotsChanged for the player's inventory.
|
||||
self.inventory_slots_changed(env);
|
||||
}
|
||||
took
|
||||
}
|
||||
|
||||
/// `BundleItem.overrideStackedOnOther`: the carried bundle on a slot.
|
||||
fn carried_bundle_click(&mut self, env: &Env, s: usize, primary: bool) -> bool {
|
||||
let Some(mut contents) = self
|
||||
.carried
|
||||
.as_ref()
|
||||
.and_then(|c| Contents::of(env.items, c))
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
match (self.slot(s).cloned(), primary) {
|
||||
(Some(other), true) => {
|
||||
// tryTransfer: what fits, taken by the slot's rules.
|
||||
let mut moved = 0;
|
||||
if can_be_in_bundle(&other) {
|
||||
let room = contents.room_for(env.items, &other);
|
||||
if let Some(mut taken) = self.safe_take(env, s, other.count, room) {
|
||||
moved = contents.try_insert(env.items, &mut taken);
|
||||
}
|
||||
}
|
||||
self.player_sounds
|
||||
.push(if moved > 0 { INSERT } else { INSERT_FAIL });
|
||||
}
|
||||
(None, false) => {
|
||||
if let Some(stack) = contents.remove_one(env.items) {
|
||||
let count = stack.count;
|
||||
let mut remainder = Some(stack);
|
||||
self.safe_insert(env, s, &mut remainder, count);
|
||||
match remainder {
|
||||
Some(mut rest) => {
|
||||
contents.try_insert(env.items, &mut rest);
|
||||
}
|
||||
None => self.player_sounds.push(REMOVE_ONE),
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => return false,
|
||||
}
|
||||
if let Some(carried) = self.carried.as_mut() {
|
||||
contents.store(carried);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// `BundleItem.overrideOtherStackedOnMe`: a click on a bundle in a slot.
|
||||
fn clicked_bundle_click(&mut self, env: &Env, s: usize, primary: bool) -> bool {
|
||||
let Some(mut bundle) = self.slot(s).cloned() else {
|
||||
return false;
|
||||
};
|
||||
let Some(mut contents) = Contents::of(env.items, &bundle) else {
|
||||
return false;
|
||||
};
|
||||
let allowed = self.allow_modification(env, s);
|
||||
match (self.carried.take(), primary) {
|
||||
(Some(mut other), true) => {
|
||||
let moved = if allowed {
|
||||
contents.try_insert(env.items, &mut other)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
self.player_sounds
|
||||
.push(if moved > 0 { INSERT } else { INSERT_FAIL });
|
||||
self.carried = Some(other);
|
||||
tidy(&mut self.carried);
|
||||
}
|
||||
(None, false) => {
|
||||
if allowed && let Some(removed) = contents.remove_one(env.items) {
|
||||
self.player_sounds.push(REMOVE_ONE);
|
||||
self.carried = Some(removed);
|
||||
}
|
||||
}
|
||||
(carried, _) => {
|
||||
// toggleSelectedItem(-1): Horizon keeps no selected item.
|
||||
self.carried = carried;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// `self.set(BUNDLE_CONTENTS, ...)` changes the slot's stack in place.
|
||||
contents.store(&mut bundle);
|
||||
let slot = self.slots[s];
|
||||
self.containers[slot.container][slot.index] = Some(bundle);
|
||||
true
|
||||
}
|
||||
}
|
||||
@@ -589,6 +589,7 @@ impl Settings {
|
||||
height_focused: f64::from(self.chat_height_focused),
|
||||
height_unfocused: f64::from(self.chat_height_unfocused),
|
||||
line_spacing: f64::from(self.chat_line_spacing),
|
||||
colors: self.chat_colors,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -428,6 +428,34 @@ impl Text {
|
||||
}
|
||||
out
|
||||
}
|
||||
/// The text without its legacy `§` codes (ChatFormatting.stripFormatting:
|
||||
/// a `§` and a code character, `0`-`9`, `a`-`f`, `k`-`o` or `r`).
|
||||
pub fn strip_legacy(&self) -> Text {
|
||||
if !self.spans.iter().any(|s| s.text.contains('§')) {
|
||||
return self.clone();
|
||||
}
|
||||
let code =
|
||||
|c: char| matches!(c.to_ascii_lowercase(), '0'..='9' | 'a'..='f' | 'k'..='o' | 'r');
|
||||
let mut out = Text::default();
|
||||
for span in &self.spans {
|
||||
let mut text = String::with_capacity(span.text.len());
|
||||
let mut chars = span.text.chars().peekable();
|
||||
while let Some(c) = chars.next() {
|
||||
if c == '§' && chars.peek().is_some_and(|&n| code(n)) {
|
||||
chars.next();
|
||||
} else {
|
||||
text.push(c);
|
||||
}
|
||||
}
|
||||
if !text.is_empty() {
|
||||
out.spans.push(Span {
|
||||
text,
|
||||
style: span.style.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
/// Characters with their effective styles.
|
||||
pub fn chars(&self) -> impl Iterator<Item = (char, &Style)> {
|
||||
self.spans
|
||||
|
||||
@@ -19,13 +19,16 @@ wit-component = { version = "0.239", optional = true }
|
||||
wit-parser = { version = "0.239", optional = true }
|
||||
|
||||
[features]
|
||||
default = ["runtime"]
|
||||
# Loads and runs mods; without it only manifests and packaging (cargo horizon-mod).
|
||||
# Loads and runs mods (the game's `mods` feature); without it only manifests,
|
||||
# content and packaging. Off by default so builds that don't run mods (the
|
||||
# game's tests and probes) skip wasmtime and cranelift.
|
||||
runtime = ["dep:wasmtime"]
|
||||
pack = ["dep:wit-component", "dep:wit-parser"]
|
||||
|
||||
[dev-dependencies]
|
||||
horizon-mods = { path = ".", features = ["pack"] }
|
||||
# Runs real mods: `cargo test -p horizon-mods --features runtime,pack`.
|
||||
[[test]]
|
||||
name = "sandbox"
|
||||
required-features = ["runtime", "pack"]
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -11,6 +11,6 @@ pub mod manifest;
|
||||
mod runtime;
|
||||
|
||||
pub use content::Content;
|
||||
pub use manifest::{Capability, Manifest, Package, Side};
|
||||
pub use manifest::{Capability, Manifest, Package, Side, find};
|
||||
#[cfg(feature = "runtime")]
|
||||
pub use runtime::*;
|
||||
@@ -1,6 +1,10 @@
|
||||
//! `horizon-mod.toml`, a mod's manifest, and the `.hmod` file around it.
|
||||
use serde::Deserialize;
|
||||
use std::{fmt, io::Read, path::Path};
|
||||
use std::{
|
||||
fmt,
|
||||
io::Read,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
/// The manifest's file name, beside `mod.wasm`.
|
||||
pub const MANIFEST: &str = "horizon-mod.toml";
|
||||
@@ -282,6 +286,20 @@ impl Package {
|
||||
}
|
||||
}
|
||||
|
||||
/// The `.hmod` files and mod folders in `dir`, by name.
|
||||
pub fn find(dir: &Path) -> Vec<PathBuf> {
|
||||
let Ok(read) = std::fs::read_dir(dir) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut found: Vec<PathBuf> = read
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
.filter(|p| p.extension().is_some_and(|e| e == "hmod") || p.join(MANIFEST).is_file())
|
||||
.collect();
|
||||
found.sort();
|
||||
found
|
||||
}
|
||||
|
||||
/// Turns a `wasm32-unknown-unknown` cdylib built with the SDK into the
|
||||
/// component the game loads.
|
||||
#[cfg(feature = "pack")]
|
||||
|
||||
@@ -131,23 +131,6 @@ pub struct Mods {
|
||||
out: Vec<Output>,
|
||||
}
|
||||
|
||||
/// The `.hmod` files and mod folders in `dir`, by name.
|
||||
pub fn find(dir: &Path) -> Vec<PathBuf> {
|
||||
let Ok(read) = std::fs::read_dir(dir) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut found: Vec<PathBuf> = read
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
.filter(|p| {
|
||||
p.extension().is_some_and(|e| e == "hmod")
|
||||
|| p.join(crate::manifest::MANIFEST).is_file()
|
||||
})
|
||||
.collect();
|
||||
found.sort();
|
||||
found
|
||||
}
|
||||
|
||||
impl Mods {
|
||||
/// Reads every mod at `paths` and starts those `enabled` says are on.
|
||||
/// A command that `reserved` (vanilla's commands) or an earlier mod
|
||||
|
||||
@@ -372,6 +372,19 @@ fn client_library_artifacts(manifest: &Value) -> Result<Vec<(&str, &Value)>, Str
|
||||
Ok(artifacts)
|
||||
}
|
||||
|
||||
/// Netty's native transports (`io.netty:netty-transport-native-kqueue:4.2.16.Final:osx-aarch_64`,
|
||||
/// the epoll ones on Linux): the game uses one when it loads and Netty NIO otherwise
|
||||
/// (`EventLoopGroupHolder.remote` asks `KQueue.isAvailable()`, then `Epoll.isAvailable()`), and
|
||||
/// Prism Launcher never downloads them (#1487). Preparing imports one when the launcher has it.
|
||||
fn runs_without(name: &str) -> bool {
|
||||
let mut parts = name.split(':');
|
||||
parts.next() == Some("io.netty")
|
||||
&& parts
|
||||
.next()
|
||||
.is_some_and(|artifact| artifact.starts_with("netty-transport-native-"))
|
||||
&& parts.nth(1).is_some()
|
||||
}
|
||||
|
||||
/// A relative path with only normal components and forward slashes, from a manifest or bundle.
|
||||
pub(crate) fn safe_relative(path: &str) -> Option<PathBuf> {
|
||||
if path.contains('\\') || path.starts_with('/') {
|
||||
@@ -547,9 +560,18 @@ pub fn import(
|
||||
for (name, artifact) in client_library_artifacts(&manifest)? {
|
||||
let (path, sha1) = artifact_fields(artifact)?;
|
||||
let relative = safe_relative(path).ok_or("Unsafe library path")?;
|
||||
let source = launcher_library(&minecraft, name, &relative);
|
||||
let destination = lock.directory.join("libraries/client").join(&relative);
|
||||
if runs_without(name) && !source.is_file() && !destination.exists() {
|
||||
log(&format!(
|
||||
"No {name} in {}: the game runs without it",
|
||||
minecraft.display()
|
||||
));
|
||||
continue;
|
||||
}
|
||||
client_libraries.push(import_copy(
|
||||
&launcher_library(&minecraft, name, &relative),
|
||||
&lock.directory.join("libraries/client").join(&relative),
|
||||
&source,
|
||||
&destination,
|
||||
sha1,
|
||||
"sha1",
|
||||
&hint(LAUNCHER_HINT, version),
|
||||
@@ -783,13 +805,17 @@ pub fn verify(lock: &Lock) -> Result<Artifacts, String> {
|
||||
}
|
||||
|
||||
/// The client libraries, checked against the version JSON (tools/test_model_baking_parity.py's
|
||||
/// `client_libraries`); the client-side exporters compile against them.
|
||||
/// `client_libraries`); the client-side exporters compile against them. A native transport the
|
||||
/// player's launcher did not have is left out.
|
||||
pub fn client_libraries(lock: &Lock, manifest: &Value) -> Result<Vec<PathBuf>, String> {
|
||||
let mut libraries = Vec::new();
|
||||
for (_, artifact) in client_library_artifacts(manifest)? {
|
||||
for (name, artifact) in client_library_artifacts(manifest)? {
|
||||
let (path, sha1) = artifact_fields(artifact)?;
|
||||
let relative = safe_relative(path).ok_or("Unsafe client library path")?;
|
||||
let library = lock.directory.join("libraries/client").join(&relative);
|
||||
if runs_without(name) && !library.exists() {
|
||||
continue;
|
||||
}
|
||||
if !library.exists() || digest(&library, "sha1")? != sha1 {
|
||||
return Err(format!("Client dependency checksum changed: {path}"));
|
||||
}
|
||||
@@ -1071,6 +1097,135 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// scanfee's Prism Launcher 26.3 install on a Mac (#1487): Prism keeps natives under its own
|
||||
/// names and never downloads Netty's native transport, which the game runs without. Preparing
|
||||
/// still checks every other library, and the transport whenever the launcher has it.
|
||||
#[test]
|
||||
fn a_prism_install_without_netty_natives_prepares() {
|
||||
let fixture = Imports::new();
|
||||
let old = fixture.imported(&fixture.source);
|
||||
let prism = fixture.directory.join("PrismLauncher");
|
||||
let put = |path: &str, data: &[u8]| {
|
||||
let path = prism.join(path);
|
||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
fs::write(path, data).unwrap();
|
||||
};
|
||||
put(
|
||||
"versions/9.9/9.9.jar",
|
||||
&fs::read(old.join("jars/client.jar")).unwrap(),
|
||||
);
|
||||
put("libraries/org/lib/lib/1/lib-1.jar", b"lib");
|
||||
put(
|
||||
"libraries/com/mojang/jtracy-natives-test/1/jtracy-natives-test-1.jar",
|
||||
b"jtracy natives",
|
||||
);
|
||||
let sha1 = |data: &[u8]| {
|
||||
let file = fixture.directory.join("sha1");
|
||||
fs::write(&file, data).unwrap();
|
||||
digest(&file, "sha1").unwrap()
|
||||
};
|
||||
let library = |name: &str, path: &str, sha1: &str, rules: &str| {
|
||||
format!(
|
||||
r#"{{"name":"{name}","downloads":{{"artifact":{{"path":"{path}","sha1":"{sha1}"}}}}{rules}}}"#
|
||||
)
|
||||
};
|
||||
let netty = |arch: &str| {
|
||||
library(
|
||||
&format!("io.netty:netty-transport-native-kqueue:1:osx-{arch}"),
|
||||
&format!("io/netty/netty-transport-native-kqueue/1/netty-transport-native-kqueue-1-osx-{arch}.jar"),
|
||||
&sha1(b"kqueue"),
|
||||
&format!(r#","rules":[{{"action":"allow","os":{{"name":"{}"}}}}]"#, rule_system()),
|
||||
)
|
||||
};
|
||||
let client_sha1 = digest(&old.join("jars/client.jar"), "sha1").unwrap();
|
||||
let server_sha1 = digest(&old.join("jars/server.jar"), "sha1").unwrap();
|
||||
let libraries = [
|
||||
library(
|
||||
"org.lib:lib:1",
|
||||
"org/lib/lib/1/lib-1.jar",
|
||||
&sha1(b"lib"),
|
||||
"",
|
||||
),
|
||||
library(
|
||||
"com.mojang:jtracy:1:natives-test",
|
||||
"com/mojang/jtracy/1/jtracy-1-natives-test.jar",
|
||||
&sha1(b"jtracy natives"),
|
||||
"",
|
||||
),
|
||||
netty("aarch_64"),
|
||||
netty("x86_64"),
|
||||
];
|
||||
put(
|
||||
"versions/9.9/9.9.json",
|
||||
format!(
|
||||
r#"{{"id":"9.9","javaVersion":{{"majorVersion":25}},"downloads":{{"client":{{"sha1":"{client_sha1}"}},"server":{{"sha1":"{server_sha1}"}}}},"libraries":[{}]}}"#,
|
||||
libraries.join(",")
|
||||
)
|
||||
.as_bytes(),
|
||||
);
|
||||
let manifest_sha1 = digest(&prism.join("versions/9.9/9.9.json"), "sha1").unwrap();
|
||||
fs::write(
|
||||
fixture.target.join("research-lock.json"),
|
||||
format!(
|
||||
r#"{{"minecraft":{{"version":"9.9","java_major":25,"manifest_sha1":"{manifest_sha1}","client_sha1":"{client_sha1}","server_sha1":"{server_sha1}"}}}}"#
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
let lock = Lock::read(&fixture.target).unwrap();
|
||||
let server = old.join("jars/server.jar");
|
||||
let prepare = || import(&fixture.target, &lock, Some(&prism), Some(&server), &|_| {});
|
||||
let imported = || {
|
||||
let manifest = verify(&lock).unwrap().manifest;
|
||||
client_libraries(&lock, &manifest).unwrap().len()
|
||||
};
|
||||
|
||||
prepare().unwrap();
|
||||
assert_eq!(imported(), 2);
|
||||
let prepared = read_json(&lock.directory.join("prepared.json")).unwrap();
|
||||
let listed = prepared.get("libraries").and_then(|l| l.get("client"));
|
||||
assert_eq!(
|
||||
listed.and_then(Value::as_array).map(<[Value]>::len),
|
||||
Some(2)
|
||||
);
|
||||
assert!(reuse_imported(&fixture.target, &|_| {}).unwrap());
|
||||
|
||||
// The launcher's copy is used, and checked, when it has one.
|
||||
let manifest = read_json(&prism.join("versions/9.9/9.9.json")).unwrap();
|
||||
let (_, transport) = client_library_artifacts(&manifest)
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.find(|(name, _)| runs_without(name))
|
||||
.unwrap();
|
||||
let path = transport.get("path").and_then(Value::as_str).unwrap();
|
||||
put(&format!("libraries/{path}"), b"not kqueue");
|
||||
assert!(prepare().unwrap_err().contains("is not the pinned file"));
|
||||
put(&format!("libraries/{path}"), b"kqueue");
|
||||
prepare().unwrap();
|
||||
assert_eq!(imported(), 3);
|
||||
// Other libraries stay required.
|
||||
fs::remove_dir_all(lock.directory.join("libraries/client/org")).unwrap();
|
||||
fs::remove_file(prism.join("libraries/org/lib/lib/1/lib-1.jar")).unwrap();
|
||||
assert!(prepare().unwrap_err().starts_with("Missing "));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runs_without_only_netty_native_transports() {
|
||||
assert!(runs_without(
|
||||
"io.netty:netty-transport-native-kqueue:4.2.16.Final:osx-aarch_64"
|
||||
));
|
||||
assert!(runs_without(
|
||||
"io.netty:netty-transport-native-epoll:4.2.16.Final:linux-x86_64"
|
||||
));
|
||||
for required in [
|
||||
"io.netty:netty-transport-native-unix-common:4.2.16.Final",
|
||||
"io.netty:netty-transport-classes-kqueue:4.2.16.Final",
|
||||
"org.lwjgl:lwjgl:3.3.3:natives-macos-arm64",
|
||||
"com.mojang:jtracy:1.14.38:natives-windows",
|
||||
] {
|
||||
assert!(!runs_without(required), "{required}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn natives_follow_the_architecture() {
|
||||
let names = ["a:b:1:natives-macos", "a:b:1:natives-macos-arm64"];
|
||||
|
||||
+10
-2
@@ -137,10 +137,17 @@ proposal, no profiling. Its cranelift backend generates native code for x86-64
|
||||
and ARM64 on Windows, macOS and Linux, which is why one `.hmod` serves them
|
||||
all.
|
||||
|
||||
The game links it only with its `mods` feature: every release build turns it
|
||||
on (`release.yml`), and a build from source needs
|
||||
`cargo build --release -p horizon-client --features mods` to run code mods.
|
||||
Without it (the tests and probes) wasmtime and cranelift aren't built, a
|
||||
quarter of a clean build; content mods still load, and the game logs why the
|
||||
code mods don't start.
|
||||
|
||||
### Performance
|
||||
|
||||
Measured on the Mac mini (M4 Pro), release build, `cargo test -p
|
||||
horizon-mods --release --test sandbox -- --ignored --nocapture`:
|
||||
horizon-mods --features runtime,pack --release --test sandbox -- --ignored --nocapture`:
|
||||
|
||||
| | per call |
|
||||
|---|---|
|
||||
@@ -180,7 +187,8 @@ chat (`Mod Test Guest stopped: it took too long (an endless loop?)`), marks it
|
||||
on the Mods page and carries on; the other mods keep running. Host functions
|
||||
clip what a mod hands back (chat lines per call, HUD lines and lengths). Tests:
|
||||
`crates/horizon-mods/tests/sandbox.rs` (an endless loop, a panic and a 1 GiB
|
||||
allocation, next to a healthy mod).
|
||||
allocation, next to a healthy mod; `cargo test -p horizon-mods --features
|
||||
runtime,pack`).
|
||||
|
||||
## Playing with mods
|
||||
|
||||
|
||||
@@ -419,6 +419,20 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Whether the reference on disk says `want`, whatever line endings the checkout gave it: Git
|
||||
/// for Windows checks text out with CRLF (core.autocrlf) where .gitattributes does not pin LF.
|
||||
fn up_to_date(have: &str, want: &str) -> bool {
|
||||
have.replace("\r\n", "\n") == want
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_windows_checkout_of_the_reference_is_up_to_date() {
|
||||
let want = markdown();
|
||||
assert!(up_to_date(&want, &want));
|
||||
assert!(up_to_date(&want.replace('\n', "\r\n"), &want));
|
||||
assert!(!up_to_date(&want.replacen("## ", "### ", 1), &want));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_launcher_reference_is_up_to_date() {
|
||||
let path =
|
||||
@@ -429,7 +443,7 @@ mod tests {
|
||||
}
|
||||
let have = std::fs::read_to_string(&path).unwrap_or_default();
|
||||
assert!(
|
||||
have == want,
|
||||
up_to_date(&have, &want),
|
||||
"docs/launcher-commands.md is out of date: HORIZON_WRITE_DOCS=1 cargo test --manifest-path launcher/Cargo.toml the_launcher_reference"
|
||||
);
|
||||
for tool in all() {
|
||||
|
||||
@@ -37,6 +37,9 @@ public final class VanillaMenuOracle {
|
||||
static final List<String> drops = new ArrayList<>();
|
||||
/** onEquipItem calls that change the item (its sound and game event): slot and new stack. */
|
||||
static final List<String> equips = new ArrayList<>();
|
||||
/** The sounds the player played ({@code Entity.playSound}: a bundle's insert and remove). */
|
||||
static final List<String> sounds = new ArrayList<>();
|
||||
static final net.minecraft.util.RandomSource soundRandom = net.minecraft.util.RandomSource.create(0L);
|
||||
/** The block entity behind a "crafter_be" menu (slot state packets go to it). */
|
||||
static net.minecraft.world.level.block.entity.CrafterBlockEntity crafter;
|
||||
/** The block entity behind a "lectern" menu. */
|
||||
@@ -99,6 +102,8 @@ public final class VanillaMenuOracle {
|
||||
@Override public void scheduleTick(BlockPos pos, net.minecraft.world.level.block.Block block, int delay) { }
|
||||
@Override public void updateNeighborsAt(BlockPos pos, net.minecraft.world.level.block.Block block, net.minecraft.world.level.redstone.Orientation o) { }
|
||||
@Override public void playSound(net.minecraft.world.entity.Entity e, BlockPos pos, net.minecraft.sounds.SoundEvent s, net.minecraft.sounds.SoundSource c, float v, float pitch) { }
|
||||
// A bundle sound's pitch: 0.8 + level.getRandom().nextFloat() * 0.4.
|
||||
@Override public net.minecraft.util.RandomSource getRandom() { return soundRandom; }
|
||||
}
|
||||
|
||||
/** The maps of the case's level, by id, and the next free id. */
|
||||
@@ -127,6 +132,9 @@ public final class VanillaMenuOracle {
|
||||
if (!ItemStack.isSameItemSameComponents(old, now)) equips.add(slot.getName() + ":" + ParityData.canon(now));
|
||||
}
|
||||
@Override public boolean hasDisconnected() { return false; }
|
||||
@Override public void playSound(net.minecraft.sounds.SoundEvent sound, float volume, float pitch) {
|
||||
sounds.add(sound.location().toString());
|
||||
}
|
||||
@Override public ItemEntity drop(ItemStack stack, boolean thrown, net.minecraft.util.Prediction p) {
|
||||
if (!stack.isEmpty()) drops.add(ParityData.canon(stack));
|
||||
return null;
|
||||
@@ -251,9 +259,9 @@ public final class VanillaMenuOracle {
|
||||
// No inventory slots: the inventory Take Book fills.
|
||||
StringBuilder items = new StringBuilder();
|
||||
for (int i = 0; i < 36; i++) items.append(' ').append(ParityData.canon(inv.getItem(i)));
|
||||
return state(menu, inv) + " | " + String.join(";", equips) + " | inv" + items;
|
||||
return state(menu, inv) + " | " + String.join(";", equips) + " | " + String.join(";", sounds) + " | inv" + items;
|
||||
}
|
||||
return state(menu, inv) + " | " + String.join(";", equips);
|
||||
return state(menu, inv) + " | " + String.join(";", equips) + " | " + String.join(";", sounds);
|
||||
}
|
||||
|
||||
public static void main(String[] args) throws Exception {
|
||||
@@ -271,6 +279,7 @@ public final class VanillaMenuOracle {
|
||||
try {
|
||||
drops.clear();
|
||||
equips.clear();
|
||||
sounds.clear();
|
||||
var world = make(World.class);
|
||||
world.server = make(Server.class);
|
||||
var player = make(FakePlayer.class);
|
||||
@@ -325,6 +334,7 @@ public final class VanillaMenuOracle {
|
||||
for (int i = 0; i < dataJson.size(); i++) menu.setData(i, dataJson.get(i).getAsInt());
|
||||
drops.clear();
|
||||
equips.clear();
|
||||
sounds.clear();
|
||||
out.println("init " + clickState(menu, inv));
|
||||
for (var opJson : f.getAsJsonArray("ops")) {
|
||||
var op = opJson.getAsJsonArray();
|
||||
|
||||
@@ -65,6 +65,7 @@ import net.minecraft.network.protocol.game.ServerboundAcceptTeleportationPacket;
|
||||
import net.minecraft.network.protocol.game.ServerboundChatCommandPacket;
|
||||
import net.minecraft.network.protocol.game.ServerboundChatPacket;
|
||||
import net.minecraft.network.protocol.game.ServerboundChunkBatchReceivedPacket;
|
||||
import net.minecraft.network.protocol.game.ServerboundClientTickEndPacket;
|
||||
import net.minecraft.network.protocol.game.ServerboundMovePlayerPacket;
|
||||
import net.minecraft.network.protocol.game.ServerboundPlayerLoadedPacket;
|
||||
import net.minecraft.network.protocol.handshake.ClientIntent;
|
||||
@@ -484,6 +485,8 @@ public final class VanillaProtocolClient {
|
||||
// When the dead player sent a command (ServerGamePacketListenerImpl.handleChatCommand runs it
|
||||
// whether the player is alive or not); it respawns once the reply came, or 3 s later.
|
||||
long deadCommandAt = 0;
|
||||
// When the client last sent where it is.
|
||||
long positionAt = 0;
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
Received r;
|
||||
try {
|
||||
@@ -536,6 +539,7 @@ public final class VanillaProtocolClient {
|
||||
// acceptance alone, as the 26.3 client sends.
|
||||
if (!respawning && step < ENTITIES) {
|
||||
send(new ServerboundMovePlayerPacket.PosRot(position.position(), position.yRot(), position.xRot(), false, false));
|
||||
positionAt = now;
|
||||
}
|
||||
if (!loaded) {
|
||||
send(new ServerboundPlayerLoadedPacket());
|
||||
@@ -635,6 +639,17 @@ public final class VanillaProtocolClient {
|
||||
commandTree(p);
|
||||
}
|
||||
}
|
||||
if (entities == null && loaded && deadCommandAt == 0 && now - positionAt >= 1000) {
|
||||
// LocalPlayer.sendPosition: once its level is loaded the 26.3 client sends where it is
|
||||
// every 20 ticks even standing still, and the server then checks again which entities the
|
||||
// player sees (ChunkMap.move). An entity whose chunk was sent after the player's last move,
|
||||
// such as the villager when the first chunks come late, is paired at the next one. The tick
|
||||
// end first (Minecraft.tick sends one every tick): a server takes one position per client tick.
|
||||
// Until the death: from then on the steps wait only on entities spawned for the player.
|
||||
send(ServerboundClientTickEndPacket.INSTANCE);
|
||||
send(new ServerboundMovePlayerPacket.Pos(position.position(), false, false));
|
||||
positionAt = now;
|
||||
}
|
||||
if (entities != null && loaded && lastChunk > 0 && now - lastChunk > 3000 && step == 0) {
|
||||
// Entity mode: the harness's console summons the scene once it reads this
|
||||
// line; everything tracked from then on is recorded until the client leaves.
|
||||
|
||||
@@ -9,6 +9,7 @@ PACKAGES = {"horizon-core": "crates/horizon-core", "horizon-client": "crates/hor
|
||||
"horizon-theme": "crates/horizon-theme", "xtask": "xtask"}
|
||||
LIBRARIES = set(PACKAGES) - {"xtask"}
|
||||
DEPENDENCIES = {"horizon-client": {"horizon-core"}}
|
||||
MODS = " --features horizon-mods/runtime,horizon-mods/pack,horizon-client/mods"
|
||||
|
||||
|
||||
def args(host="local", quick=False):
|
||||
@@ -58,9 +59,9 @@ class PrReadyTests(unittest.TestCase):
|
||||
steps = pr_ready.plan(args(quick=True), "origin/main", {path}, PACKAGES, LIBRARIES, DEPENDENCIES)
|
||||
by_name = {step.name: step for step in steps}
|
||||
self.assertEqual(by_name["clippy"].command,
|
||||
"cargo clippy --workspace --all-targets --locked -- -D warnings")
|
||||
"cargo clippy --workspace" + MODS + " --all-targets --locked -- -D warnings")
|
||||
self.assertEqual(by_name["tests: workspace"].command,
|
||||
"cargo test --locked --workspace --tests")
|
||||
"cargo test --locked --workspace" + MODS + " --tests")
|
||||
self.assertEqual(by_name["doc tests"].command, "cargo test --locked --workspace --doc")
|
||||
|
||||
def test_non_rust_changes_skip_workspace_checks(self):
|
||||
|
||||
+70
-4
@@ -68,6 +68,9 @@ address. The script then checks, from both sides, what a guest's game used to ge
|
||||
- feedback: the guest's command feedback names the guest and shows the numbers;
|
||||
- chat: a line the host sends reaches the guest's event log (`wait-for chat`), and on the
|
||||
server thread the host window's too;
|
||||
- styles: a styled tellraw reaches the host and the guest (each its own) with its colour, bold, click and hover
|
||||
events; each player's /give tells them of the Diamonds! advancement before the give's
|
||||
own message, and never echoes their command back to them as `[Name: ...]`;
|
||||
- effects: a beacon's power reaches every player in range, the host and the guest;
|
||||
- distance: (--server-thread) the server follows the host's render distance, past vanilla's 32 too
|
||||
(IntegratedServer.tickServer), and the guest's chunks follow the server's (#804)
|
||||
@@ -221,7 +224,7 @@ ROOT = Path(__file__).resolve().parents[1]
|
||||
GUEST_NAME = 'Guest'
|
||||
# The host plays single player on its server thread (--server-thread).
|
||||
SERVER_THREAD = False
|
||||
CHECKS = ['tab', 'self', 'armour', 'nametags', 'walls', 'chest', 'crafting', 'sign', 'damage', 'mobs', 'hazards', 'pvp', 'arrows', 'kills', 'hurt', 'looks', 'leads', 'respawn', 'containers', 'feedback', 'titles', 'chat', 'commands', 'recipes', 'machines', 'brewing', 'loom', 'stonecutter', 'grindstone', 'anvil', 'smithing', 'enchanting', 'beacon', 'crafter', 'lectern', 'cartography', 'trials', 'clicks', 'sleep', 'predict', 'push', 'eat', 'eating', 'xp', 'objects', 'projectiles', 'thrown', 'sparks', 'hud', 'sidebar', 'border', 'particles', 'worn', 'blast', 'barter', 'deflect', 'fireball', 'light', 'time', 'swings', 'cracks', 'plates', 'effects', 'portals', 'distance', 'growth', 'flight', 'ladder', 'boats', 'minecarts', 'frames', 'hands', 'lightning', 'riding', 'mounts', 'worldspawn', 'beds', 'shield', 'deathmining', 'contacts', 'absorption', 'breath', 'lava', 'levels', 'drops', 'underwater']
|
||||
CHECKS = ['tab', 'self', 'armour', 'nametags', 'walls', 'chest', 'crafting', 'sign', 'damage', 'mobs', 'hazards', 'pvp', 'arrows', 'kills', 'hurt', 'looks', 'leads', 'respawn', 'containers', 'feedback', 'titles', 'chat', 'styles', 'commands', 'recipes', 'machines', 'brewing', 'loom', 'stonecutter', 'grindstone', 'anvil', 'smithing', 'enchanting', 'beacon', 'crafter', 'lectern', 'cartography', 'trials', 'clicks', 'sleep', 'predict', 'push', 'eat', 'eating', 'xp', 'objects', 'projectiles', 'thrown', 'sparks', 'hud', 'sidebar', 'border', 'particles', 'worn', 'blast', 'barter', 'deflect', 'fireball', 'light', 'time', 'swings', 'cracks', 'plates', 'effects', 'portals', 'distance', 'growth', 'flight', 'ladder', 'boats', 'minecarts', 'frames', 'hands', 'lightning', 'riding', 'mounts', 'worldspawn', 'beds', 'shield', 'deathmining', 'contacts', 'absorption', 'breath', 'lava', 'levels', 'drops', 'underwater']
|
||||
|
||||
|
||||
class Game:
|
||||
@@ -1462,8 +1465,8 @@ def check_loom(host, guest, out):
|
||||
|
||||
def own_reply(event):
|
||||
"""A chat event that answers the guest's own command. On the server thread the guest also gets
|
||||
its own command's `[Guest: ...]` operator broadcast, a moment later, which an answer to the next
|
||||
command must not be taken for."""
|
||||
its own command's `[Guest: ...]` operator broadcast before #1373, a moment later, which an answer
|
||||
to the next command must not be taken for."""
|
||||
return event.get('event') == 'chat' and not event.get('text', '').startswith(f'[{GUEST_NAME}: ')
|
||||
|
||||
|
||||
@@ -2028,6 +2031,69 @@ def check_chat(host, guest, out):
|
||||
return seen
|
||||
|
||||
|
||||
def styled_parts(component):
|
||||
"""Every component in a chat line's tree (the line, its `extra` and `with` parts)."""
|
||||
if isinstance(component, dict):
|
||||
yield component
|
||||
for key in ('extra', 'with'):
|
||||
for part in component.get(key) or []:
|
||||
yield from styled_parts(part)
|
||||
elif isinstance(component, list):
|
||||
for part in component:
|
||||
yield from styled_parts(part)
|
||||
|
||||
|
||||
def check_styles(host, guest, out):
|
||||
# ChatComponent keeps what the server sent: the style, the click and the hover event.
|
||||
stamp = f'STYLE {int(time.time())}'
|
||||
line = {'text': stamp, 'color': 'green', 'bold': True,
|
||||
'click_event': {'action': 'suggest_command', 'command': '/say clicked'},
|
||||
'hover_event': {'action': 'show_text', 'value': 'Tip'}}
|
||||
seen = {}
|
||||
for g in (host, guest):
|
||||
# Each to itself: tellraw reaches only the player whose command it is here.
|
||||
since = g.raw('events')['last']
|
||||
g('command', command='tellraw @s ' + json.dumps(line))
|
||||
g.raw('wait-for', 'chat', stamp, '--timeout', 10, '--since', since)
|
||||
part = next((p for c in g.raw('messages', 10)['styled'] for p in styled_parts(c)
|
||||
if stamp in str(p.get('text', ''))), None) or {}
|
||||
seen[g.name] = {k: part.get(k) for k in ('color', 'bold', 'click_event', 'hover_event')}
|
||||
hover = seen[g.name]['hover_event'] or {}
|
||||
seen[g.name]['hover_event'] = [hover.get('action'), 'Tip' in str(hover.get('value'))]
|
||||
# InventoryChangeTrigger fires inside /give, before its message; the operator echo
|
||||
# (broadcastToAdmins) skips the player whose command it was.
|
||||
order = {}
|
||||
for g in (host, guest):
|
||||
for command in ('advancement revoke @s everything', 'clear @s'):
|
||||
g('command', command=command)
|
||||
g.wait(5)
|
||||
since = g.raw('events')['last']
|
||||
g('command', command='give @s minecraft:diamond')
|
||||
g.raw('wait-for', 'chat', 'Diamonds!', '--timeout', 10, '--since', since)
|
||||
g.wait(20)
|
||||
chat = g.raw('messages', 8)['chat']
|
||||
name = GUEST_NAME if g is guest else host.player
|
||||
gave = next((i for i, l in enumerate(chat) if l.startswith('Gave 1 [Diamond]')), None)
|
||||
made = next((i for i, l in enumerate(chat) if '[Diamonds!]' in l), None)
|
||||
echo = [l for l in chat if l.startswith(f'[{name}: ')]
|
||||
# Newest first: the give's message is newer than the advancement's.
|
||||
order[g.name] = {'gave': gave, 'advancement': made, 'echo': echo}
|
||||
guest.raw('key', 't', 'tap')
|
||||
guest.wait(5)
|
||||
guest.shot(out, 'styles')
|
||||
guest.raw('key', 'esc', 'tap')
|
||||
want = {'color': 'green', 'bold': True,
|
||||
'click_event': {'action': 'suggest_command', 'command': '/say clicked'},
|
||||
'hover_event': ['show_text', True]}
|
||||
for name, got in seen.items():
|
||||
assert got == want, f'{name} styled line: {got}'
|
||||
for name, o in order.items():
|
||||
assert o['gave'] is not None and o['advancement'] is not None, f'{name} chat: {o}'
|
||||
assert o['gave'] < o['advancement'], f'{name}: the give came before the advancement: {o}'
|
||||
assert not o['echo'], f'{name} heard its own command: {o}'
|
||||
return {'styles': seen, 'order': order}
|
||||
|
||||
|
||||
def png_rows(path):
|
||||
"""The rows of an 8-bit RGB or RGBA PNG, unfiltered, as bytes of 4 (or 3) per pixel."""
|
||||
data = Path(path).read_bytes()
|
||||
@@ -4303,7 +4369,7 @@ CHECK_FNS = {'tab': check_tab, 'self': check_self, 'armour': check_armour,
|
||||
'sign': check_sign, 'damage': check_damage, 'mobs': check_mobs,
|
||||
'hazards': check_hazards, 'pvp': check_pvp, 'respawn': check_respawn,
|
||||
'containers': check_containers, 'feedback': check_feedback, 'titles': check_titles,
|
||||
'chat': check_chat, 'machines': check_machines, 'brewing': check_brewing,
|
||||
'chat': check_chat, 'styles': check_styles, 'machines': check_machines, 'brewing': check_brewing,
|
||||
'loom': check_loom, 'stonecutter': check_stonecutter, 'grindstone': check_grindstone,
|
||||
'anvil': check_anvil, 'smithing': check_smithing, 'enchanting': check_enchanting,
|
||||
'beacon': check_beacon, 'crafter': check_crafter, 'lectern': check_lectern,
|
||||
|
||||
+12
-2
@@ -65,6 +65,7 @@ SHARED = Path(os.environ.get("PARITY_SHARED") or (LOCAL / "minecraft").resolve()
|
||||
MACHINE_JOBS = int(os.environ.get("PARITY_MACHINE_JOBS") or max(1, (os.cpu_count() or 4) // 2))
|
||||
HOST = socket.gethostname().split(".")[0]
|
||||
DEFAULT_MEMORY = 3 << 30 # assumed peak for a harness that never ran here
|
||||
FAILURE_TAILS, FAILURE_TAIL_LINES = 10, 30 # the summary prints the end of the first failures' logs
|
||||
|
||||
|
||||
def harness_settings(path):
|
||||
@@ -1361,8 +1362,17 @@ def main():
|
||||
failed = sorted(name for name, record in results.items() if record["status"] != "pass")
|
||||
finished_run(failed)
|
||||
print(f"\n{len(results) - len(failed)} passed, {len(failed)} failed in {elapsed / 60:.1f} min.")
|
||||
for name in failed:
|
||||
print(f" {name}: {STATE_DIR / 'logs' / (name + '.log')}")
|
||||
for index, name in enumerate(failed):
|
||||
log = STATE_DIR / 'logs' / (name + '.log')
|
||||
print(f" {name}: {log}")
|
||||
# The end of a harness's output says why it failed, and CI shows only this output.
|
||||
if index < FAILURE_TAILS:
|
||||
try:
|
||||
lines = log.read_text(encoding='utf-8', errors='replace').splitlines()[-FAILURE_TAIL_LINES:]
|
||||
except OSError:
|
||||
lines = []
|
||||
for line in lines:
|
||||
print(f" {line[:300]}")
|
||||
return 1 if failed else 0
|
||||
|
||||
|
||||
|
||||
+12
-2
@@ -274,6 +274,16 @@ def parity_summary(output):
|
||||
return text
|
||||
|
||||
|
||||
def mod_features(crates):
|
||||
"""Code mods are off by default (docs/mods.md): on wherever horizon-mods is checked, as in CI."""
|
||||
if crates is not None and "horizon-mods" not in crates:
|
||||
return ""
|
||||
features = ["horizon-mods/runtime", "horizon-mods/pack"]
|
||||
if crates is None or "horizon-client" in crates:
|
||||
features.append("horizon-client/mods")
|
||||
return f" --features {','.join(features)}"
|
||||
|
||||
|
||||
def plan(args, base, changed, crates_of, libraries, dependencies):
|
||||
"""The checks for these changed files, in the order they run."""
|
||||
rust = any(workspace_rust(path) for path in changed)
|
||||
@@ -297,10 +307,10 @@ def plan(args, base, changed, crates_of, libraries, dependencies):
|
||||
Step("Python tests", "python3 -m unittest discover -s tests", where="here",
|
||||
skip=None if python else "no Python change", summary=unittest_passed),
|
||||
Step("rustfmt", "cargo fmt --all -- --check", skip=None if rust else no_rust),
|
||||
Step("clippy", f"cargo clippy {clippy_selection} --all-targets --locked -- -D warnings",
|
||||
Step("clippy", f"cargo clippy {clippy_selection}{mod_features(clippy_crates)} --all-targets --locked -- -D warnings",
|
||||
skip=None if rust else no_rust),
|
||||
Step("tests" + (": workspace" if crates is None else f": {', '.join(crates)}" if crates else ""),
|
||||
f"cargo test --locked {selection} --tests",
|
||||
f"cargo test --locked {selection}{mod_features(crates)} --tests",
|
||||
skip=test_skip, summary=tests_passed),
|
||||
Step("doc tests", f"cargo test --locked {doc_selection} --doc",
|
||||
skip=test_skip or (None if doc_crates else "no library touched"), summary=tests_passed),
|
||||
|
||||
@@ -7,7 +7,9 @@ respawn_radius 0) and stops; a vanilla dedicated server and Horizon's
|
||||
server.properties, server-icon.png and lists. VanillaProtocolClient, a
|
||||
headless client made of vanilla's own codecs (any byte vanilla would not
|
||||
read is an error), pings each server, joins it in offline mode through
|
||||
login, configuration and play, accepts the spawn teleport, receives the
|
||||
login, configuration and play, accepts the spawn teleport (then, standing
|
||||
still until its death, sends its position every 20 ticks as LocalPlayer
|
||||
does: the server checks then which entities it sees), receives the
|
||||
chunks around the spawn (their bytes are compared: the world is loaded from
|
||||
the same files), chats, runs /list and commands that fail (`/random value
|
||||
7..7`, `/random value x`, `/trigger nope`), trades, is made an operator by
|
||||
@@ -244,6 +246,20 @@ def console_lines(lines):
|
||||
return out
|
||||
|
||||
|
||||
# What a failure's diff doesn't say: warnings, errors, panics and why a player left.
|
||||
NOTABLE = re.compile(r'/(WARN|ERROR)\]|Exception|panicked|lost connection')
|
||||
|
||||
|
||||
def notable_lines(path, limit=20):
|
||||
"""A server's notable lines from its `Done (` on (the startup warnings are every run's), or a client's."""
|
||||
try:
|
||||
lines = path.read_text(encoding='utf-8', errors='replace').splitlines()
|
||||
except OSError:
|
||||
return []
|
||||
start = next((i for i, l in enumerate(lines) if 'Done (' in l), 0)
|
||||
return [f'{path.name}: {l[:300]}' for l in lines[start:] if NOTABLE.search(l)][-limit:]
|
||||
|
||||
|
||||
def server_files(directory):
|
||||
out = []
|
||||
for name in ['server.properties', 'eula.txt', 'ops.json', 'whitelist.json', 'banned-players.json', 'banned-ips.json',
|
||||
@@ -420,9 +436,13 @@ def main():
|
||||
(world / 'playerdata').mkdir(exist_ok=True)
|
||||
|
||||
def client(port, out):
|
||||
oh.run_logged(['java', '-Xmx1g', '-Duser.timezone=UTC', '-cp', str(classes) + os.pathsep + dependencies,
|
||||
'dev.horizonlab.parity.VanillaProtocolClient', '127.0.0.1', port, PLAYER, out], build,
|
||||
out.stem, timeout=300)
|
||||
try:
|
||||
oh.run_logged(['java', '-Xmx1g', '-Duser.timezone=UTC', '-cp', str(classes) + os.pathsep + dependencies,
|
||||
'dev.horizonlab.parity.VanillaProtocolClient', '127.0.0.1', port, PLAYER, out], build,
|
||||
out.stem, timeout=300)
|
||||
except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as error:
|
||||
log = (build / f'{out.stem}.log').read_text(encoding='utf-8', errors='replace').splitlines()
|
||||
raise AssertionError(f'{out.stem}: {error}:\n' + '\n'.join(log[-30:])) from None
|
||||
|
||||
results = {}
|
||||
for label, command in [('vanilla', vanilla_server), ('horizon', horizon_server)]:
|
||||
@@ -448,8 +468,10 @@ def main():
|
||||
if a != b:
|
||||
differences.append(f'chunk {key}: vanilla {a} horizon {b}')
|
||||
if differences or errors:
|
||||
notes = [line for name in ('vanilla', 'vanilla-client', 'horizon', 'horizon-client')
|
||||
for line in notable_lines(build / f'{name}.log')]
|
||||
raise AssertionError(f'{len([d for d in differences if d[:1] in "+-" and d[:3] not in ("+++", "---")])} differing lines '
|
||||
f'(see {build}/vanilla.txt and horizon.txt):\n' + '\n'.join(differences[:80]))
|
||||
f'(see {build}/vanilla.txt and horizon.txt):\n' + '\n'.join(differences[:80] + notes))
|
||||
lab.write_json(report, dict(
|
||||
schema=1, minecraft=lock['minecraft']['version'], platform=platform.platform(),
|
||||
java=subprocess.check_output(['java', '--version'], text=True).splitlines()[0],
|
||||
|
||||
@@ -22,8 +22,10 @@ results, and the scaled and locked copies a take makes), the lectern (over the
|
||||
block entity: its page buttons, clamped to the book's pages, and Take Book,
|
||||
with the inventory compared too) and the
|
||||
server's furnace menus over their block entities (`*_be`: a new input restarts
|
||||
cooking, other clicks keep its progress). After
|
||||
every click the slots, carried stack, data slots and drops are compared.
|
||||
cooking, other clicks keep its progress). Bundles (empty, partly and fully
|
||||
loaded, nested) are among the stacks and often in hand, for BundleItem's click
|
||||
overrides. After every click the slots, carried stack, data slots, drops and
|
||||
the player's sounds are compared.
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
@@ -37,6 +39,8 @@ except ImportError:
|
||||
MENUS = {'inventory': 46, 'crafting': 46, 'chest1': 45, 'chest2': 54, 'chest3': 63, 'chest4': 72, 'chest5': 81,
|
||||
'chest6': 90, 'hopper': 41, 'dispenser': 45, 'shulker': 63, 'furnace': 39, 'blast_furnace': 39, 'smoker': 39, 'brewing': 41, 'crafter': 46,
|
||||
'crafter_be': 46, 'loom': 40, 'stonecutter': 38, 'grindstone': 39, 'anvil': 39, 'smithing': 40, 'enchanting': 38, 'beacon': 37, 'cartography': 39, 'lectern': 1, 'furnace_be': 39, 'blast_furnace_be': 39, 'smoker_be': 39}
|
||||
# Where a carried bundle is common: the containers that hold anything.
|
||||
BUNDLE_MENUS = {'inventory', 'chest1', 'chest3', 'shulker', 'hopper', 'dispenser'}
|
||||
DATA = {'furnace': 4, 'blast_furnace': 4, 'smoker': 4, 'brewing': 4, 'crafter': 10, 'crafter_be': 10, 'loom': 1,
|
||||
'stonecutter': 1, 'enchanting': 10, 'beacon': 3, 'lectern': 1,
|
||||
'furnace_be': 4, 'blast_furnace_be': 4, 'smoker_be': 4}
|
||||
@@ -91,8 +95,10 @@ def snbt(item, count, damage=0, curse=False, potion=None):
|
||||
|
||||
|
||||
class Gen:
|
||||
def __init__(self, rng, items):
|
||||
def __init__(self, rng, items, bundles=None):
|
||||
self.rng = rng
|
||||
# Bundles come from their own generator, so the other stacks stay as they were.
|
||||
self.bundles = bundles or random.Random(0)
|
||||
self.items = items
|
||||
self.pool = ['minecraft:' + c for c in COMMON if 'minecraft:' + c in items]
|
||||
self.all = [k for k, v in items.items() if k != 'minecraft:air' and 'bundle' not in k
|
||||
@@ -108,7 +114,37 @@ class Gen:
|
||||
damage = rng.randrange(1, info['minecraft:max_damage'])
|
||||
count = 1 if top == 1 else rng.choice([1, 1, 2, top, top, top - 1, rng.randrange(1, top + 1)])
|
||||
potion = rng.choice(['minecraft:water', 'minecraft:awkward', 'minecraft:healing', 'minecraft:thick']) if item.endswith('potion') and rng.random() < 0.8 else None
|
||||
return snbt(item, max(1, count), damage, curse=rng.random() < 0.15 and 'equippable' in json.dumps(info), potion=potion)
|
||||
stack = snbt(item, max(1, count), damage, curse=rng.random() < 0.15 and 'equippable' in json.dumps(info), potion=potion)
|
||||
return self.bundle() if self.bundles.random() < 0.06 else stack
|
||||
|
||||
def bundle(self, room=64):
|
||||
"""A bundle, empty or holding up to its weight (in 64ths: 1 per item of a
|
||||
64-stack, 4 of a 16-stack, 64 of a tool), now and then another bundle."""
|
||||
return self.loaded_bundle(room)[0]
|
||||
|
||||
def loaded_bundle(self, room):
|
||||
rng = self.bundles
|
||||
color = rng.choice(['', '', 'red_', 'light_blue_'])
|
||||
inside, used = [], 0
|
||||
for _ in range(rng.choice([0, 0, 1, 1, 2, 3, 5])):
|
||||
if room == 64 and rng.random() < 0.1:
|
||||
# A nested bundle weighs its contents plus 1/16.
|
||||
if room - used > 4:
|
||||
nested, weight = self.loaded_bundle(min(room - used - 4, 16))
|
||||
inside.append(nested)
|
||||
used += weight + 4
|
||||
continue
|
||||
item = rng.choice(self.pool)
|
||||
top = self.items[item].get('minecraft:max_stack_size', 64)
|
||||
unit = 64 // top
|
||||
if unit > room - used or 'shulker_box' in item:
|
||||
continue
|
||||
most = min(top, (room - used) // unit)
|
||||
count = min(most, rng.choice([1, 1, 2, rng.randrange(1, most + 1)]))
|
||||
used += count * unit
|
||||
inside.append(snbt(item, count))
|
||||
body = ',components:{"minecraft:bundle_contents":[%s]}' % ','.join(inside) if inside else ''
|
||||
return '{id:"minecraft:%sbundle",count:1%s}' % (color, body), used
|
||||
|
||||
def slots(self, size, density):
|
||||
return [self.stack() if self.rng.random() < density else '-' for _ in range(size)]
|
||||
@@ -333,7 +369,7 @@ def main():
|
||||
vanilla.compile('ParityData', 'VanillaMenuOracle')
|
||||
items = json.loads((lib.RESOURCES / 'items.json').read_text())
|
||||
rng = random.Random(args.seed)
|
||||
g = Gen(rng, items)
|
||||
g = Gen(rng, items, random.Random(args.seed + 1))
|
||||
cases = []
|
||||
chosen = args.menus.split(',') if args.menus else list(MENUS)
|
||||
for n in range(args.cases):
|
||||
@@ -442,6 +478,9 @@ def main():
|
||||
'anvil': g.anvil_stack, 'smithing': g.smith_stack}.get(menu, g.stack)() if rng.random() < 0.5 else '-'
|
||||
if carried_food is not None:
|
||||
carried = carried_food
|
||||
elif menu in BUNDLE_MENUS and g.bundles.random() < 0.3:
|
||||
# A bundle in hand, emptied into slots and filled from them.
|
||||
carried = g.bundle()
|
||||
if menu == 'lectern':
|
||||
carried = '-'
|
||||
extra = {}
|
||||
|
||||
Reference in new issue
Block a user