mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 17:21:49 -10:00
Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d8dd2023dd | ||
|
|
d84d7a7e61 | ||
|
|
1cd0dd8c5d | ||
|
|
7f60a55ca3 | ||
|
|
16db2f44ae | ||
|
|
574e3ca567 | ||
|
|
2a2b4acb5c | ||
|
|
e1fff77a48 | ||
|
|
ba8bdc7179 | ||
|
|
4d9baf60d4 | ||
|
|
c19418b55d | ||
|
|
ee79b8f0cd | ||
|
|
8667cef3a1 | ||
|
|
fe1d544d02 | ||
|
|
0cc3b4a057 | ||
|
|
8ef418e223 | ||
|
|
b15e5e893e | ||
|
|
63baec3ac8 | ||
|
|
5d2f1804e7 | ||
|
|
c7abc8c718 |
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -8846,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;
|
||||
}
|
||||
@@ -8922,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,
|
||||
}
|
||||
|
||||
@@ -1422,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,
|
||||
@@ -1431,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);
|
||||
}
|
||||
}
|
||||
@@ -1822,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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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],
|
||||
|
||||
Reference in new issue
Block a user