mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-11 02:21:38 -10:00
Compare commits
326
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
906b9143f3 | ||
|
|
b880384691 | ||
|
|
77a34310b0 | ||
|
|
7627df18b0 | ||
|
|
ee66246131 | ||
|
|
766a5d4c87 | ||
|
|
04a26210e3 | ||
|
|
f37bcae92c | ||
|
|
04b53013c8 | ||
|
|
5070174aef | ||
|
|
5841fdc1e7 | ||
|
|
2093fb4d0d | ||
|
|
4e84f9e7fe | ||
|
|
6d45e61cdc | ||
|
|
960c03a4a1 | ||
|
|
7618cdca8e | ||
|
|
31c02b8f59 | ||
|
|
67c6dfbca9 | ||
|
|
7ef8abd367 | ||
|
|
63f20e8f30 | ||
|
|
f25fdf5828 | ||
|
|
87ef5ed8d9 | ||
|
|
31e1d6aedf | ||
|
|
cc4067a168 | ||
|
|
f7c9520898 | ||
|
|
417728114c | ||
|
|
1f2fd92867 | ||
|
|
b955f83e5b | ||
|
|
916dd0cebb | ||
|
|
b4977c3700 | ||
|
|
8cbfc508ae | ||
|
|
1843b792ff | ||
|
|
8b6513f833 | ||
|
|
3895a99900 | ||
|
|
70f3965c0a | ||
|
|
e51f313b02 | ||
|
|
339bda5ed2 | ||
|
|
0cc2963ccf | ||
|
|
1b27a37702 | ||
|
|
d4d833f199 | ||
|
|
1101314498 | ||
|
|
68b4c07179 | ||
|
|
395789e107 | ||
|
|
9223dc0e30 | ||
|
|
d9fe05af0d | ||
|
|
f02798aed5 | ||
|
|
900933aae2 | ||
|
|
2ef31a7618 | ||
|
|
cbf533a8ae | ||
|
|
ccc233474d | ||
|
|
1b16aaecbf | ||
|
|
2160d3e4d1 | ||
|
|
4536ec51ca | ||
|
|
72d1238876 | ||
|
|
214469a4bf | ||
|
|
b2bfa61fb1 | ||
|
|
5f4dbb058b | ||
|
|
292dfe14e1 | ||
|
|
506624b951 | ||
|
|
e7fb465789 | ||
|
|
31af1ed8ad | ||
|
|
af3a8d235b | ||
|
|
3c564f0e2b | ||
|
|
45c4e0d744 | ||
|
|
802056c402 | ||
|
|
fc604a958a | ||
|
|
8f98a65be5 | ||
|
|
75dc9db341 | ||
|
|
eb161b9c99 | ||
|
|
7687b2a619 | ||
|
|
1d50e158b9 | ||
|
|
58b5d93f8f | ||
|
|
9951ef958d | ||
|
|
8cb5c02d4b | ||
|
|
b75ffd9554 | ||
|
|
c7552a32e5 | ||
|
|
ff03932fb0 | ||
|
|
6a007e8e02 | ||
|
|
a4b71ca8ba | ||
|
|
1386a7fd1f | ||
|
|
5f029bb260 | ||
|
|
23a2b65c29 | ||
|
|
62165d135d | ||
|
|
2732d737a1 | ||
|
|
a1807b93a1 | ||
|
|
53c0b0694b | ||
|
|
cc3e92cadd | ||
|
|
27e0787a23 | ||
|
|
f312c30454 | ||
|
|
a79db77ac1 | ||
|
|
5fde1275b5 | ||
|
|
7ebdfbc3ee | ||
|
|
9e4e43a69b | ||
|
|
78f286e657 | ||
|
|
71e8f7c8cd | ||
|
|
91429d906a | ||
|
|
846e34c2eb | ||
|
|
a084ad2f35 | ||
|
|
10588c9b4c | ||
|
|
8ad2bee66a | ||
|
|
3e8dbbc2df | ||
|
|
bbe1ec5372 | ||
|
|
1f6cf55e1c | ||
|
|
fb9b1d3542 | ||
|
|
4d4aec1c7a | ||
|
|
a8a5843f59 | ||
|
|
63a2feba79 | ||
|
|
b228e6bef7 | ||
|
|
6a246dd81d | ||
|
|
4c49ec3523 | ||
|
|
2e6f3cd716 | ||
|
|
289e90d636 | ||
|
|
3275cd1602 | ||
|
|
a100957b30 | ||
|
|
3dcdbf8947 | ||
|
|
5da9065a8d | ||
|
|
a04de39fea | ||
|
|
76d95dbcc9 | ||
|
|
b9ad2da17e | ||
|
|
fab1e1a3e6 | ||
|
|
f6d8444698 | ||
|
|
b5a64cf86b | ||
|
|
0966aa0d3f | ||
|
|
9188926fff | ||
|
|
b920f8a432 | ||
|
|
16fb5cfed0 | ||
|
|
ef9f8e86d7 | ||
|
|
b91c2d4965 | ||
|
|
1cf4775de9 | ||
|
|
bdf670abdf | ||
|
|
327b811914 | ||
|
|
938dcd673c | ||
|
|
e1848e08a4 | ||
|
|
3661bfd7c2 | ||
|
|
a08205b8ec | ||
|
|
41fdaa3f0c | ||
|
|
2398317072 | ||
|
|
669e1a162a | ||
|
|
d645b72e8b | ||
|
|
3bc4e3035c | ||
|
|
15183261d9 | ||
|
|
adbc523e5c | ||
|
|
7f5b3462df | ||
|
|
c106b315ad | ||
|
|
1d9040c05a | ||
|
|
a35b729ccf | ||
|
|
27cded80d5 | ||
|
|
64e74bffd2 | ||
|
|
db26868e4f | ||
|
|
5cfbc7e3ea | ||
|
|
3f1f2fd3a8 | ||
|
|
ab7d9f4c50 | ||
|
|
4f1b40d9ca | ||
|
|
81a8d04a1f | ||
|
|
e1a6c5e363 | ||
|
|
89733192ca | ||
|
|
b973a2ed28 | ||
|
|
03a6255967 | ||
|
|
6539061751 | ||
|
|
7059358371 | ||
|
|
d05c54b48e | ||
|
|
9f9d226294 | ||
|
|
0a9ceaedb2 | ||
|
|
d64e5172b6 | ||
|
|
faa8141c2f | ||
|
|
2d5e7839c6 | ||
|
|
7a47e7a21f | ||
|
|
188817342b | ||
|
|
56c7214447 | ||
|
|
c1d32a0f93 | ||
|
|
861914b486 | ||
|
|
d1ca042ab8 | ||
|
|
1e66c75589 | ||
|
|
bd6bf2422b | ||
|
|
72879fc35b | ||
|
|
38b2b41791 | ||
|
|
5f40c762f6 | ||
|
|
46988764a5 | ||
|
|
2289b7a732 | ||
|
|
d68f241a0e | ||
|
|
176f5b3475 | ||
|
|
34d39e0da0 | ||
|
|
a45aa615ef | ||
|
|
c7ca421167 | ||
|
|
c1540bd026 | ||
|
|
8683a6b570 | ||
|
|
d88c0d9011 | ||
|
|
9f861b1624 | ||
|
|
bdf33dd2a3 | ||
|
|
016630efda | ||
|
|
3a953f4f9a | ||
|
|
91b743b549 | ||
|
|
59923087d5 | ||
|
|
763b6f6c8c | ||
|
|
88c287eb7e | ||
|
|
94f499bdbe | ||
|
|
f943e82857 | ||
|
|
6efab1f890 | ||
|
|
34b5ab04b0 | ||
|
|
a76ea00dde | ||
|
|
d7c7e06634 | ||
|
|
7f8a855d78 | ||
|
|
5f68b5da25 | ||
|
|
ca01155bce | ||
|
|
2740471e60 | ||
|
|
3f63aa1f5c | ||
|
|
a151569d72 | ||
|
|
bad4b0ef9a | ||
|
|
3a22ba32c2 | ||
|
|
eedbb1717c | ||
|
|
fad8e4e555 | ||
|
|
dbbe0e6bf5 | ||
|
|
5a78eee6ca | ||
|
|
e11233f2dc | ||
|
|
941e45b1ba | ||
|
|
916064d1ac | ||
|
|
4ef09a8e0d | ||
|
|
d9cf8f497c | ||
|
|
a321aa6c73 | ||
|
|
2490c57d3e | ||
|
|
e624d71b21 | ||
|
|
a190dbbc0d | ||
|
|
f779b500ec | ||
|
|
4ed92f7176 | ||
|
|
6e8edf7ad2 | ||
|
|
deb5860d9e | ||
|
|
6dc2d97de4 | ||
|
|
920e317f53 | ||
|
|
9286b28e24 | ||
|
|
df4b2599b4 | ||
|
|
8aea9cb52b | ||
|
|
8aaa338f08 | ||
|
|
79b62aaaea | ||
|
|
02b3b03618 | ||
|
|
0acd52bcb5 | ||
|
|
02f00f9b83 | ||
|
|
7d75faab81 | ||
|
|
0eddebf75e | ||
|
|
baab01b031 | ||
|
|
034c4b6e5c | ||
|
|
566027307e | ||
|
|
e8dbc42da7 | ||
|
|
adfc70d2f3 | ||
|
|
9da6aa25e5 | ||
|
|
acf9008e49 | ||
|
|
7e32b7d0da | ||
|
|
c14ab8d543 | ||
|
|
cd9441f90e | ||
|
|
ac296614af | ||
|
|
5328ec28b5 | ||
|
|
de6cc998df | ||
|
|
03b7d245b5 | ||
|
|
a6204f1f3a | ||
|
|
c2dd2d935d | ||
|
|
9f04ad5318 | ||
|
|
272fcfa34c | ||
|
|
9ec09e4714 | ||
|
|
997007191a | ||
|
|
9790d9d81c | ||
|
|
ae0ab270ff | ||
|
|
e865160f46 | ||
|
|
c7a360b9bb | ||
|
|
1a4e885e2c | ||
|
|
9e68efc05b | ||
|
|
b8ceaa3ed4 | ||
|
|
1414d6350f | ||
|
|
02a8a61de4 | ||
|
|
6a68afa6cc | ||
|
|
3c5c7d6077 | ||
|
|
d93a9a4be4 | ||
|
|
2b6aed3dc7 | ||
|
|
53d1d825ee | ||
|
|
6db95ffc1e | ||
|
|
cef8682971 | ||
|
|
67d694dd9c | ||
|
|
7b25b8ed80 | ||
|
|
c2e4f28c3f | ||
|
|
8e26753cda | ||
|
|
edfe18495e | ||
|
|
8ede2ff197 | ||
|
|
f1696089ef | ||
|
|
a4925a7954 | ||
|
|
085b4109e3 | ||
|
|
4a6942b8e9 | ||
|
|
1efd1c20c9 | ||
|
|
d8c00ad64e | ||
|
|
e2f472508d | ||
|
|
38060a67ba | ||
|
|
6c199da5f6 | ||
|
|
42b04e0238 | ||
|
|
b37b703f1c | ||
|
|
94cad99398 | ||
|
|
d7dc5628e2 | ||
|
|
8b45d15486 | ||
|
|
e8f93ffc67 | ||
|
|
2e0f86f312 | ||
|
|
167caf28d0 | ||
|
|
2485e58daa | ||
|
|
36bad2c06e | ||
|
|
7c7bca2e7b | ||
|
|
58552b8cb9 | ||
|
|
b064c12aca | ||
|
|
959a89ef67 | ||
|
|
1cf0e13b61 | ||
|
|
37541674a9 | ||
|
|
18e0854d00 | ||
|
|
04914f8c33 | ||
|
|
c2a2fe370f | ||
|
|
65d35f3261 | ||
|
|
b4dea2a473 | ||
|
|
bfa20f0413 | ||
|
|
05c7788a8f | ||
|
|
35ab895667 | ||
|
|
178a2bf8b4 | ||
|
|
673a2515e8 | ||
|
|
93d57cf58c | ||
|
|
4f935ae226 | ||
|
|
8eba763b33 | ||
|
|
85029c85e5 | ||
|
|
e08699893a | ||
|
|
b714d73e46 | ||
|
|
72cd6e42ee | ||
|
|
9baf83e129 | ||
|
|
2d04344265 | ||
|
|
226e365523 | ||
|
|
bcc1264dff |
No files matched your search
+12
-12
@@ -1,16 +1,16 @@
|
||||
# Changes to these files wait for Ludovic's review once branch protection requires code owners' reviews
|
||||
# Changes to these files wait for a council member's review (council poll P35, 2026-11-10) once branch protection requires code owners' reviews
|
||||
# (tools/ci/protect_pipeline.sh, docs/security.md). They are what builds, signs and ships releases, the code that
|
||||
# checks downloads before installing them (backend.rs downloads, installs and updates the launcher itself; links.json
|
||||
# says where from; the Cargo.toml files pick the crates that do it), and this file. Everything else merges as soon as
|
||||
# `verify` passes.
|
||||
/.github/ @ludovic111
|
||||
/tools/ci/ @ludovic111
|
||||
/tools/release_train.py @ludovic111
|
||||
/crates/horizon-release-sign/ @ludovic111
|
||||
/crates/horizon-server/src/update.rs @ludovic111
|
||||
/crates/horizon-server/Cargo.toml @ludovic111
|
||||
/launcher/src/signed.rs @ludovic111
|
||||
/launcher/src/backend.rs @ludovic111
|
||||
/launcher/links.json @ludovic111
|
||||
/launcher/Cargo.toml @ludovic111
|
||||
/packaging/ @ludovic111
|
||||
/.github/ @horizon-rust/council
|
||||
/tools/ci/ @horizon-rust/council
|
||||
/tools/release_train.py @horizon-rust/council
|
||||
/crates/horizon-release-sign/ @horizon-rust/council
|
||||
/crates/horizon-server/src/update.rs @horizon-rust/council
|
||||
/crates/horizon-server/Cargo.toml @horizon-rust/council
|
||||
/launcher/src/signed.rs @horizon-rust/council
|
||||
/launcher/src/backend.rs @horizon-rust/council
|
||||
/launcher/links.json @horizon-rust/council
|
||||
/launcher/Cargo.toml @horizon-rust/council
|
||||
/packaging/ @horizon-rust/council
|
||||
@@ -20,6 +20,13 @@ Feature → files → harness for the most-changed areas: [docs/architecture.md]
|
||||
is the GitHub account the PR is opened from (`gh api user --jq .login`). Issues say "Filed by", reviews
|
||||
"Review by". Every comment starts with `🤖 Agent:`. Write only as the person you work for: the lines on
|
||||
this page that name Ludovic are for his own agents (account `ludovic111`), never for anyone else's.
|
||||
- **Review only when asked, and decide.** Review a pull request only when its author or a maintainer asks.
|
||||
Asked by a maintainer to review open pull requests in general, take them one at a time, oldest first, skipping
|
||||
drafts, your person's own and those they already reviewed. Check that the change does what it says (bugs, edge
|
||||
cases), what it could break, whether tests cover it, and whether its scope and description hold up. Report only
|
||||
what you can point to with a `file:line`, each point marked Blocker (fix before merge), Important, Nit,
|
||||
Suggestion, Question or Praise. Submit a decision, never a plain comment: `gh pr review --approve` when nothing
|
||||
blocks, `--request-changes` when something does.
|
||||
- **Attribution.** Commits end with a `Co-Authored-By:` line for the agent that wrote them, and PR bodies
|
||||
with that agent's own "Generated with" line. Example, Ludovic's agents (Claude Code on `ludovic111`):
|
||||
first line `> 🤖 Opened by Ludovic's AI agent (Claude), not by Ludovic himself.`, commits
|
||||
|
||||
Generated
-1
@@ -1523,7 +1523,6 @@ dependencies = [
|
||||
"horizon-anticheat",
|
||||
"horizon-assets",
|
||||
"horizon-bench",
|
||||
"horizon-chat-filter",
|
||||
"horizon-client-core",
|
||||
"horizon-client-input",
|
||||
"horizon-client-render",
|
||||
|
||||
@@ -358,3 +358,29 @@ state of Starling's `crates/horizon-client-render/src/touch.rs` at the same comm
|
||||
|
||||
`crates/horizon-client-render/src/android_runtime.rs` and `android_runtime/tests/` are adapted from the
|
||||
input part of Starling's `crates/horizon-client-render/src/android_runtime.rs` at the same commit.
|
||||
|
||||
## PCG3D hash
|
||||
|
||||
`hash3` in `crates/horizon-client-render/src/hdr.wgsl`, the noise HDR10 output's dither is made from, is the
|
||||
`pcg3d` hash of Mark Jarzynski and Marc Olano ("Hash Functions for GPU Rendering", Journal of Computer Graphics
|
||||
Techniques 9(3), 2020), from <https://github.com/markjarzynski/PCG3D>. Copyright 2020 Mark Jarzynski & Marc Olano,
|
||||
licensed under MIT:
|
||||
|
||||
```
|
||||
Copyright 2020 Mark Jarzynski & Marc Olano
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
|
||||
associated documentation files (the "Software"), to deal in the Software without restriction,
|
||||
including without limitation the rights to use, copy, modify, merge, publish, distribute,
|
||||
sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial
|
||||
portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT
|
||||
NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES
|
||||
OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
||||
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
```
|
||||
@@ -7,7 +7,7 @@ rust-version.workspace = true
|
||||
description = "The dedicated server's chat filter: its [chat_filter] settings, the slur list's matcher and the mutes, with no game state (docs/chat-filter.md)"
|
||||
|
||||
[dependencies]
|
||||
# The moderation durations, shared with `/tempban`.
|
||||
# The hook the hub asks (`chat_guard`), and the moderation durations.
|
||||
horizon-server-files = { path = "../horizon-server-files" }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
//! The dedicated server's chat filter (docs/chat-filter.md), without the game: `server.toml`'s
|
||||
//! `[chat_filter]`, the matcher for the slur list (`data/slurs.txt`, or the owner's `words_file`), and
|
||||
//! the escalation (a refused message, then a mute, then a temporary ban), with the mutes kept in a file
|
||||
//! so that they outlive a restart. The server's hub (`horizon_client::lan`) asks it before it relays a
|
||||
//! player's text and carries out what it decides.
|
||||
//! so that they outlive a restart. The server's hub (`horizon_client::lan`) asks it, through the engine's
|
||||
//! `horizon_server_files::chat_guard` hook, before it relays a player's text, and carries out what it decides.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
@@ -10,6 +10,8 @@ use std::path::{Path, PathBuf};
|
||||
|
||||
mod normalise;
|
||||
|
||||
use horizon_server_files::chat_guard::{self, ChatGuard, Loaded, Place};
|
||||
pub use horizon_server_files::chat_guard::{Action, Mute};
|
||||
pub use horizon_server_files::duration::{
|
||||
DurationUnit, duration_parts, duration_text, now_millis, parse_duration,
|
||||
};
|
||||
@@ -221,49 +223,59 @@ fn fits(entry: &[Run], runs: &[Run], whole: bool) -> bool {
|
||||
.all(|(e, r)| e.letter == r.letter && r.count >= e.count)
|
||||
}
|
||||
|
||||
/// What a hit leads to.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Action {
|
||||
/// The text is refused, nothing more.
|
||||
Refused,
|
||||
/// Refused, and the player can't chat until then (epoch milliseconds).
|
||||
Muted { until: i64 },
|
||||
/// Refused, and the player is banned until then.
|
||||
Banned { until: i64 },
|
||||
/// A mute as the mutes file keeps it.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
struct SavedMute {
|
||||
uuid: String,
|
||||
name: String,
|
||||
until: Option<i64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
by: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
reason: Option<String>,
|
||||
}
|
||||
|
||||
impl Action {
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Action::Refused => "refused",
|
||||
Action::Muted { .. } => "muted",
|
||||
Action::Banned { .. } => "banned",
|
||||
impl From<SavedMute> for Mute {
|
||||
fn from(m: SavedMute) -> Self {
|
||||
let SavedMute {
|
||||
uuid,
|
||||
name,
|
||||
until,
|
||||
by,
|
||||
reason,
|
||||
} = m;
|
||||
Mute {
|
||||
uuid,
|
||||
name,
|
||||
until,
|
||||
by,
|
||||
reason,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Mute {
|
||||
pub uuid: String,
|
||||
pub name: String,
|
||||
/// Epoch milliseconds; `None`: until `/unmute`.
|
||||
pub until: Option<i64>,
|
||||
/// The operator who muted them (`/mute`, `/tempmute`); `None`: the filter.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub by: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub reason: Option<String>,
|
||||
}
|
||||
|
||||
impl Mute {
|
||||
pub fn active(&self, now: i64) -> bool {
|
||||
self.until.is_none_or(|until| until > now)
|
||||
impl From<Mute> for SavedMute {
|
||||
fn from(m: Mute) -> Self {
|
||||
let Mute {
|
||||
uuid,
|
||||
name,
|
||||
until,
|
||||
by,
|
||||
reason,
|
||||
} = m;
|
||||
SavedMute {
|
||||
uuid,
|
||||
name,
|
||||
until,
|
||||
by,
|
||||
reason,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default, Serialize, Deserialize)]
|
||||
struct MutesFile {
|
||||
mutes: Vec<Mute>,
|
||||
mutes: Vec<SavedMute>,
|
||||
}
|
||||
|
||||
/// One player's hits: how many, and when the last one stops counting.
|
||||
@@ -293,7 +305,7 @@ impl Guard {
|
||||
{
|
||||
for m in saved.mutes {
|
||||
if let Some(uuid) = parse_uuid(&m.uuid) {
|
||||
mutes.insert(uuid, m);
|
||||
mutes.insert(uuid, m.into());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -431,7 +443,7 @@ impl Guard {
|
||||
};
|
||||
let now = now_millis();
|
||||
self.mutes.retain(|_, m| m.active(now));
|
||||
let mut mutes: Vec<Mute> = self.mutes.values().cloned().collect();
|
||||
let mut mutes: Vec<SavedMute> = self.mutes.values().cloned().map(Into::into).collect();
|
||||
mutes.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
if let Ok(text) = serde_json::to_string_pretty(&MutesFile { mutes }) {
|
||||
let tmp = file.with_extension("json.tmp");
|
||||
@@ -442,6 +454,94 @@ impl Guard {
|
||||
}
|
||||
}
|
||||
|
||||
impl ChatGuard for Guard {
|
||||
fn matches(&self, text: &str) -> bool {
|
||||
self.filter.matches(text)
|
||||
}
|
||||
|
||||
fn muted(&self, uuid: u128, now: i64) -> Option<&Mute> {
|
||||
Guard::muted(self, uuid, now)
|
||||
}
|
||||
|
||||
fn mutes(&self, now: i64) -> Vec<&Mute> {
|
||||
Guard::mutes(self, now)
|
||||
}
|
||||
|
||||
fn mute(
|
||||
&mut self,
|
||||
uuid: u128,
|
||||
name: &str,
|
||||
until: Option<i64>,
|
||||
by: &str,
|
||||
reason: Option<String>,
|
||||
) {
|
||||
Guard::mute(self, uuid, name, until, by, reason);
|
||||
}
|
||||
|
||||
fn strike(&mut self, uuid: u128, name: &str, now: i64) -> (u32, Action) {
|
||||
Guard::strike(self, uuid, name, now)
|
||||
}
|
||||
|
||||
fn unmute(&mut self, name: &str) -> Option<String> {
|
||||
Guard::unmute(self, name)
|
||||
}
|
||||
|
||||
fn log(
|
||||
&mut self,
|
||||
now: i64,
|
||||
name: &str,
|
||||
uuid: u128,
|
||||
place: Place,
|
||||
count: u32,
|
||||
action: Action,
|
||||
text: &str,
|
||||
) -> Result<(), String> {
|
||||
let path = &self.config.log;
|
||||
if path.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let line = log_line(now, name, uuid, place.name(), count, action, text);
|
||||
append_log(Path::new(path), &line).map_err(|e| format!("{path}: {e}"))
|
||||
}
|
||||
}
|
||||
|
||||
/// `server.toml`'s `[chat_filter]` for the dedicated server ([`chat_guard::install`] it at startup): on
|
||||
/// by default, off (with an error logged) when the section is wrong. The mutes are kept in `dir`.
|
||||
pub fn load(server_toml: &str, dir: &Path) -> Loaded {
|
||||
let mut loaded = Loaded::default();
|
||||
let config = match Config::from_server_toml(server_toml) {
|
||||
Ok(config) => config,
|
||||
Err(e) => {
|
||||
loaded.log.push((
|
||||
"ERROR",
|
||||
format!("server.toml: {e}; the chat filter stays off"),
|
||||
));
|
||||
return loaded;
|
||||
}
|
||||
};
|
||||
if !config.enabled {
|
||||
return loaded;
|
||||
}
|
||||
let (guard, problem) = Guard::load(config, dir);
|
||||
if let Some(problem) = problem {
|
||||
loaded.log.push(("WARN", problem));
|
||||
}
|
||||
loaded.log.push((
|
||||
"INFO",
|
||||
format!(
|
||||
"Chat filter on: {} words (server.toml [chat_filter]; enabled = false turns it off)",
|
||||
guard.filter.len()
|
||||
),
|
||||
));
|
||||
loaded.guard = Some(Box::new(guard));
|
||||
loaded
|
||||
}
|
||||
|
||||
/// Gives the dedicated server this chat filter.
|
||||
pub fn install() {
|
||||
chat_guard::install(load);
|
||||
}
|
||||
|
||||
/// One line of `logs/chat-filter.log`, without its newline.
|
||||
pub fn log_line(
|
||||
now: i64,
|
||||
|
||||
@@ -74,11 +74,70 @@ pub trait ResourceSource {
|
||||
vec!["minecraft".into()]
|
||||
}
|
||||
}
|
||||
/// The bundled file a language reads, and whether it drops `ß`: a regional variant reads its
|
||||
/// language's file (Swiss German writes "ss" for "ß", as vanilla's de_ch does).
|
||||
fn bundled_code(code: &str) -> (&str, bool) {
|
||||
match code {
|
||||
"ko" => ("ko_kr", false),
|
||||
"hr" => ("hr_hr", false),
|
||||
"de_at" => ("de_de", false),
|
||||
"de_ch" => ("de_de", true),
|
||||
"fr_ca" | "fr_ch" => ("fr_fr", false),
|
||||
"es_ar" | "es_cl" | "es_ec" | "es_mx" | "es_uy" | "es_ve" => ("es_es", false),
|
||||
code => (code, false),
|
||||
}
|
||||
}
|
||||
|
||||
/// Lang files crates above the engine ship (`horizon_hooks::lang`), each a list of
|
||||
/// (code, JSON) pairs. The game's entry installs them once, before any language loads.
|
||||
static CRATE_FILES: std::sync::OnceLock<&'static [&'static [(&'static str, &'static str)]]> =
|
||||
std::sync::OnceLock::new();
|
||||
|
||||
/// Installs the crates' lang files (`horizon_hooks::lang::engine::all()`). Only the first call
|
||||
/// counts.
|
||||
pub fn install_crate_files(files: &'static [&'static [(&'static str, &'static str)]]) {
|
||||
let _ = CRATE_FILES.set(files);
|
||||
}
|
||||
|
||||
/// The crates' entries for language `code` (through [`bundled_code`]'s variants) that the
|
||||
/// engine's own `horizon` file for it doesn't have: on a key both have, the engine wins.
|
||||
fn read_crate_files(files: &[&[(&str, &str)]], code: &str, map: &mut HashMap<String, String>) {
|
||||
if files.is_empty() {
|
||||
return;
|
||||
}
|
||||
let (file, swiss) = bundled_code(code);
|
||||
let mut own = HashMap::new();
|
||||
if let Some(bytes) = Bundled.read(&format!("assets/horizon/lang/{code}.json")) {
|
||||
read_lang_file(&bytes, &mut own);
|
||||
}
|
||||
for (c, json) in files.iter().flat_map(|f| f.iter()) {
|
||||
if *c != file {
|
||||
continue;
|
||||
}
|
||||
let mut theirs = HashMap::new();
|
||||
if swiss {
|
||||
read_lang_file(json.replace('ß', "ss").as_bytes(), &mut theirs);
|
||||
} else {
|
||||
read_lang_file(json.as_bytes(), &mut theirs);
|
||||
}
|
||||
for (key, value) in theirs {
|
||||
if !own.contains_key(&key) {
|
||||
map.insert(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Horizon's own strings, compiled in so they need no prepared resources.
|
||||
struct Bundled;
|
||||
impl ResourceSource for Bundled {
|
||||
fn read(&self, path: &str) -> Option<Vec<u8>> {
|
||||
let body = match path {
|
||||
let code = path
|
||||
.strip_prefix("assets/horizon/lang/")?
|
||||
.strip_suffix(".json")?;
|
||||
let (file, swiss) = bundled_code(code);
|
||||
let path = format!("assets/horizon/lang/{file}.json");
|
||||
let body = match path.as_str() {
|
||||
"assets/horizon/lang/en_us.json" => {
|
||||
include_str!("../../horizon-client/assets/horizon/lang/en_us.json")
|
||||
}
|
||||
@@ -103,10 +162,10 @@ impl ResourceSource for Bundled {
|
||||
"assets/horizon/lang/tr_tr.json" => {
|
||||
include_str!("../../horizon-client/assets/horizon/lang/tr_tr.json")
|
||||
}
|
||||
"assets/horizon/lang/ko_kr.json" | "assets/horizon/lang/ko.json" => {
|
||||
"assets/horizon/lang/ko_kr.json" => {
|
||||
include_str!("../../horizon-client/assets/horizon/lang/ko_kr.json")
|
||||
}
|
||||
"assets/horizon/lang/hr_hr.json" | "assets/horizon/lang/hr.json" => {
|
||||
"assets/horizon/lang/hr_hr.json" => {
|
||||
include_str!("../../horizon-client/assets/horizon/lang/hr_hr.json")
|
||||
}
|
||||
"assets/horizon/lang/pt_pt.json" => {
|
||||
@@ -331,30 +390,20 @@ impl ResourceSource for Bundled {
|
||||
"assets/horizon/lang/vec_it.json" => {
|
||||
include_str!("../../horizon-client/assets/horizon/lang/vec_it.json")
|
||||
}
|
||||
"assets/horizon/lang/de_de.json" | "assets/horizon/lang/de_at.json" => {
|
||||
"assets/horizon/lang/de_de.json" => {
|
||||
include_str!("../../horizon-client/assets/horizon/lang/de_de.json")
|
||||
}
|
||||
// Swiss German writes "ss" for "ß", as vanilla's de_ch does.
|
||||
"assets/horizon/lang/de_ch.json" => {
|
||||
let de = include_str!("../../horizon-client/assets/horizon/lang/de_de.json");
|
||||
return Some(de.replace('ß', "ss").into_bytes());
|
||||
}
|
||||
"assets/horizon/lang/fr_fr.json"
|
||||
| "assets/horizon/lang/fr_ca.json"
|
||||
| "assets/horizon/lang/fr_ch.json" => {
|
||||
"assets/horizon/lang/fr_fr.json" => {
|
||||
include_str!("../../horizon-client/assets/horizon/lang/fr_fr.json")
|
||||
}
|
||||
"assets/horizon/lang/es_es.json"
|
||||
| "assets/horizon/lang/es_ar.json"
|
||||
| "assets/horizon/lang/es_cl.json"
|
||||
| "assets/horizon/lang/es_ec.json"
|
||||
| "assets/horizon/lang/es_mx.json"
|
||||
| "assets/horizon/lang/es_uy.json"
|
||||
| "assets/horizon/lang/es_ve.json" => {
|
||||
"assets/horizon/lang/es_es.json" => {
|
||||
include_str!("../../horizon-client/assets/horizon/lang/es_es.json")
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
if swiss {
|
||||
return Some(body.replace('ß', "ss").into_bytes());
|
||||
}
|
||||
Some(body.as_bytes().to_vec())
|
||||
}
|
||||
fn namespaces(&self) -> Vec<String> {
|
||||
@@ -438,6 +487,16 @@ impl Language {
|
||||
/// from every namespace and every source in priority order (later sources
|
||||
/// override).
|
||||
pub fn load(sources: &[&dyn ResourceSource], code: &str) -> Self {
|
||||
let crate_files = CRATE_FILES.get().copied().unwrap_or_default();
|
||||
Self::load_with(sources, code, crate_files)
|
||||
}
|
||||
|
||||
/// [`Language::load`] with these crates' lang files.
|
||||
fn load_with(
|
||||
sources: &[&dyn ResourceSource],
|
||||
code: &str,
|
||||
crate_files: &[&[(&str, &str)]],
|
||||
) -> Self {
|
||||
let mut map = HashMap::new();
|
||||
let mut codes = vec![DEFAULT];
|
||||
if code != DEFAULT {
|
||||
@@ -453,12 +512,14 @@ impl Language {
|
||||
sources.iter().flat_map(|s| s.namespaces()).collect();
|
||||
for c in codes {
|
||||
for namespace in &namespaces {
|
||||
for source in sources {
|
||||
let Some(bytes) = source.read(&format!("assets/{namespace}/lang/{c}.json"))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
read_lang_file(&bytes, &mut map);
|
||||
for (i, source) in sources.iter().enumerate() {
|
||||
if let Some(bytes) = source.read(&format!("assets/{namespace}/lang/{c}.json")) {
|
||||
read_lang_file(&bytes, &mut map);
|
||||
}
|
||||
// The crates' text right after the engine's own (`Bundled`, the first source).
|
||||
if i == 0 && namespace == "horizon" {
|
||||
read_crate_files(crate_files, c, &mut map);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -688,6 +749,43 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_crates_text_loads_after_the_engines_and_before_packs() {
|
||||
static CRATE: &[(&str, &str)] = &[
|
||||
(
|
||||
"en_us",
|
||||
r#"{"test.crate.hello": "Hello", "horizon.osk.close": "Shut", "test.crate.packed": "Crate"}"#,
|
||||
),
|
||||
(
|
||||
"de_de",
|
||||
r#"{"test.crate.hello": "Grüß dich", "horizon.osk.close": "Zu"}"#,
|
||||
),
|
||||
("fr_fr", r#"{"test.crate.hello": "Bonjour"}"#),
|
||||
];
|
||||
let pack = Mem(HashMap::from([(
|
||||
"assets/horizon/lang/en_us.json".to_owned(),
|
||||
br#"{"test.crate.packed": "Pack"}"#.to_vec(),
|
||||
)]));
|
||||
let load = |code| Language::load_with(&[&pack], code, &[CRATE]);
|
||||
let en = load("en_us");
|
||||
assert_eq!(en.get("test.crate.hello"), Some("Hello"));
|
||||
// The engine's own file wins on a key both have; a pack wins over both.
|
||||
assert_eq!(en.get("horizon.osk.close"), Some("Close"));
|
||||
assert_eq!(en.get("test.crate.packed"), Some("Pack"));
|
||||
let de = load("de_de");
|
||||
assert_eq!(de.get("test.crate.hello"), Some("Grüß dich"));
|
||||
assert_eq!(de.get("horizon.osk.close"), Some("Schließen"));
|
||||
// Regional variants read their language's file, as the engine's do.
|
||||
assert_eq!(load("de_ch").get("test.crate.hello"), Some("Grüss dich"));
|
||||
assert_eq!(load("fr_ca").get("test.crate.hello"), Some("Bonjour"));
|
||||
// A language the crate lacks falls back to its English.
|
||||
assert_eq!(load("pl_pl").get("test.crate.hello"), Some("Hello"));
|
||||
// Without crate files, nothing changes.
|
||||
let plain = Language::load_with(&[&pack], "de_de", &[]);
|
||||
assert_eq!(plain.get("test.crate.hello"), None);
|
||||
assert_eq!(plain.len() + 1, de.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_scoped_language_is_the_calling_threads_and_reports_what_it_lacks() {
|
||||
let empty = Mem(HashMap::new());
|
||||
@@ -739,6 +837,20 @@ mod tests {
|
||||
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../horizon-client/assets/horizon/lang")
|
||||
}
|
||||
/// The crates whose own lang files the game loads (`crates/<name>/assets/lang`,
|
||||
/// registered through `horizon_hooks::lang`), with that folder.
|
||||
fn crate_lang_dirs() -> Vec<(String, std::path::PathBuf)> {
|
||||
let crates = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("..");
|
||||
let mut dirs: Vec<_> = std::fs::read_dir(&crates)
|
||||
.unwrap()
|
||||
.flatten()
|
||||
.map(|e| (e.file_name().into_string().unwrap(), e.path()))
|
||||
.map(|(name, path)| (name, path.join("assets/lang")))
|
||||
.filter(|(_, dir)| dir.join("en_us.json").is_file())
|
||||
.collect();
|
||||
dirs.sort();
|
||||
dirs
|
||||
}
|
||||
/// The keys of a flat bundled file in file order, duplicates included (a map
|
||||
/// would keep only the last of two equal keys). One `"key": "value"` per line.
|
||||
fn file_keys(body: &str) -> Vec<String> {
|
||||
@@ -776,11 +888,33 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn bundled_files_are_served_and_have_no_stale_or_repeated_keys() {
|
||||
let dir = bundled_dir();
|
||||
let engine = check_lang_dir(&bundled_dir(), true);
|
||||
assert!(
|
||||
engine.1 >= 7,
|
||||
"found {} bundled lang files in {}",
|
||||
engine.1,
|
||||
bundled_dir().display()
|
||||
);
|
||||
// A crate's files pass the same checks. Its keys are its own: on a key the engine's
|
||||
// files also have, the engine's text would win and hide the crate's.
|
||||
for (name, dir) in crate_lang_dirs() {
|
||||
let (en, _) = check_lang_dir(&dir, false);
|
||||
for key in en.keys() {
|
||||
assert!(
|
||||
!engine.0.contains_key(key),
|
||||
"{name}: {key} is also the engine's"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The checks every lang folder passes: flat maps, no repeated key, no key en_us lacks, the
|
||||
/// same placeholders as English, no empty text. Its en_us and how many files it has.
|
||||
fn check_lang_dir(dir: &std::path::Path, bundled: bool) -> (HashMap<String, String>, usize) {
|
||||
let en_body = std::fs::read_to_string(dir.join("en_us.json")).unwrap();
|
||||
let en: HashMap<String, String> = serde_json::from_str(&en_body).unwrap();
|
||||
let mut seen = 0;
|
||||
for entry in std::fs::read_dir(&dir).unwrap().flatten() {
|
||||
for entry in std::fs::read_dir(dir).unwrap().flatten() {
|
||||
let name = entry.file_name().into_string().unwrap();
|
||||
let Some(code) = name.strip_suffix(".json") else {
|
||||
continue;
|
||||
@@ -789,7 +923,7 @@ mod tests {
|
||||
// A file that Bundled::read does not name is never loaded.
|
||||
let path = format!("assets/horizon/lang/{name}");
|
||||
assert!(
|
||||
Bundled.read(&path).is_some(),
|
||||
!bundled || Bundled.read(&path).is_some(),
|
||||
"{name} is not served by Bundled"
|
||||
);
|
||||
let body = std::fs::read_to_string(entry.path()).unwrap();
|
||||
@@ -815,11 +949,7 @@ mod tests {
|
||||
assert!(!value.trim().is_empty(), "{code}: {key} is empty");
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
seen >= 7,
|
||||
"found {seen} bundled lang files in {}",
|
||||
dir.display()
|
||||
);
|
||||
(en, seen)
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -834,10 +964,10 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
let en: HashMap<String, String> = serde_json::from_str(
|
||||
&std::fs::read_to_string(bundled_dir().join("en_us.json")).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let english = |dir: &std::path::Path| -> HashMap<String, String> {
|
||||
serde_json::from_str(&std::fs::read_to_string(dir.join("en_us.json")).unwrap()).unwrap()
|
||||
};
|
||||
let mut en = english(&bundled_dir());
|
||||
let crates = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("..");
|
||||
let mut files = Vec::new();
|
||||
for krate in [
|
||||
@@ -847,6 +977,11 @@ mod tests {
|
||||
] {
|
||||
rust_files(&crates.join(krate).join("src"), &mut files);
|
||||
}
|
||||
// A crate that ships lang files names its keys in its own sources.
|
||||
for (name, dir) in crate_lang_dirs() {
|
||||
en.extend(english(&dir));
|
||||
rust_files(&crates.join(name).join("src"), &mut files);
|
||||
}
|
||||
assert!(files.len() > 100, "found only {} sources", files.len());
|
||||
let mut found = 0;
|
||||
for file in files {
|
||||
|
||||
@@ -36,8 +36,6 @@ horizon-server-files = { path = "../horizon-server-files" }
|
||||
getrandom = "0.3"
|
||||
# The dedicated server's anticheat, off unless server.toml's [anticheat] turns it on (docs/anticheat.md).
|
||||
horizon-anticheat = { path = "../horizon-anticheat" }
|
||||
# The dedicated server's chat filter: [chat_filter], the slur list, mutes (docs/chat-filter.md).
|
||||
horizon-chat-filter = { path = "../horizon-chat-filter" }
|
||||
# Shader packs: reading them and turning their GLSL into naga modules.
|
||||
horizon-shaders = { path = "../horizon-shaders" }
|
||||
# Horizon's own shader packs: the format, the stages and the check (docs/shaders.md).
|
||||
|
||||
@@ -7,7 +7,7 @@ use serde_json::Value;
|
||||
use std::{
|
||||
collections::{BTreeMap, HashMap},
|
||||
path::Path,
|
||||
sync::Arc,
|
||||
sync::{Arc, OnceLock},
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -398,7 +398,37 @@ impl Biomes {
|
||||
pub fn contains(&self, name: &str) -> bool {
|
||||
self.index.contains_key(name)
|
||||
}
|
||||
/// The biome at `id`, clamped to the last entry when `id` is past the
|
||||
/// table (a stale column from an older registry keeps its id).
|
||||
///
|
||||
/// When the table is empty (a resource pack with no biome files), returns
|
||||
/// a shared default plains biome instead of panicking on `list.len() - 1`.
|
||||
pub fn get(&self, id: u16) -> &BiomeInfo {
|
||||
if self.list.is_empty() {
|
||||
static DEFAULT: OnceLock<BiomeInfo> = OnceLock::new();
|
||||
return DEFAULT.get_or_init(|| BiomeInfo {
|
||||
spawns: HashMap::new(),
|
||||
spawn_costs: HashMap::new(),
|
||||
creature_probability: 0.0,
|
||||
increased_fire_burnout: false,
|
||||
name: "minecraft:plains".to_owned(),
|
||||
temperature: 0.8,
|
||||
frozen: false,
|
||||
downfall: 0.4,
|
||||
precipitation: false,
|
||||
grass: 0x7f_c0_55,
|
||||
modifier: GrassModifier::None,
|
||||
foliage: 0x6a_ab_37,
|
||||
dry_foliage: 0x79_75_54,
|
||||
water: 0x3f_76_e4,
|
||||
sky: None,
|
||||
fog: None,
|
||||
water_fog: 0x3f_76_e4,
|
||||
ambient_particles: Vec::new(),
|
||||
features: Vec::new(),
|
||||
attributes: Default::default(),
|
||||
});
|
||||
}
|
||||
&self.list[usize::from(id).min(self.list.len() - 1)]
|
||||
}
|
||||
/// The name of biome `id`, plains (as for an unknown name) when the id is
|
||||
@@ -795,4 +825,34 @@ mod blend_tests {
|
||||
}
|
||||
assert!(at(-2.0) > 0.0 && at(-2.0) < 0.5);
|
||||
}
|
||||
|
||||
/// An empty biome table (a resource pack with no biome files) must not
|
||||
/// panic on `get`: before the fix, `list.len() - 1` underflowed.
|
||||
#[test]
|
||||
fn get_on_empty_biome_table_returns_default_instead_of_panicking() {
|
||||
use std::sync::Arc;
|
||||
|
||||
let empty = Biomes {
|
||||
builtin: Arc::new(Vec::new()),
|
||||
index: HashMap::new(),
|
||||
list: Vec::new(),
|
||||
info_noise: SimplexNoise::with_skipped_offsets(&mut LegacyRandom::new(2345), true),
|
||||
colormaps: Arc::new(Colormaps {
|
||||
grass: Vec::new(),
|
||||
foliage: Vec::new(),
|
||||
dry: Vec::new(),
|
||||
}),
|
||||
packs: BTreeMap::new(),
|
||||
};
|
||||
// Any id returns the shared default plains biome, no panic.
|
||||
let b = empty.get(0);
|
||||
assert_eq!(b.name, "minecraft:plains");
|
||||
let b = empty.get(65535);
|
||||
assert_eq!(b.name, "minecraft:plains");
|
||||
// Colours and temperature come from the default.
|
||||
assert_eq!(b.temperature, 0.8);
|
||||
assert_eq!(b.grass, 0x7f_c0_55);
|
||||
assert_eq!(b.foliage, 0x6a_ab_37);
|
||||
assert_eq!(b.water, 0x3f_76_e4);
|
||||
}
|
||||
}
|
||||
@@ -84,6 +84,71 @@ pub fn contents_effects(stack: &ItemStack) -> Vec<(&'static str, i32, i32)> {
|
||||
)
|
||||
.collect()
|
||||
}
|
||||
/// `PotionContents.getAllEffects`, retaining each custom instance's flags.
|
||||
pub fn contents_instances(stack: &ItemStack) -> Vec<crate::effects::EffectInstance> {
|
||||
use horizon_client_core::nbt_value::{number, text};
|
||||
use horizon_world::nbt::Tag;
|
||||
let mut effects: Vec<_> = stack
|
||||
.potion()
|
||||
.and_then(potion)
|
||||
.map_or(&[][..], |p| p.effects)
|
||||
.iter()
|
||||
.map(|(id, duration, amp)| crate::effects::EffectInstance::new(id, *duration, *amp))
|
||||
.collect();
|
||||
if let Some((_, Tag::List(custom))) = stack
|
||||
.data()
|
||||
.and_then(|d| d.potion_extra.iter().find(|(k, _)| k == "custom_effects"))
|
||||
{
|
||||
for e in custom {
|
||||
let Some(def) = text(e.get("id")).and_then(|id| effect(&id)) else {
|
||||
continue;
|
||||
};
|
||||
let mut instance = crate::effects::EffectInstance::new(
|
||||
def.id,
|
||||
number(e.get("duration")).unwrap_or(0) as i32,
|
||||
number(e.get("amplifier")).unwrap_or(0).clamp(0, 255) as i32,
|
||||
);
|
||||
instance.ambient = number(e.get("ambient")).unwrap_or(0) != 0;
|
||||
instance.visible = number(e.get("show_particles")) != Some(0);
|
||||
effects.push(instance);
|
||||
}
|
||||
}
|
||||
effects
|
||||
}
|
||||
/// `PotionContents.getColor`: an explicit colour wins; otherwise mix only
|
||||
/// visible base and custom effects, with their amplifier weights.
|
||||
pub fn contents_color(stack: &ItemStack) -> u32 {
|
||||
use horizon_client_core::nbt_value::number;
|
||||
if let Some(color) = stack
|
||||
.data()
|
||||
.and_then(|d| d.potion_extra.iter().find(|(k, _)| k == "custom_color"))
|
||||
.and_then(|(_, v)| number(Some(v)))
|
||||
{
|
||||
return color as u32;
|
||||
}
|
||||
0xFF00_0000
|
||||
| horizon_client_core::potions::effects_color(
|
||||
contents_instances(stack)
|
||||
.iter()
|
||||
.filter(|e| e.visible)
|
||||
.map(|e| (e.id, e.amplifier)),
|
||||
)
|
||||
}
|
||||
/// `PotionContents.equals(EMPTY)`: colour and custom name also make contents
|
||||
/// nonempty, even when there is no registered potion or custom effect.
|
||||
pub fn contents_empty(stack: &ItemStack) -> bool {
|
||||
use horizon_world::nbt::Tag;
|
||||
stack.potion().is_none()
|
||||
&& stack.data().is_none_or(|d| {
|
||||
d.potion_extra
|
||||
.iter()
|
||||
.all(|(key, value)| match key.as_str() {
|
||||
"custom_effects" => matches!(value, Tag::List(list) if list.is_empty()),
|
||||
"custom_color" | "custom_name" => false,
|
||||
_ => true,
|
||||
})
|
||||
})
|
||||
}
|
||||
/// Roman amplifier suffix (`potion.potency.N`) and duration lines of a potion.
|
||||
pub fn tooltip(
|
||||
potion: Option<&str>,
|
||||
@@ -706,4 +771,39 @@ mod tests {
|
||||
let lines = effect_lines([("minecraft:resistance", 15, 0)], 1.0, &translate);
|
||||
assert_eq!(lines[0].0, "resistance");
|
||||
}
|
||||
#[test]
|
||||
fn custom_contents_color_and_empty_semantics() {
|
||||
use horizon_world::nbt::Tag;
|
||||
let mut stack = ItemStack::new("minecraft:tipped_arrow", 1);
|
||||
assert!(contents_empty(&stack));
|
||||
stack
|
||||
.data_mut()
|
||||
.potion_extra
|
||||
.push(("custom_effects".into(), Tag::List(Vec::new())));
|
||||
assert!(contents_empty(&stack));
|
||||
stack.data_mut().potion_extra = vec![(
|
||||
"custom_effects".into(),
|
||||
Tag::List(vec![
|
||||
Tag::compound([
|
||||
("id", Tag::String("minecraft:glowing".into())),
|
||||
("amplifier", Tag::Int(1)),
|
||||
]),
|
||||
Tag::compound([
|
||||
("id", Tag::String("minecraft:poison".into())),
|
||||
("show_particles", Tag::Byte(0)),
|
||||
]),
|
||||
]),
|
||||
)];
|
||||
assert!(!contents_empty(&stack));
|
||||
// Glowing's colour; hidden poison contributes no weight.
|
||||
assert_eq!(contents_color(&stack), 0xFF94_A061);
|
||||
stack
|
||||
.data_mut()
|
||||
.potion_extra
|
||||
.push(("custom_color".into(), Tag::Int(123456)));
|
||||
assert_eq!(contents_color(&stack), 123456);
|
||||
stack.data_mut().potion_extra = vec![("custom_name".into(), Tag::String("test".into()))];
|
||||
assert!(!contents_empty(&stack));
|
||||
assert_eq!(contents_color(&stack), 0xFF38_5DC6);
|
||||
}
|
||||
}
|
||||
@@ -539,6 +539,7 @@ pub struct Data {
|
||||
pub current: Option<Info>,
|
||||
pub travel: i32,
|
||||
pub selector: Selector,
|
||||
pub reload_particle: bool,
|
||||
}
|
||||
/// Which sculk block entity this is.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Data packs (`<world>/datapacks`: folders and zips with `pack.mcmeta`) and
|
||||
//! the parts of them commands use: `.mcfunction` functions (with macros and
|
||||
//! line continuation), function tags and predicates.
|
||||
mod slot_sources;
|
||||
use crate::zip::Zip;
|
||||
use horizon_world::{
|
||||
nbt::Tag,
|
||||
@@ -663,6 +664,8 @@ pub struct DataStore {
|
||||
pub item_modifiers: BTreeMap<String, serde_json::Value>,
|
||||
/// `slot_source` definitions, evaluated against a command's live accessor.
|
||||
pub slot_sources: BTreeMap<String, serde_json::Value>,
|
||||
/// Slot-source tags belong to the same loaded snapshot as their definitions.
|
||||
pub slot_source_tags: BTreeMap<String, Vec<(String, bool)>>,
|
||||
/// The content tables' raw files from the packs after the built-in one:
|
||||
/// `recipe/`, `recipe_filter/`, `loot_table/`, `advancement/`, `dialog/`, `damage_type/`, `data_maps/`, `enchantment/` and `tags/…/` files, by
|
||||
/// `<kind-dir>/<ns>:<path>`, each id's files in pack order. The tables
|
||||
@@ -759,6 +762,16 @@ impl DataStore {
|
||||
&& let Ok(v) = serde_json::from_slice(&bytes)
|
||||
{
|
||||
s.item_modifiers.insert(id, v);
|
||||
} else if let Some(id) = resource_id(&f, "tags/slot_source", ".json")
|
||||
&& let Some(bytes) = pack.read(&f)
|
||||
&& let Ok(v) = serde_json::from_slice::<serde_json::Value>(&bytes)
|
||||
{
|
||||
let entries = tag_entries(&v);
|
||||
let slot = s.slot_source_tags.entry(id).or_default();
|
||||
if v["replace"].as_bool() == Some(true) {
|
||||
slot.clear();
|
||||
}
|
||||
slot.extend(entries);
|
||||
} else if let Some(id) = resource_id(&f, "slot_source", ".json")
|
||||
&& let Some(bytes) = pack.read(&f)
|
||||
&& let Ok(v) = serde_json::from_slice(&bytes)
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
//! Slot-source holder-set tags share DataStore's pack ordering and snapshot.
|
||||
use super::DataStore;
|
||||
use std::collections::BTreeSet;
|
||||
impl DataStore {
|
||||
/// TagLoader's required-member validation and first-seen membership order.
|
||||
pub fn slot_source_tag(&self, id: &str) -> Result<Vec<String>, String> {
|
||||
fn visit(
|
||||
store: &DataStore,
|
||||
id: &str,
|
||||
active: &mut BTreeSet<String>,
|
||||
) -> Result<Vec<String>, String> {
|
||||
if !active.insert(id.to_owned()) {
|
||||
return Err(format!("Recursive slot-source tag #{id}"));
|
||||
}
|
||||
let result = (|| {
|
||||
let entries = store
|
||||
.slot_source_tags
|
||||
.get(id)
|
||||
.ok_or_else(|| format!("Unknown slot-source tag #{id}"))?;
|
||||
let mut out = Vec::new();
|
||||
for (entry, required) in entries {
|
||||
let values = if let Some(tag) = entry.strip_prefix('#') {
|
||||
visit(store, tag, active)
|
||||
} else if store.slot_sources.contains_key(entry) {
|
||||
Ok(vec![entry.clone()])
|
||||
} else {
|
||||
Err(format!("Unknown slot source {entry}"))
|
||||
};
|
||||
match values {
|
||||
Ok(values) => {
|
||||
for value in values {
|
||||
if !out.contains(&value) {
|
||||
out.push(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(error) if *required => return Err(error),
|
||||
Err(_) => {}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
})();
|
||||
active.remove(id);
|
||||
result
|
||||
}
|
||||
visit(self, id, &mut BTreeSet::new())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn holder_set_tags_keep_order_required_members_and_snapshot_authority() {
|
||||
let mut store = DataStore::default();
|
||||
for name in ["t:a", "t:b"] {
|
||||
store.slot_sources.insert(
|
||||
name.into(),
|
||||
serde_json::json!({"type":"slot_range","slots":"container.0"}),
|
||||
);
|
||||
}
|
||||
store.slot_source_tags.insert(
|
||||
"t:child".into(),
|
||||
vec![("t:b".into(), true), ("t:a".into(), true)],
|
||||
);
|
||||
store.slot_source_tags.insert(
|
||||
"t:all".into(),
|
||||
vec![
|
||||
("t:a".into(), true),
|
||||
("#t:child".into(), true),
|
||||
("t:absent".into(), false),
|
||||
],
|
||||
);
|
||||
assert_eq!(store.slot_source_tag("t:all").unwrap(), ["t:a", "t:b"]);
|
||||
store
|
||||
.slot_source_tags
|
||||
.get_mut("t:all")
|
||||
.unwrap()
|
||||
.push(("t:absent".into(), true));
|
||||
assert!(store.slot_source_tag("t:all").is_err());
|
||||
store
|
||||
.slot_source_tags
|
||||
.insert("t:cycle".into(), vec![("#t:cycle".into(), true)]);
|
||||
assert!(store.slot_source_tag("t:cycle").is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
//! A bind group's validity depends on the identity of its buffers and the
|
||||
//! bound slice, not on the bytes written into those buffers.
|
||||
|
||||
pub(super) struct BindingKey<B, const N: usize> {
|
||||
pub first: u64,
|
||||
pub count: u64,
|
||||
pub buffers: [B; N],
|
||||
}
|
||||
|
||||
impl<B: PartialEq, const N: usize> BindingKey<B, N> {
|
||||
pub fn matches(&self, first: u64, count: u64, buffers: [&B; N]) -> bool {
|
||||
self.first == first
|
||||
&& self.count == count
|
||||
&& self
|
||||
.buffers
|
||||
.iter()
|
||||
.zip(buffers)
|
||||
.all(|(old, new)| old == new)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn changes_to_any_resource_or_range_invalidate_the_binding() {
|
||||
let key = BindingKey {
|
||||
first: 16,
|
||||
count: 8,
|
||||
buffers: [1u64, 2, 3, 4],
|
||||
};
|
||||
assert!(key.matches(16, 8, [&1, &2, &3, &4]));
|
||||
assert!(!key.matches(0, 8, [&1, &2, &3, &4]));
|
||||
assert!(!key.matches(16, 9, [&1, &2, &3, &4]));
|
||||
for i in 0..4 {
|
||||
let mut resources = [1u64, 2, 3, 4];
|
||||
resources[i] += 100;
|
||||
assert!(!key.matches(16, 8, resources.each_ref()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,10 @@
|
||||
//! start, and the run is drawn indirectly with the count the copy reached.
|
||||
use super::SolidPipeline;
|
||||
use super::arena::GRAIN;
|
||||
use smallvec::SmallVec;
|
||||
|
||||
#[path = "binding_cache.rs"]
|
||||
mod binding_cache;
|
||||
use binding_cache::BindingKey;
|
||||
|
||||
/// One section draw for `flatten.wgsl`: index count, first sorted index (or
|
||||
/// `QUADS` for a solid draw; phase 2: its entry in phase 2's draws), base
|
||||
@@ -85,6 +88,11 @@ pub struct Call {
|
||||
pub count: u32,
|
||||
}
|
||||
|
||||
struct CachedGroup<const N: usize> {
|
||||
key: BindingKey<wgpu::Buffer, N>,
|
||||
group: wgpu::BindGroup,
|
||||
}
|
||||
|
||||
pub struct Flat {
|
||||
/// The solid draws in one pass (`fs_solid`), where the GPU doesn't hide pixels
|
||||
/// by tiles (`split_solid` in `Renderer::draw_frame`: elsewhere than Metal).
|
||||
@@ -109,8 +117,9 @@ pub struct Flat {
|
||||
phase2_call_data: Vec<[u32; 5]>,
|
||||
/// GPU culling's phase 2 uses indirect draws; flat draws without culling do not.
|
||||
indirect_execution: bool,
|
||||
/// The copy of phase 2's draws, and its ranges.
|
||||
phase2_group: Option<(wgpu::BindGroup, u32)>,
|
||||
/// Bindings reused while the range and all GPU buffers are unchanged.
|
||||
phase2_group: Option<CachedGroup<4>>,
|
||||
groups: Vec<CachedGroup<3>>,
|
||||
/// The layout of a page's bind group 1 (shader packs' terrain pipelines share it).
|
||||
pub(super) draw_layout: wgpu::BindGroupLayout,
|
||||
blocks_per_page: u64,
|
||||
@@ -515,6 +524,7 @@ impl Flat {
|
||||
phase2_call_data: Vec::new(),
|
||||
indirect_execution,
|
||||
phase2_group: None,
|
||||
groups: Vec::new(),
|
||||
draw_layout,
|
||||
blocks_per_page: page_vertices.div_ceil(GRAIN),
|
||||
translucent_blocks_per_page: page_vertices.div_ceil(GRAIN),
|
||||
@@ -626,6 +636,8 @@ impl Flat {
|
||||
}
|
||||
pub fn clear(&mut self) {
|
||||
self.pages.clear();
|
||||
self.groups.clear();
|
||||
self.phase2_group = None;
|
||||
self.free_ids.clear();
|
||||
self.next_id = 0;
|
||||
}
|
||||
@@ -665,7 +677,6 @@ impl Flat {
|
||||
sorted_page: impl Fn(u32) -> wgpu::Buffer,
|
||||
timing: Option<&mut super::pack_timing::PassTiming>,
|
||||
) -> bool {
|
||||
self.phase2_group = None;
|
||||
let phase2 = phase2.filter(|_| self.indirect_execution);
|
||||
let phase1_draws = if phase2.is_some() {
|
||||
phase1_draws
|
||||
@@ -729,6 +740,9 @@ impl Flat {
|
||||
self.shadow_calls = shadow_calls;
|
||||
self.pass_calls = pass_calls;
|
||||
if total == 0 || total * 4 > device.limits().max_storage_buffer_binding_size {
|
||||
// Do not retain bindings for a frame that cannot use flat draws.
|
||||
self.phase2_group = None;
|
||||
self.groups.clear();
|
||||
self.calls.clear();
|
||||
self.phase1_calls = 0;
|
||||
self.solid_calls = 0;
|
||||
@@ -802,39 +816,63 @@ impl Flat {
|
||||
phase2,
|
||||
self.segments.iter().find(|(s, ..)| *s == Source::Seen),
|
||||
) {
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Flatten phase 2 section draws"),
|
||||
layout: &self.seen_layout,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: ranges_at(first, count),
|
||||
let buffers = [&self.ranges, &self.indices, phase2, &self.phase2_calls];
|
||||
if !self
|
||||
.phase2_group
|
||||
.as_ref()
|
||||
.is_some_and(|cached| cached.key.matches(first, count, buffers))
|
||||
{
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Flatten phase 2 section draws"),
|
||||
layout: &self.seen_layout,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: ranges_at(first, count),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: self.indices.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 3,
|
||||
resource: phase2.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 4,
|
||||
resource: self.phase2_calls.as_entire_binding(),
|
||||
},
|
||||
],
|
||||
});
|
||||
self.phase2_group = Some(CachedGroup {
|
||||
key: BindingKey {
|
||||
first,
|
||||
count,
|
||||
buffers: buffers.map(|b| b.clone()),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: self.indices.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 3,
|
||||
resource: phase2.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 4,
|
||||
resource: self.phase2_calls.as_entire_binding(),
|
||||
},
|
||||
],
|
||||
});
|
||||
self.phase2_group = Some((group, count as u32));
|
||||
group,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
self.phase2_group = None;
|
||||
}
|
||||
let groups: SmallVec<[(wgpu::BindGroup, u32); 8]> = self
|
||||
.segments
|
||||
.iter()
|
||||
.filter_map(|&(source, first, count)| {
|
||||
let sorted = match source {
|
||||
Source::Quads => None,
|
||||
Source::Seen => return None,
|
||||
Source::Sorted(page) => Some(sorted_page(page)),
|
||||
};
|
||||
let mut groups = 0;
|
||||
for &(source, first, count) in &self.segments {
|
||||
let sorted = match source {
|
||||
Source::Quads => None,
|
||||
Source::Seen => continue,
|
||||
Source::Sorted(page) => Some(sorted_page(page)),
|
||||
};
|
||||
let buffers = [
|
||||
&self.ranges,
|
||||
sorted.as_ref().unwrap_or(&self.empty),
|
||||
&self.indices,
|
||||
];
|
||||
if self
|
||||
.groups
|
||||
.get(groups)
|
||||
.is_none_or(|cached| !cached.key.matches(first, count, buffers))
|
||||
{
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Flatten section draws"),
|
||||
layout: &self.compute_layout,
|
||||
@@ -845,7 +883,7 @@ impl Flat {
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: sorted.as_ref().unwrap_or(&self.empty).as_entire_binding(),
|
||||
resource: buffers[1].as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
@@ -853,17 +891,32 @@ impl Flat {
|
||||
},
|
||||
],
|
||||
});
|
||||
Some((group, count as u32))
|
||||
})
|
||||
.collect();
|
||||
let cached = CachedGroup {
|
||||
key: BindingKey {
|
||||
first,
|
||||
count,
|
||||
buffers: buffers.map(|b| b.clone()),
|
||||
},
|
||||
group,
|
||||
};
|
||||
if groups < self.groups.len() {
|
||||
self.groups[groups] = cached;
|
||||
} else {
|
||||
self.groups.push(cached);
|
||||
}
|
||||
}
|
||||
groups += 1;
|
||||
}
|
||||
self.groups.truncate(groups);
|
||||
let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
|
||||
label: Some("Flatten section draws"),
|
||||
timestamp_writes: timing.and_then(|t| t.compute_writes("Flatten")),
|
||||
});
|
||||
pass.set_pipeline(&self.flatten);
|
||||
for (group, count) in &groups {
|
||||
pass.set_bind_group(0, group, &[]);
|
||||
pass.dispatch_workgroups((*count).min(GROUP_LIMIT), count.div_ceil(GROUP_LIMIT), 1);
|
||||
for cached in &self.groups {
|
||||
let count = cached.key.count as u32;
|
||||
pass.set_bind_group(0, &cached.group, &[]);
|
||||
pass.dispatch_workgroups(count.min(GROUP_LIMIT), count.div_ceil(GROUP_LIMIT), 1);
|
||||
}
|
||||
drop(pass);
|
||||
true
|
||||
@@ -919,16 +972,17 @@ impl Flat {
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
timing: Option<&mut super::pack_timing::PassTiming>,
|
||||
) {
|
||||
let Some((group, count)) = &self.phase2_group else {
|
||||
let Some(cached) = &self.phase2_group else {
|
||||
return;
|
||||
};
|
||||
let count = cached.key.count as u32;
|
||||
let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
|
||||
label: Some("Flatten phase 2 section draws"),
|
||||
timestamp_writes: timing.and_then(|t| t.compute_writes("Flatten phase 2")),
|
||||
});
|
||||
pass.set_pipeline(&self.flatten_seen);
|
||||
pass.set_bind_group(0, group, &[]);
|
||||
pass.dispatch_workgroups((*count).min(GROUP_LIMIT), count.div_ceil(GROUP_LIMIT), 1);
|
||||
pass.set_bind_group(0, &cached.group, &[]);
|
||||
pass.dispatch_workgroups(count.min(GROUP_LIMIT), count.div_ceil(GROUP_LIMIT), 1);
|
||||
}
|
||||
|
||||
/// The camera's translucent calls.
|
||||
|
||||
@@ -169,7 +169,8 @@ pub struct Hdr {
|
||||
hand_mark: wgpu::RenderPipeline,
|
||||
params: wgpu::Buffer,
|
||||
/// The same pass drawing HDR10 for a screenshot (`capture`), with its own parameters,
|
||||
/// so it can differ from the swapchain's in one frame.
|
||||
/// so it can differ from the swapchain's in one frame, and without the swapchain's dither:
|
||||
/// a screenshot keeps the exact codes.
|
||||
capture_pipeline: wgpu::RenderPipeline,
|
||||
capture_params: wgpu::Buffer,
|
||||
pub output: Output,
|
||||
@@ -286,8 +287,9 @@ impl Hdr {
|
||||
entries: &entries,
|
||||
})
|
||||
};
|
||||
// One full-screen pass of hdr.wgsl's `entry` into `format`, reading `layout`.
|
||||
let pass = |label, layout: &wgpu::BindGroupLayout, entry, format| {
|
||||
// One full-screen pass of hdr.wgsl's `entry` into `format`, reading `layout`; `dither`
|
||||
// for the last pass into the HDR10 swapchain (`pq_dither`).
|
||||
let pass = |label, layout: &wgpu::BindGroupLayout, entry, format, dither: bool| {
|
||||
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some(label),
|
||||
bind_group_layouts: &[Some(layout)],
|
||||
@@ -308,7 +310,10 @@ impl Hdr {
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &shader,
|
||||
entry_point: Some(entry),
|
||||
compilation_options: Default::default(),
|
||||
compilation_options: wgpu::PipelineCompilationOptions {
|
||||
constants: &[("pq_dither", f64::from(u8::from(dither)))],
|
||||
..Default::default()
|
||||
},
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format,
|
||||
blend: None,
|
||||
@@ -321,7 +326,8 @@ impl Hdr {
|
||||
};
|
||||
let layout_bindings: &[u32] = if coverage { &[0, 3, 4, 5] } else { &[0, 1, 3] };
|
||||
let display = layout("Horizon HDR bindings", layout_bindings, true);
|
||||
let pipeline_for = |format| pass("Horizon HDR", &display, entry_point, format);
|
||||
let pipeline_for =
|
||||
|format, dither| pass("Horizon HDR", &display, entry_point, format, dither);
|
||||
let params = || {
|
||||
device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Horizon HDR parameters"),
|
||||
@@ -333,7 +339,7 @@ impl Hdr {
|
||||
let layered = coverage.then(|| {
|
||||
let with = |label, textures: &[u32], params, entry, format| {
|
||||
let layout = layout(label, textures, params);
|
||||
let pipeline = pass(label, &layout, entry, format);
|
||||
let pipeline = pass(label, &layout, entry, format, false);
|
||||
(layout, pipeline)
|
||||
};
|
||||
let hud = super::hud_coverage::HUD_FORMAT;
|
||||
@@ -395,8 +401,8 @@ impl Hdr {
|
||||
);
|
||||
Self {
|
||||
logo: logo_mask(device, queue),
|
||||
pipeline: pipeline_for(output.format()),
|
||||
capture_pipeline: pipeline_for(Output::Pq.format()),
|
||||
pipeline: pipeline_for(output.format(), output == Output::Pq),
|
||||
capture_pipeline: pipeline_for(Output::Pq.format(), false),
|
||||
layout: display,
|
||||
clear_alpha,
|
||||
hand_mark,
|
||||
@@ -1709,19 +1715,32 @@ struct ReferenceParams { peak: f32, scene: f32, world: f32, debug: f32, share: v
|
||||
super::super::hud_coverage::clear(&mut encoder, &hdr.coverage_view().unwrap());
|
||||
hdr.begin(&queue, true);
|
||||
hdr.present(&queue, &mut encoder, &target, true, None);
|
||||
let got = texel(&device, &queue, encoder, &target);
|
||||
for c in &got[..3] {
|
||||
let c = f64::from(*c);
|
||||
let ok = match output {
|
||||
// 10-bit codes, a step either way for rounding.
|
||||
Output::Pq => (c - 1023.0 * pq(want)).abs() <= 1.0,
|
||||
// 80 nits a unit, in half floats.
|
||||
Output::ScRgb => (c * 80.0 - want).abs() <= 2e-3 * want + 1e-4,
|
||||
// HDR10's exact codes are a screenshot's copy: the swapchain's own get a
|
||||
// code of dither either way.
|
||||
let exact = (output == Output::Pq)
|
||||
.then(|| hdr.capture(&device, &queue, &mut encoder, true).unwrap());
|
||||
let presented = texel(&device, &queue, encoder, &target);
|
||||
let exact = exact.map(|copy| {
|
||||
let encoder = device.create_command_encoder(&Default::default());
|
||||
texel(&device, &queue, encoder, ©)
|
||||
});
|
||||
for (got, codes) in [(Some(presented), 2.0), (exact, 1.0)] {
|
||||
let Some(got) = got else {
|
||||
continue;
|
||||
};
|
||||
assert!(
|
||||
ok,
|
||||
"{output:?} black {black}, light {light}: {got:?}, not {want} nits"
|
||||
);
|
||||
for c in &got[..3] {
|
||||
let c = f64::from(*c);
|
||||
let ok = match output {
|
||||
// 10-bit codes, a step either way for rounding.
|
||||
Output::Pq => (c - 1023.0 * pq(want)).abs() <= codes,
|
||||
// 80 nits a unit, in half floats.
|
||||
Output::ScRgb => (c * 80.0 - want).abs() <= 2e-3 * want + 1e-4,
|
||||
};
|
||||
assert!(
|
||||
ok,
|
||||
"{output:?} black {black}, light {light}: {got:?}, not {want} nits"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2078,3 +2097,7 @@ mod lighting_tests {
|
||||
#[cfg(test)]
|
||||
#[path = "hdr_written_tests.rs"]
|
||||
mod written_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "hdr_dither_tests.rs"]
|
||||
mod dither_tests;
|
||||
@@ -0,0 +1,313 @@
|
||||
//! HDR10's dither (`hdr.wgsl`, `dithered`): the last pass into the 10-bit swapchain, and what
|
||||
//! must stay without it.
|
||||
use super::{
|
||||
Hdr, Output,
|
||||
tests::{fill, gpu},
|
||||
};
|
||||
use wgpu::TextureFormat as F;
|
||||
|
||||
const SIZE: [u32; 2] = [64, 1024];
|
||||
/// The ramp: grey rising by one half-float step every `ROWS` rows from `FIRST / 2048`. That is
|
||||
/// three PQ codes down the picture around 91 nits: as slow as a clear sky, and as fine as the
|
||||
/// float frame holds.
|
||||
const FIRST: u32 = 1434;
|
||||
const ROWS: u32 = 52;
|
||||
|
||||
/// SMPTE ST 2084: nits as a 10-bit PQ code, before rounding.
|
||||
fn code(nits: f64) -> f64 {
|
||||
let y = (nits / 10_000.0).powf(0.1593017578125);
|
||||
1023.0 * ((0.8359375 + 18.8515625 * y) / (1.0 + 18.6875 * y)).powf(78.84375)
|
||||
}
|
||||
|
||||
/// The ramp's row `y` as a PQ code, at the 200-nit paper white `Hdr` starts with.
|
||||
fn ramp_code(y: u32) -> f64 {
|
||||
code(200.0 * (f64::from(FIRST + y / ROWS) / 2048.0).powf(2.2))
|
||||
}
|
||||
|
||||
fn ramp(device: &wgpu::Device, encoder: &mut wgpu::CommandEncoder, frame: &wgpu::Texture) {
|
||||
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: None,
|
||||
source: wgpu::ShaderSource::Wgsl(
|
||||
format!(
|
||||
r#"
|
||||
@vertex fn vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4<f32> {{
|
||||
return vec4(vec2(f32((i << 1u) & 2u), f32(i & 2u)) * 2.0 - 1.0, 0.0, 1.0);
|
||||
}}
|
||||
@fragment fn fs(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {{
|
||||
return vec4(vec3(f32({FIRST}u + u32(pos.y) / {ROWS}u) / 2048.0), 1.0);
|
||||
}}
|
||||
"#
|
||||
)
|
||||
.into(),
|
||||
),
|
||||
});
|
||||
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: None,
|
||||
layout: None,
|
||||
vertex: wgpu::VertexState {
|
||||
module: &shader,
|
||||
entry_point: Some("vs"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[],
|
||||
},
|
||||
primitive: Default::default(),
|
||||
depth_stencil: None,
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &shader,
|
||||
entry_point: Some("fs"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(frame.format().into())],
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
let view = frame.create_view(&Default::default());
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
color_attachments: &[Some(super::super::hud_coverage::attachment(&view))],
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_pipeline(&pipeline);
|
||||
pass.draw(0..3, 0..1);
|
||||
}
|
||||
|
||||
/// What `hdr` writes for the float frame `paint` fills, with no HUD over it: its last pass into
|
||||
/// a swapchain image of its output (`Hdr::present`), or a screenshot's HDR10 copy
|
||||
/// (`Hdr::capture`). Every pixel's bytes, row by row.
|
||||
fn shown(
|
||||
(device, queue): &(wgpu::Device, wgpu::Queue),
|
||||
hdr: &mut Hdr,
|
||||
screenshot: bool,
|
||||
paint: impl Fn(&mut wgpu::CommandEncoder, &wgpu::Texture),
|
||||
) -> Vec<u8> {
|
||||
let frame = hdr.frame(device, SIZE);
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
paint(&mut encoder, &frame);
|
||||
hdr.keep_world(&mut encoder, None);
|
||||
super::super::hud_coverage::clear(&mut encoder, &hdr.coverage_view().unwrap());
|
||||
let image = if screenshot {
|
||||
hdr.capture(device, queue, &mut encoder, true).unwrap()
|
||||
} else {
|
||||
let image = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: None,
|
||||
size: frame.size(),
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: hdr.output.format(),
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
|
||||
view_formats: &[],
|
||||
});
|
||||
hdr.present(queue, &mut encoder, &image, true, None);
|
||||
image
|
||||
};
|
||||
let row = SIZE[0] * image.format().block_copy_size(None).unwrap();
|
||||
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: u64::from(row * SIZE[1]),
|
||||
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
encoder.copy_texture_to_buffer(
|
||||
image.as_image_copy(),
|
||||
wgpu::TexelCopyBufferInfo {
|
||||
buffer: &buffer,
|
||||
layout: wgpu::TexelCopyBufferLayout {
|
||||
offset: 0,
|
||||
bytes_per_row: Some(row),
|
||||
rows_per_image: None,
|
||||
},
|
||||
},
|
||||
image.size(),
|
||||
);
|
||||
queue.submit([encoder.finish()]);
|
||||
buffer.map_async(wgpu::MapMode::Read, .., |r| r.unwrap());
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
buffer.get_mapped_range(..).unwrap().to_vec()
|
||||
}
|
||||
|
||||
fn float_hdr((device, queue): &(wgpu::Device, wgpu::Queue), output: Output) -> Hdr {
|
||||
Hdr::with_debug(device, queue, F::Rgba16Float, output, true, None)
|
||||
}
|
||||
|
||||
/// An HDR10 picture's 10-bit codes, red, green and blue.
|
||||
fn codes(bytes: &[u8]) -> Vec<[u32; 3]> {
|
||||
let pixels = bytes.as_chunks::<4>().0.iter();
|
||||
pixels
|
||||
.map(|p| [0, 10, 20].map(|shift| (u32::from_le_bytes(*p) >> shift) & 0x3ff))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Down every column of a channel: the longest run of rows with one code, and the runs' mean
|
||||
/// length.
|
||||
fn runs(codes: &[[u32; 3]], channel: usize) -> (u32, f64) {
|
||||
let (mut longest, mut count) = (0, 0);
|
||||
for x in 0..SIZE[0] {
|
||||
let mut run = 0;
|
||||
for y in 0..SIZE[1] {
|
||||
let at = |y: u32| codes[(y * SIZE[0] + x) as usize][channel];
|
||||
if y > 0 && at(y) != at(y - 1) {
|
||||
(longest, count, run) = (longest.max(run), count + 1, 0);
|
||||
}
|
||||
run += 1;
|
||||
}
|
||||
(longest, count) = (longest.max(run), count + 1);
|
||||
}
|
||||
(longest, f64::from(SIZE[0] * SIZE[1]) / f64::from(count))
|
||||
}
|
||||
|
||||
/// Whether every row of a picture is one value from side to side: no noise in it.
|
||||
fn rows_are_flat(bytes: &[u8]) -> bool {
|
||||
let pixel = bytes.len() / (SIZE[0] * SIZE[1]) as usize;
|
||||
bytes
|
||||
.chunks(pixel * SIZE[0] as usize)
|
||||
.all(|row| row.chunks(pixel).all(|p| p == &row[..pixel]))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_slow_gradient_gets_grain_instead_of_bands_on_hdr10() {
|
||||
let Some(gpu) = gpu() else {
|
||||
return;
|
||||
};
|
||||
let ramp = |encoder: &mut wgpu::CommandEncoder, frame: &wgpu::Texture| {
|
||||
ramp(&gpu.0, encoder, frame);
|
||||
};
|
||||
let mut hdr = float_hdr(&gpu, Output::Pq);
|
||||
// The ramp is what the test says: three codes, top to bottom.
|
||||
let span = ramp_code(SIZE[1] - 1) - ramp_code(0);
|
||||
assert!((2.9..3.2).contains(&span), "{span}");
|
||||
// Without dither, as a screenshot's copy stays: each row its nearest code, in bands hundreds
|
||||
// of rows tall. A GPU need not round at exactly half a code: an NVIDIA one rounds up from
|
||||
// 0.55 here, which shows as 0.05 of a code less, so the bounds below leave a quarter code.
|
||||
let plain = codes(&shown(&gpu, &mut hdr, true, ramp));
|
||||
let dithered = codes(&shown(&gpu, &mut hdr, false, ramp));
|
||||
let want = |i: usize| ramp_code(i as u32 / SIZE[0]);
|
||||
for channel in 0..3 {
|
||||
for (i, p) in plain.iter().enumerate() {
|
||||
let off = (f64::from(p[channel]) - want(i)).abs();
|
||||
assert!(
|
||||
off <= 0.75,
|
||||
"plain {channel} at {i}: {p:?}, not {}",
|
||||
want(i)
|
||||
);
|
||||
}
|
||||
let (longest, _) = runs(&plain, channel);
|
||||
assert!(longest >= 250, "plain {channel}: bands of {longest} rows");
|
||||
// With it: each pixel within the noise's one code of its light (and half a code of
|
||||
// rounding), no column holds a code for long, and a run is a few rows on average.
|
||||
for (i, d) in dithered.iter().enumerate() {
|
||||
let off = (f64::from(d[channel]) - want(i)).abs();
|
||||
assert!(
|
||||
off <= 1.75,
|
||||
"dithered {channel} at {i}: {d:?}, not {}",
|
||||
want(i)
|
||||
);
|
||||
}
|
||||
let (longest, mean) = runs(&dithered, channel);
|
||||
eprintln!(
|
||||
"HDR10 dither GPU, channel {channel}: longest run {longest} rows, mean {mean:.2}"
|
||||
);
|
||||
assert!(longest <= 64, "dithered {channel}: a run of {longest} rows");
|
||||
assert!(mean < 4.0, "dithered {channel}: runs of {mean} rows");
|
||||
// The mean of each 64-row block is the light meant.
|
||||
let mut worst = 0.0_f64;
|
||||
for block in 0..SIZE[1] / 64 {
|
||||
let rows = (block * 64 * SIZE[0]) as usize..((block + 1) * 64 * SIZE[0]) as usize;
|
||||
let n = rows.len() as f64;
|
||||
let got = dithered[rows.clone()]
|
||||
.iter()
|
||||
.map(|d| f64::from(d[channel]))
|
||||
.sum::<f64>()
|
||||
/ n;
|
||||
let meant = rows.map(want).sum::<f64>() / n;
|
||||
worst = worst.max((got - meant).abs());
|
||||
assert!(
|
||||
(got - meant).abs() < 0.25,
|
||||
"block {block} of {channel}: {got}, not {meant}"
|
||||
);
|
||||
}
|
||||
eprintln!("HDR10 dither GPU, channel {channel}: block means within {worst:.3} of a code");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hdr10s_dither_keeps_both_ends_exact_and_leans_to_them_in_their_last_code() {
|
||||
let Some(gpu) = gpu() else {
|
||||
return;
|
||||
};
|
||||
let mut hdr = float_hdr(&gpu, Output::Pq);
|
||||
let grey = |g: f64| {
|
||||
move |encoder: &mut wgpu::CommandEncoder, frame: &wgpu::Texture| {
|
||||
fill(encoder, frame, [g, g, g, 1.0]);
|
||||
}
|
||||
};
|
||||
// How far the picture's mean code is from `end`.
|
||||
let mean_from = |picture: &[[u32; 3]], end: f64| {
|
||||
let sum: f64 = picture.iter().flatten().map(|c| f64::from(*c)).sum();
|
||||
(sum / (picture.len() * 3) as f64 - end).abs()
|
||||
};
|
||||
// Within a code of an end the noise shrinks to the distance to it. A third of a code from
|
||||
// the end, the signal plus that noise passes half a code only where the noise is over half
|
||||
// its range, an eighth of a triangle: so the picture's mean is an eighth of a code from the
|
||||
// end, not a third. That lean to the end, in the last code alone, is the price of keeping
|
||||
// the end itself exact. Bound: a GPU that rounds a tenth of a code off the half (as above)
|
||||
// shows between 0.02 and 0.32 of a code, always short of the third.
|
||||
let leans = |mean: f64| (0.015..0.325).contains(&mean);
|
||||
|
||||
// Black gets no noise: every pixel stays off.
|
||||
let black = codes(&shown(&gpu, &mut hdr, false, grey(0.0)));
|
||||
assert!(black.iter().all(|p| *p == [0; 3]));
|
||||
// A third of a code above black (this grey is a half float): 0 or 1, never lifted past it.
|
||||
let dim = 0.00038433074951171875;
|
||||
let meant = code(200.0 * f64::powf(dim, 2.2));
|
||||
assert!((meant - 1.0 / 3.0).abs() < 0.001, "{meant}");
|
||||
let near_black = codes(&shown(&gpu, &mut hdr, false, grey(dim)));
|
||||
assert!(near_black.iter().flatten().all(|c| *c <= 1));
|
||||
let mean = mean_from(&near_black, 0.0);
|
||||
eprintln!("HDR10 dither GPU: a third of a code above black shows as {mean:.3}");
|
||||
assert!(leans(mean), "{mean} above black");
|
||||
|
||||
// The top of the range, PQ's 10 000 nits, stays the top: nothing wraps or dims. Light past
|
||||
// the peak is held at it, so the Peak setting is the signal here.
|
||||
hdr.peak = 10_000.0;
|
||||
let top = codes(&shown(&gpu, &mut hdr, false, grey(20.0)));
|
||||
assert!(top.iter().all(|p| *p == [1023; 3]));
|
||||
// A third of a code under the top, mirrored: 1022 or 1023.
|
||||
let (mut under, mut over) = (9000.0, 10_000.0);
|
||||
for _ in 0..60 {
|
||||
let middle = (under + over) / 2.0;
|
||||
if code(middle) < 1023.0 - 1.0 / 3.0 {
|
||||
under = middle;
|
||||
} else {
|
||||
over = middle;
|
||||
}
|
||||
}
|
||||
hdr.peak = under as f32;
|
||||
let meant = code(f64::from(hdr.peak));
|
||||
assert!((1023.0 - meant - 1.0 / 3.0).abs() < 0.001, "{meant}");
|
||||
let near_top = codes(&shown(&gpu, &mut hdr, false, grey(20.0)));
|
||||
assert!(near_top.iter().flatten().all(|c| *c >= 1022));
|
||||
let mean = mean_from(&near_top, 1023.0);
|
||||
eprintln!("HDR10 dither GPU: a third of a code under the top shows as {mean:.3} under");
|
||||
assert!(leans(mean), "{mean} under the top");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scrgb_and_screenshots_get_no_dither() {
|
||||
let Some(gpu) = gpu() else {
|
||||
return;
|
||||
};
|
||||
let ramp = |encoder: &mut wgpu::CommandEncoder, frame: &wgpu::Texture| {
|
||||
ramp(&gpu.0, encoder, frame);
|
||||
};
|
||||
let (mut hdr10, mut scrgb) = (float_hdr(&gpu, Output::Pq), float_hdr(&gpu, Output::ScRgb));
|
||||
// scRGB's swapchain is float: every row is one value from side to side.
|
||||
assert!(rows_are_flat(&shown(&gpu, &mut scrgb, false, ramp)));
|
||||
// A screenshot's HDR10 copy is the same bytes whichever swapchain is presented, the
|
||||
// dithered one included, and has no noise in it.
|
||||
let screenshot = shown(&gpu, &mut hdr10, true, ramp);
|
||||
assert!(rows_are_flat(&screenshot));
|
||||
assert_eq!(screenshot, shown(&gpu, &mut scrgb, true, ramp));
|
||||
// The swapchain's own picture differs from it: the dither is on there.
|
||||
assert_ne!(screenshot, shown(&gpu, &mut hdr10, false, ramp));
|
||||
}
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
use glam::{Mat4, Vec3};
|
||||
use horizon_shader_api::{
|
||||
Api, Frame as FrameInputs, StageKind, check::CheckedStage, stages::GROUP,
|
||||
Api, Frame as FrameInputs, Primaries, StageKind, check::CheckedStage, stages::GROUP,
|
||||
};
|
||||
|
||||
use super::Frame;
|
||||
@@ -1596,7 +1596,11 @@ pub fn frame_inputs(
|
||||
paper_white: 80.0,
|
||||
peak: 80.0,
|
||||
black_level: 0.0,
|
||||
padding: [0; 3],
|
||||
// No pack is served another space yet (`[frame] primaries`).
|
||||
primaries: Primaries::Bt709 as u32,
|
||||
luminance_gap: [0; 2],
|
||||
luminance: Primaries::Bt709.luminance(),
|
||||
padding: 0,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -173,13 +173,52 @@ fn calibration_picture(pos: vec4<f32>) -> vec4<f32> {
|
||||
return vec4(nits / 80.0, 1.0);
|
||||
}
|
||||
|
||||
// Light in units of paper white, as the swapchain takes it: HDR10 or scRGB, the black level
|
||||
// lifted.
|
||||
fn display(light: vec3<f32>) -> vec4<f32> {
|
||||
// On for the HDR10 swapchain alone (Hdr::new): its 10 bits get dither. A screenshot's HDR10 copy
|
||||
// (Hdr::capture) keeps the exact codes, and scRGB's floats need none.
|
||||
override pq_dither: bool = false;
|
||||
|
||||
// Three even random values in [0, 1) for `at`: PCG's 3D hash (Jarzynski and Olano, "Hash Functions
|
||||
// for GPU Rendering", JCGT 2020). Integers only, so it is the same on every GPU and at every
|
||||
// resolution, where hashes built on `fract(sin())` repeat or streak at 4K's coordinates.
|
||||
// Copyright 2020 Mark Jarzynski & Marc Olano, MIT (github.com/markjarzynski/PCG3D; the license is in
|
||||
// THIRD_PARTY_NOTICES.md).
|
||||
fn hash3(at: vec3<u32>) -> vec3<f32> {
|
||||
var v = at * 1664525u + 1013904223u;
|
||||
v.x += v.y * v.z;
|
||||
v.y += v.z * v.x;
|
||||
v.z += v.x * v.y;
|
||||
v ^= v >> vec3(16u);
|
||||
v.x += v.y * v.z;
|
||||
v.y += v.z * v.x;
|
||||
v.z += v.x * v.y;
|
||||
return vec3<f32>(v >> vec3(8u)) / 16777216.0;
|
||||
}
|
||||
|
||||
// The PQ signal `e` with dither for its 10 bits: triangular noise of one code either way, each
|
||||
// channel's own, so a slow gradient (a clear sky is a few codes tall) shows as grain a code deep
|
||||
// instead of bands, and its mean is the light meant. The noise is the pixel's alone: no frame
|
||||
// counter, so a still picture stays still. Within a code of black or of the top it shrinks to
|
||||
// what fits: the signal never leaves 0..1, and black stays exactly black (an OLED's pixel off).
|
||||
// The price is in that last code alone: the smaller noise keeps the signal's mean but not the
|
||||
// rounded one, which leans to the end (a third of a code from it shows as an eighth). Under
|
||||
// 0.00004 nits at black, that is the accepted cost of a true 0.
|
||||
fn dithered(e: vec3<f32>, pixel: vec2<f32>) -> vec3<f32> {
|
||||
let at = vec2<u32>(pixel);
|
||||
let noise = hash3(vec3(at, 0u)) + hash3(vec3(at, 1u)) - 1.0;
|
||||
return e + noise * min(min(e, 1.0 - e), vec3(1.0 / 1023.0));
|
||||
}
|
||||
|
||||
// Light in units of paper white at the pixel `pos`, as the swapchain takes it: HDR10 or scRGB,
|
||||
// the black level lifted.
|
||||
fn display(light: vec3<f32>, pos: vec4<f32>) -> vec4<f32> {
|
||||
let scrgb = light * params.paper;
|
||||
if params.pq > 0.5 {
|
||||
let e = pq(BT709_TO_BT2020 * scrgb * 80.0);
|
||||
return vec4(select(e, lift_black(e), params.black > 0.0), 1.0);
|
||||
let lifted = select(e, lift_black(e), params.black > 0.0);
|
||||
if pq_dither {
|
||||
return vec4(dithered(lifted, pos.xy), 1.0);
|
||||
}
|
||||
return vec4(lifted, 1.0);
|
||||
}
|
||||
if params.black > 0.0 {
|
||||
return vec4(pq_nits(lift_black(pq(scrgb * 80.0))) / 80.0, 1.0);
|
||||
@@ -209,7 +248,7 @@ fn display(light: vec3<f32>) -> vec4<f32> {
|
||||
// The HUD and menus at their own brightness, by how much they cover, no brighter than the
|
||||
// peak (as `fs_hdr_layered`): UI Brightness can pass paper white.
|
||||
light = min(light * mix(1.0, params.ui, ui), vec3(params.peak));
|
||||
return display(light);
|
||||
return display(light, pos);
|
||||
}
|
||||
|
||||
// The tone curve's knee: up to it light stays as it is. Where HDR lighting eased into the peak
|
||||
@@ -328,5 +367,5 @@ override screenshot_shoulder: bool = true;
|
||||
if calibrating(pos) {
|
||||
return calibration_picture(pos);
|
||||
}
|
||||
return display(min(pow(layered(vec2<i32>(pos.xy)), vec3(2.2)), vec3(params.peak)));
|
||||
return display(min(pow(layered(vec2<i32>(pos.xy)), vec3(2.2)), vec3(params.peak)), pos);
|
||||
}
|
||||
@@ -52,7 +52,7 @@ pub struct TextureFlags {
|
||||
impl TextureFlags {
|
||||
/// The `texture` section of a `.png.mcmeta` file, if it sets either flag.
|
||||
pub fn from_mcmeta(json: &str) -> Option<Self> {
|
||||
let meta: serde_json::Value = serde_json::from_str(json).ok()?;
|
||||
let meta = crate::packs::first_json(json.as_bytes())?;
|
||||
let section = meta.get("texture")?;
|
||||
let flag = |key| section.get(key).and_then(serde_json::Value::as_bool) == Some(true);
|
||||
let flags = Self {
|
||||
@@ -387,7 +387,7 @@ fn strip_frame(pixels: &[u8], stride: u32, x: u32, y: u32, w: u32, h: u32) -> Ve
|
||||
}
|
||||
/// The `animation` section of a texture's `.mcmeta`.
|
||||
fn animation_of(mcmeta: Option<&str>) -> Option<AnimationMetadata> {
|
||||
let meta: serde_json::Value = serde_json::from_str(mcmeta?).ok()?;
|
||||
let meta = crate::packs::first_json(mcmeta?.as_bytes())?;
|
||||
AnimationMetadata::from_json(&meta.get("animation")?.to_string()).ok()
|
||||
}
|
||||
/// An animated strip's first frame: the region the mcmeta's frame size picks
|
||||
@@ -3110,6 +3110,18 @@ mod coverage_tests;
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn animated_texture_metadata_ignores_trailing_pack_signature() {
|
||||
let meta = r#"{"animation":{"frametime":2,"width":2,"height":2}}
|
||||
// High Contrast pack signature"#;
|
||||
let animation = animation_of(Some(meta)).expect("the first JSON value is metadata");
|
||||
assert_eq!(animation.frame_size(8, 4), [2, 2]);
|
||||
let pixels = (0..8 * 4).flat_map(|i| [i as u8, 0, 0, 255]).collect();
|
||||
let (_, height, first) = first_frame((8, 4, pixels), Some(&animation));
|
||||
assert_eq!(height, 2);
|
||||
assert_eq!(first.len(), 2 * 2 * 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn object_skin_revision_tracks_changes_and_capacity_clear() {
|
||||
let mut atlas = HudAtlas::boot();
|
||||
|
||||
@@ -22,6 +22,7 @@ use crate::{
|
||||
use horizon_core::geometry::{Direction, ShapeOp, VoxelShape};
|
||||
use horizon_world::registry::{BlockRegistry, BlockStateId};
|
||||
use std::{
|
||||
cell::RefCell,
|
||||
collections::{HashMap, VecDeque},
|
||||
path::Path,
|
||||
sync::Arc,
|
||||
@@ -365,6 +366,48 @@ fn give_scratch(scratch: LightScratch) {
|
||||
}
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static RELIGHT_SCRATCH: RefCell<RelightScratch> = RefCell::new(RelightScratch::new());
|
||||
}
|
||||
|
||||
struct RelightScratch {
|
||||
decrease: VecDeque<(Pos, u8)>,
|
||||
cleared: Vec<Pos>,
|
||||
increase: VecDeque<Pos>,
|
||||
sources: FxHashMap<(i32, i32), i32>,
|
||||
}
|
||||
|
||||
impl RelightScratch {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
decrease: VecDeque::new(),
|
||||
cleared: Vec::new(),
|
||||
increase: VecDeque::new(),
|
||||
sources: FxHashMap::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn clear(&mut self) {
|
||||
const MAX_CAPACITY: usize = 16_384;
|
||||
self.decrease.clear();
|
||||
if self.decrease.capacity() > MAX_CAPACITY {
|
||||
self.decrease.shrink_to(MAX_CAPACITY);
|
||||
}
|
||||
self.cleared.clear();
|
||||
if self.cleared.capacity() > MAX_CAPACITY {
|
||||
self.cleared.shrink_to(MAX_CAPACITY);
|
||||
}
|
||||
self.increase.clear();
|
||||
if self.increase.capacity() > MAX_CAPACITY {
|
||||
self.increase.shrink_to(MAX_CAPACITY);
|
||||
}
|
||||
self.sources.clear();
|
||||
if self.sources.capacity() > MAX_CAPACITY {
|
||||
self.sources.shrink_to(MAX_CAPACITY);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a section sits in the 48-wide region.
|
||||
#[derive(Clone, Copy)]
|
||||
struct At {
|
||||
@@ -880,12 +923,12 @@ impl<'w> Region<'w> {
|
||||
Self {
|
||||
layout: world.layout(),
|
||||
world,
|
||||
slots: Vec::new(),
|
||||
slots: Vec::with_capacity(16),
|
||||
index: FxHashMap::default(),
|
||||
last: ((i32::MIN, 0, 0), NONE),
|
||||
}
|
||||
}
|
||||
#[inline]
|
||||
#[inline(always)]
|
||||
fn slot(&mut self, x: i32, y: i32, z: i32) -> Option<usize> {
|
||||
let s = self.layout.section_index(y)?;
|
||||
let key = (x >> 4, s as i32, z >> 4);
|
||||
@@ -1142,10 +1185,25 @@ pub fn relight(
|
||||
force: bool,
|
||||
changed: &mut LightChanges,
|
||||
) {
|
||||
let mut region = Region::new(world);
|
||||
changed.keyed(region.layout);
|
||||
relight_region(&mut region, tables, sky_light, positions, force, changed);
|
||||
region.finish();
|
||||
changed.keyed(world.layout());
|
||||
if positions.is_empty() {
|
||||
return;
|
||||
}
|
||||
RELIGHT_SCRATCH.with_borrow_mut(|scratch| {
|
||||
scratch.clear();
|
||||
let mut region = Region::new(world);
|
||||
relight_region(
|
||||
&mut region,
|
||||
tables,
|
||||
sky_light,
|
||||
positions,
|
||||
force,
|
||||
scratch,
|
||||
changed,
|
||||
);
|
||||
region.finish();
|
||||
scratch.clear();
|
||||
});
|
||||
}
|
||||
/// The columns `changed` touched that border a loaded column not lit yet
|
||||
/// (`unlit`). `relight` reads and writes only lit columns, so an edit's
|
||||
@@ -1170,10 +1228,11 @@ fn relight_region(
|
||||
sky_light: bool,
|
||||
positions: &[Pos],
|
||||
force: bool,
|
||||
scratch: &mut RelightScratch,
|
||||
changed: &mut LightChanges,
|
||||
) {
|
||||
crate::spans::time("lt_block", || {
|
||||
relight_layer(world, tables, false, positions.to_vec(), force, changed)
|
||||
relight_layer(world, tables, false, positions, force, scratch, changed)
|
||||
});
|
||||
if sky_light {
|
||||
let (min_y, max_y) = (world.layout.min_y(), world.layout.max_y());
|
||||
@@ -1201,7 +1260,7 @@ fn relight_region(
|
||||
}
|
||||
});
|
||||
crate::spans::time("lt_sky", || {
|
||||
relight_layer(world, tables, true, seeds, force, changed)
|
||||
relight_layer(world, tables, true, &seeds, force, scratch, changed)
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1209,14 +1268,30 @@ fn relight_layer(
|
||||
world: &mut Region,
|
||||
tables: &LightTables,
|
||||
sky: bool,
|
||||
seeds: Vec<Pos>,
|
||||
seeds: &[Pos],
|
||||
force: bool,
|
||||
scratch: &mut RelightScratch,
|
||||
changed: &mut LightChanges,
|
||||
) {
|
||||
let mut decrease: VecDeque<(Pos, u8)> = VecDeque::new();
|
||||
let mut cleared: Vec<Pos> = Vec::new();
|
||||
let mut increase: VecDeque<Pos> = VecDeque::new();
|
||||
for p in seeds {
|
||||
scratch.decrease.clear();
|
||||
scratch.cleared.clear();
|
||||
scratch.increase.clear();
|
||||
scratch.sources.clear();
|
||||
|
||||
if seeds.len() > scratch.decrease.capacity() {
|
||||
scratch.decrease.reserve(seeds.len());
|
||||
}
|
||||
if seeds.len() > scratch.cleared.capacity() {
|
||||
scratch.cleared.reserve(seeds.len());
|
||||
}
|
||||
if seeds.len() > scratch.increase.capacity() {
|
||||
scratch.increase.reserve(seeds.len());
|
||||
}
|
||||
|
||||
let decrease = &mut scratch.decrease;
|
||||
let cleared = &mut scratch.cleared;
|
||||
let increase = &mut scratch.increase;
|
||||
for &p in seeds {
|
||||
let Some(old) = level(world, sky, p) else {
|
||||
continue;
|
||||
};
|
||||
@@ -1267,13 +1342,22 @@ fn relight_layer(
|
||||
}
|
||||
}
|
||||
// Sources inside the cleared area light up again.
|
||||
let mut sources: FxHashMap<(i32, i32), i32> = FxHashMap::default();
|
||||
let sources = &mut scratch.sources;
|
||||
let mut last_xz = (i32::MIN, i32::MIN);
|
||||
let mut last_lowest = 0;
|
||||
crate::spans::time("lt_sources", || {
|
||||
for p in cleared {
|
||||
for &p in cleared.iter() {
|
||||
let seed = if sky {
|
||||
let lowest = *sources
|
||||
.entry((p.0, p.2))
|
||||
.or_insert_with(|| lowest_sky_source(world, tables, p.0, p.2));
|
||||
let lowest = if (p.0, p.2) == last_xz {
|
||||
last_lowest
|
||||
} else {
|
||||
let lowest = *sources
|
||||
.entry((p.0, p.2))
|
||||
.or_insert_with(|| lowest_sky_source(world, tables, p.0, p.2));
|
||||
last_xz = (p.0, p.2);
|
||||
last_lowest = lowest;
|
||||
lowest
|
||||
};
|
||||
if p.1 >= lowest { 15 } else { 0 }
|
||||
} else {
|
||||
world
|
||||
@@ -2119,9 +2203,39 @@ pub(crate) mod tests {
|
||||
relight(&mut w, &t, true, &positions, false, &mut changed);
|
||||
}
|
||||
println!(
|
||||
"relight_bench: {:?}, {} sections",
|
||||
start.elapsed(),
|
||||
changed.sections().count()
|
||||
"relight_bench (large batch: 400 rounds x 32 blocks): {:?}",
|
||||
start.elapsed()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn relight_single_block_bench() {
|
||||
let mut w: FxHashMap<_, _> = world(|_| {}).into_iter().collect();
|
||||
let t = tables();
|
||||
let mut rng = 0x2545_f491_u32;
|
||||
let mut next = |n: i32| {
|
||||
rng ^= rng << 13;
|
||||
rng ^= rng >> 17;
|
||||
rng ^= rng << 5;
|
||||
(rng % n as u32) as i32
|
||||
};
|
||||
let start = std::time::Instant::now();
|
||||
let mut changed = LightChanges::default();
|
||||
for round in 0..2000 {
|
||||
let state = [TORCH, SLAB, AIR][round % 3];
|
||||
let pos = (next(32) - 8, next(12), next(32) - 8);
|
||||
Arc::make_mut(w.get_mut(&(pos.0 >> 4, pos.2 >> 4)).unwrap()).set(
|
||||
pos.0 & 15,
|
||||
pos.1,
|
||||
pos.2 & 15,
|
||||
state,
|
||||
);
|
||||
relight(&mut w, &t, true, &[pos], false, &mut changed);
|
||||
}
|
||||
println!(
|
||||
"relight_single_block_bench (single block: 2000 rounds): {:?}",
|
||||
start.elapsed()
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -466,6 +466,13 @@ fn read_meta(source: &Source) -> Option<serde_json::Value> {
|
||||
/// file with a comment after the closing brace. Gson reads past a UTF-8 byte order mark;
|
||||
/// serde_json rejects it.
|
||||
pub(crate) fn parse_meta(bytes: &[u8]) -> Option<serde_json::Value> {
|
||||
first_json(bytes)
|
||||
}
|
||||
|
||||
/// Reads the first JSON value from resource metadata, matching Gson's
|
||||
/// `JsonReader` (which ignores a trailing signature or comment). Resource
|
||||
/// packs commonly append such text to texture metadata as well as pack.mcmeta.
|
||||
pub(crate) fn first_json(bytes: &[u8]) -> Option<serde_json::Value> {
|
||||
serde_json::Deserializer::from_slice(bytes.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(bytes))
|
||||
.into_iter()
|
||||
.next()?
|
||||
|
||||
@@ -526,6 +526,8 @@ pub struct Particle {
|
||||
no_render: bool,
|
||||
/// Ticked once when created (`CritParticle` constructor).
|
||||
pre_tick: bool,
|
||||
/// A Polytone particle with physics, which joins only where its box is clear of blocks.
|
||||
spawn_check: bool,
|
||||
/// `Particle.random`: the particle's own generator (`RandomSource.create()`).
|
||||
random: LegacyRandom,
|
||||
controller: Option<Box<dyn native::Controller>>,
|
||||
@@ -895,6 +897,7 @@ impl Particle {
|
||||
behaviour: Behaviour::Base,
|
||||
no_render: false,
|
||||
pre_tick: false,
|
||||
spawn_check: false,
|
||||
frame_ticks: 0,
|
||||
random: LegacyRandom::new(0),
|
||||
controller: None,
|
||||
@@ -1759,28 +1762,6 @@ impl ParticleEngine {
|
||||
particle.controller = Some(Box::new(controller));
|
||||
Some(particle)
|
||||
}
|
||||
/// Sets (or clears) the resource packs' Polytone custom particle types.
|
||||
pub fn set_polytone(&mut self, registry: Option<std::sync::Arc<polytone::Registry>>) {
|
||||
let registry = registry.filter(|r| !r.is_empty());
|
||||
let same = match (&self.polytone, ®istry) {
|
||||
(Some(a), Some(b)) => std::sync::Arc::ptr_eq(a, b),
|
||||
(None, None) => true,
|
||||
_ => false,
|
||||
};
|
||||
if !same {
|
||||
self.polytone_gpu.clear();
|
||||
self.polytone_overrides = registry
|
||||
.iter()
|
||||
.flat_map(|r| {
|
||||
ParticleType::ALL.iter().filter_map(|&ty| {
|
||||
let id = format!("minecraft:{}", ty.name());
|
||||
r.get(&id).is_some().then_some((ty, id))
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
self.polytone = registry;
|
||||
}
|
||||
/// The quads the last render blends additively (`RenderPipelines` additive, src alpha and
|
||||
/// one), for the frame's additive pass.
|
||||
pub fn take_additive(&self) -> Vec<Vertex> {
|
||||
@@ -3621,13 +3602,14 @@ impl ParticleEngine {
|
||||
self.sprite_from_age(&mut q);
|
||||
q
|
||||
}
|
||||
/// ClientLevel.createFireworks: a Starter (or poofs without explosions).
|
||||
/// ClientLevel.createFireworks: a Starter (or poofs without explosions) with `playSound`.
|
||||
pub fn fireworks(
|
||||
&mut self,
|
||||
at: [f64; 3],
|
||||
v: [f64; 3],
|
||||
explosions: Vec<FireworkExplosion>,
|
||||
camera: [f64; 3],
|
||||
play_sound: bool,
|
||||
) {
|
||||
if explosions.is_empty() {
|
||||
for _ in 0..self.random.next_int_bound(3).unwrap_or(0) + 2 {
|
||||
@@ -3651,17 +3633,17 @@ impl ParticleEngine {
|
||||
life: 0,
|
||||
lifetime,
|
||||
twinkle_delay: twinkle,
|
||||
far,
|
||||
sound: play_sound.then_some(far),
|
||||
});
|
||||
}
|
||||
fn tick_starters(&mut self) {
|
||||
for mut s in std::mem::take(&mut self.starters) {
|
||||
if s.life == 0 {
|
||||
if s.life == 0 && s.sound.is_some() {
|
||||
let large = s.explosions.len() >= 3
|
||||
|| s.explosions
|
||||
.iter()
|
||||
.any(|e| e.shape == FireworkShape::LargeBall);
|
||||
let sound = match (large, s.far) {
|
||||
let sound = match (large, s.sound == Some(true)) {
|
||||
(true, true) => "minecraft:entity.firework_rocket.large_blast_far",
|
||||
(true, false) => "minecraft:entity.firework_rocket.large_blast",
|
||||
(false, true) => "minecraft:entity.firework_rocket.blast_far",
|
||||
@@ -3704,16 +3686,13 @@ impl ParticleEngine {
|
||||
(c & 255) as f32 / 255.0,
|
||||
((c >> 24) & 255) as f32 / 255.0,
|
||||
];
|
||||
self.create(
|
||||
&ParticleOptions::colored(ParticleType::Flash, rgba),
|
||||
s.at,
|
||||
[0.0; 3],
|
||||
);
|
||||
let flash = ParticleOptions::colored(ParticleType::Flash, rgba);
|
||||
self.create(&flash, s.at, [0.0; 3]);
|
||||
}
|
||||
s.life += 1;
|
||||
if s.life > s.lifetime {
|
||||
if s.twinkle_delay {
|
||||
let sound = if s.far {
|
||||
if s.twinkle_delay && s.sound.is_some() {
|
||||
let sound = if s.sound == Some(true) {
|
||||
"minecraft:entity.firework_rocket.twinkle_far"
|
||||
} else {
|
||||
"minecraft:entity.firework_rocket.twinkle"
|
||||
@@ -3846,6 +3825,9 @@ impl ParticleEngine {
|
||||
}
|
||||
let pending = std::mem::take(&mut self.pending);
|
||||
for mut p in pending {
|
||||
if p.spawn_check && !polytone::unobstructed(level, p.bb) {
|
||||
continue;
|
||||
}
|
||||
if p.pre_tick {
|
||||
p.pre_tick = false;
|
||||
self.tick_particle(&mut p, level);
|
||||
@@ -5054,7 +5036,9 @@ struct Starter {
|
||||
life: i32,
|
||||
lifetime: i32,
|
||||
twinkle_delay: bool,
|
||||
far: bool,
|
||||
/// `FireworkParticles.Starter.playSound`, with `isFarAwayFromCamera` when
|
||||
/// it plays: `None` for a silent rocket.
|
||||
sound: Option<bool>,
|
||||
}
|
||||
const STAR: [[f64; 2]; 6] = [
|
||||
[0.0, 1.0],
|
||||
|
||||
@@ -1090,6 +1090,16 @@ const INITIALIZED: [&str; 13] = [
|
||||
"dz",
|
||||
];
|
||||
|
||||
/// Whether no block's collision shape overlaps `bb` (where the query fails, nothing does).
|
||||
pub(super) fn unobstructed(level: &ParticleLevel<'_>, bb: horizon_core::geometry::Aabb) -> bool {
|
||||
let mut scene = crate::collisions::SceneCollisions {
|
||||
world: level.world,
|
||||
binding: level.collision,
|
||||
subject: None,
|
||||
};
|
||||
scene.cells_without_collision(bb) || scene.unobstructed(bb).unwrap_or(true)
|
||||
}
|
||||
|
||||
impl super::ParticleController for Controller {
|
||||
fn tick(&mut self, p: &mut Particle, level: &ParticleLevel<'_>) -> ParticleUpdate {
|
||||
let mut update = ParticleUpdate::default();
|
||||
@@ -1320,6 +1330,10 @@ pub(super) fn make(
|
||||
particle.polytone_type =
|
||||
(def.limit.is_some() || def.exclusion_radius > 0.0).then(|| Arc::from(name));
|
||||
particle.bb = super::aabb_at(position, particle.bb_width, particle.bb_height);
|
||||
// A particle with physics whose box starts inside a block's collision never joins the world:
|
||||
// it is not drawn and none of its emitters run (measured on the mod: one spawned just under a
|
||||
// block, where falling leaves start, never shows; one 0.01 clear of it does).
|
||||
particle.spawn_check = def.has_physics;
|
||||
particle.controller = Some(Box::new(Controller {
|
||||
def: def.clone(),
|
||||
registry: registry.clone(),
|
||||
@@ -1531,6 +1545,31 @@ impl ParticleEngine {
|
||||
}
|
||||
}
|
||||
|
||||
impl ParticleEngine {
|
||||
/// Sets (or clears) the resource packs' Polytone custom particle types.
|
||||
pub fn set_polytone(&mut self, registry: Option<Arc<Registry>>) {
|
||||
let registry = registry.filter(|r| !r.is_empty());
|
||||
let same = match (&self.polytone, ®istry) {
|
||||
(Some(a), Some(b)) => Arc::ptr_eq(a, b),
|
||||
(None, None) => true,
|
||||
_ => false,
|
||||
};
|
||||
if !same {
|
||||
self.polytone_gpu.clear();
|
||||
self.polytone_overrides = registry
|
||||
.iter()
|
||||
.flat_map(|r| {
|
||||
ParticleType::ALL.iter().filter_map(|&ty| {
|
||||
let id = format!("minecraft:{}", ty.name());
|
||||
r.get(&id).is_some().then_some((ty, id))
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
self.polytone = registry;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -2244,6 +2283,144 @@ mod tests {
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
/// `scene`, with the torch a full block of collision: something a particle's box can start in.
|
||||
fn solid_scene(blocks: &'static BlockRegistry) -> Scene {
|
||||
use horizon_core::geometry::{Axis, VoxelShape};
|
||||
let tag = |shape: VoxelShape| {
|
||||
let sizes = shape.sizes();
|
||||
let occupied = (0..sizes[2])
|
||||
.flat_map(|z| {
|
||||
(0..sizes[1]).flat_map(move |y| (0..sizes[0]).map(move |x| (x, y, z)))
|
||||
})
|
||||
.map(|(x, y, z)| u8::from(shape.is_full(x as i32, y as i32, z as i32)))
|
||||
.collect();
|
||||
let (min, max) = shape.discrete_bounds();
|
||||
let axes = shape
|
||||
.cube_coordinate_axes()
|
||||
.iter()
|
||||
.enumerate()
|
||||
.fold(0, |mask, (i, on)| mask | (i8::from(*on) << i));
|
||||
let mut fields = vec![
|
||||
("occupied", Tag::ByteArray(occupied)),
|
||||
("cube", Tag::Byte(i8::from(shape.is_cube()))),
|
||||
("cube_axes", Tag::Byte(axes)),
|
||||
("discrete_min", Tag::IntArray(min.to_vec())),
|
||||
("discrete_max", Tag::IntArray(max.to_vec())),
|
||||
];
|
||||
for (axis, name) in [(Axis::X, "x"), (Axis::Y, "y"), (Axis::Z, "z")] {
|
||||
let bits = shape.coordinates(axis).iter().map(|v| v.to_bits() as i64);
|
||||
fields.push((name, Tag::LongArray(bits.collect())));
|
||||
}
|
||||
Tag::compound(fields)
|
||||
};
|
||||
let names = || Tag::List(STATES.iter().map(|s| Tag::String((*s).into())).collect());
|
||||
let cached = CachedCollisionRegistry::from_nbt(
|
||||
&Tag::compound([
|
||||
("DataVersion", Tag::Int(DATA_VERSION)),
|
||||
("states", names()),
|
||||
(
|
||||
"shapes",
|
||||
Tag::List(vec![tag(VoxelShape::empty()), tag(VoxelShape::block())]),
|
||||
),
|
||||
("cached_shapes", Tag::IntArray(vec![0, 1])),
|
||||
]),
|
||||
blocks,
|
||||
)
|
||||
.unwrap();
|
||||
Scene {
|
||||
collision: BlockCollisionRegistry::new(Arc::new(cached)).unwrap(),
|
||||
..scene(blocks)
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_particle_with_physics_born_inside_a_block_never_joins() {
|
||||
let blocks = blocks();
|
||||
let leaf = |physics| {
|
||||
serde_json::json!({"has_physics": physics, "render_type": "invisible",
|
||||
"initializer": {"lifetime": "20", "hitbox_size": "0.08"},
|
||||
"particle_emitters": [{"particle": "test:mark", "chance": "1"}]})
|
||||
};
|
||||
let root = pack(&[
|
||||
(
|
||||
"assets/test/polytone/custom_particles/leaf.json",
|
||||
leaf(true),
|
||||
),
|
||||
(
|
||||
"assets/test/polytone/custom_particles/ghost.json",
|
||||
leaf(false),
|
||||
),
|
||||
(
|
||||
"assets/test/polytone/custom_particles/mark.json",
|
||||
serde_json::json!({"render_type": "invisible", "has_physics": false}),
|
||||
),
|
||||
(
|
||||
"assets/minecraft/polytone/entity_modifiers/player.json",
|
||||
serde_json::json!({"emitters": [{"particle": "test:leaf", "chance": "1"}]}),
|
||||
),
|
||||
(
|
||||
"assets/test/polytone/custom_particles/wide.json",
|
||||
serde_json::json!({"has_physics": true, "render_type": "invisible",
|
||||
"initializer": {"lifetime": "20", "hitbox_size": 10},
|
||||
"particle_emitters": [{"particle": "test:mark", "chance": "1"}]}),
|
||||
),
|
||||
(
|
||||
"assets/test/polytone/custom_particles/parent.json",
|
||||
serde_json::json!({"has_physics": true, "render_type": "invisible",
|
||||
"initializer": {"lifetime": "20", "hitbox_size": "0.08"},
|
||||
"particle_emitters": [{"particle": "test:wide", "chance": "1", "y": "-0.05"}]}),
|
||||
),
|
||||
(
|
||||
"assets/minecraft/polytone/entity_modifiers/skeleton.json",
|
||||
serde_json::json!({"emitters": [{"particle": "test:parent", "chance": "1", "x": "-4.5"}]}),
|
||||
),
|
||||
(
|
||||
"assets/minecraft/polytone/entity_modifiers/creeper.json",
|
||||
serde_json::json!({"emitters": [{"particle": "test:wide", "chance": "1", "x": "-4.5"}]}),
|
||||
),
|
||||
(
|
||||
"assets/minecraft/polytone/entity_modifiers/zombie.json",
|
||||
serde_json::json!({"emitters": [{"particle": "test:ghost", "chance": "1"}]}),
|
||||
),
|
||||
]);
|
||||
let scene = solid_scene(blocks);
|
||||
let level = ParticleLevel {
|
||||
world: &scene.world,
|
||||
facts: &scene.facts,
|
||||
collision: &scene.collision,
|
||||
player: None,
|
||||
cached_chunk: std::cell::Cell::new(None),
|
||||
};
|
||||
let mut engine = engine(Registry::read(&root, Default::default(), blocks));
|
||||
// The solid block is at (1, 64, 1): its bottom face is y 64.
|
||||
// Spawns one particle, lets the engine take it in and tick it: whether it joined, and
|
||||
// how many particles it emitted.
|
||||
let mut spawn = |entity: &str, y: f64| {
|
||||
engine.particles.clear();
|
||||
engine.pending.clear();
|
||||
engine.polytone_entity(&level, entity, [1.5, y, 1.5]);
|
||||
engine.tick(&level);
|
||||
let joined = engine.particles.len();
|
||||
engine.tick(&level);
|
||||
(
|
||||
joined,
|
||||
engine.particles.len() + engine.pending.len() - joined,
|
||||
)
|
||||
};
|
||||
// The solid block is at (1, 64, 1): its bottom face is y 64.
|
||||
// 0.05 under the block, a 0.08 box reaches into it: it never joins, nothing emitted.
|
||||
assert_eq!(spawn("minecraft:player", 63.95), (0, 0));
|
||||
// 0.01 clear of it, it joins and emits.
|
||||
assert_eq!(spawn("minecraft:player", 63.91), (1, 1));
|
||||
// Without physics nothing collides.
|
||||
assert_eq!(spawn("minecraft:zombie", 63.95), (1, 1));
|
||||
// A wide box reaching a block from five blocks away does not join either.
|
||||
assert_eq!(spawn("minecraft:creeper", 60.0), (0, 0));
|
||||
// Nor does one a clear particle emits there: the small one joins, its wide copy never.
|
||||
assert_eq!(spawn("minecraft:skeleton", 60.0), (1, 0));
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pack_custom_particle_replaces_the_vanilla_type_of_its_id() {
|
||||
let root = pack(&[(
|
||||
|
||||
@@ -43,7 +43,7 @@ fn spawn_tick(engine: &mut ParticleEngine, rng: &mut Lcg, types: &[Emitter], roc
|
||||
twinkle: false,
|
||||
};
|
||||
let at = [-5.5 + rng.next() * 12.0, f64::from(FLOOR_Y + 20), 20.5];
|
||||
engine.fireworks(at, [0.0, 0.1, 0.0], vec![explosion], engine.camera);
|
||||
engine.fireworks(at, [0.0, 0.1, 0.0], vec![explosion], engine.camera, true);
|
||||
}
|
||||
for &(ty, count, center, delta, speed) in types {
|
||||
let options = ParticleOptions::simple(ty);
|
||||
|
||||
@@ -7,13 +7,16 @@ use horizon_core::{
|
||||
geometry::{Vec3, traverse_blocks},
|
||||
position::BlockPos,
|
||||
};
|
||||
use horizon_world::collision_context::CollisionContext;
|
||||
|
||||
/// `PotentSulfurBlock.ALLOWED_WATER_BLOCKS_ABOVE`.
|
||||
const ALLOWED_WATER_BLOCKS_ABOVE: i32 = 4;
|
||||
|
||||
/// `PotentSulfurBlockEntity.isGeyserPassableBlock`: air, water, or a block
|
||||
/// with no collision shape.
|
||||
pub fn is_geyser_passable_block(level: &ParticleLevel, pos: [i32; 3]) -> bool {
|
||||
/// with no collision shape for `CollisionContext.positionContext(context_y)`.
|
||||
/// Vanilla passes the height of the block under the column it reads, so
|
||||
/// scaffolding seen from below lets the gas and the geyser through.
|
||||
pub fn is_geyser_passable_block(level: &ParticleLevel, pos: [i32; 3], context_y: i32) -> bool {
|
||||
let state = level.world.block(pos[0], pos[1], pos[2]);
|
||||
if level.facts.is_air(state) || &*level.facts.get(state).name == "minecraft:water" {
|
||||
return true;
|
||||
@@ -23,7 +26,9 @@ pub fn is_geyser_passable_block(level: &ParticleLevel, pos: [i32; 3]) -> bool {
|
||||
binding: level.collision,
|
||||
subject: None,
|
||||
};
|
||||
match level.collision.collision_shape(&mut scene, state, pos) {
|
||||
let context = CollisionContext::position(f64::from(context_y));
|
||||
let position = BlockPos::new(pos[0], pos[1], pos[2]);
|
||||
match (level.collision).collision_shape_with_context(&mut scene, state, position, &context) {
|
||||
Ok(shape) => shape.is_empty(),
|
||||
Err(_) => !level.facts.get(state).full_collision,
|
||||
}
|
||||
@@ -46,9 +51,9 @@ pub fn find_noxious_gas_source_block(level: &ParticleLevel, origin: [i32; 3]) ->
|
||||
let state = level.world.block(pos[0], pos[1], pos[2]);
|
||||
let is_water_block = &*level.facts.get(state).name == "minecraft:water";
|
||||
if !is_water_source(level, pos)
|
||||
|| (!is_water_block && !is_geyser_passable_block(level, pos))
|
||||
|| (!is_water_block && !is_geyser_passable_block(level, pos, origin[1]))
|
||||
{
|
||||
if level.facts.is_air(state) || is_geyser_passable_block(level, pos) {
|
||||
if level.facts.is_air(state) || is_geyser_passable_block(level, pos, origin[1]) {
|
||||
return Some(pos);
|
||||
}
|
||||
break;
|
||||
@@ -63,7 +68,7 @@ pub fn find_noxious_gas_source_block(level: &ParticleLevel, origin: [i32; 3]) ->
|
||||
pub fn unobstructed_block_count(level: &ParticleLevel, pos: [i32; 3], water_blocks: i32) -> i32 {
|
||||
let geyser_force_height = 6 * water_blocks;
|
||||
for i in 0..geyser_force_height {
|
||||
if !is_geyser_passable_block(level, [pos[0], pos[1] + i, pos[2]]) {
|
||||
if !is_geyser_passable_block(level, [pos[0], pos[1] + i, pos[2]], pos[1] - 1) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
@@ -79,7 +84,7 @@ pub fn can_be_reached_by_noxious_gas(
|
||||
pos: [f64; 3],
|
||||
) -> bool {
|
||||
let block = ParticleLevel::block_at(pos);
|
||||
if !is_geyser_passable_block(level, block) {
|
||||
if !is_geyser_passable_block(level, block, block[1] - 1) {
|
||||
return false;
|
||||
}
|
||||
let center = source_block.map(|c| f64::from(c) + 0.5);
|
||||
|
||||
@@ -232,6 +232,8 @@ impl Selector {
|
||||
pub struct Tick {
|
||||
/// A vibration was chosen: its travel time (the particle starts).
|
||||
pub started: Option<(Info, i32)>,
|
||||
/// A vibration was reloaded from save: particle resumes from interpolated pos.
|
||||
pub resumed: Option<(Info, i32)>,
|
||||
/// A vibration arrived.
|
||||
pub received: Option<Info>,
|
||||
/// `onDataChanged`.
|
||||
@@ -261,6 +263,10 @@ impl Data {
|
||||
self.selector.start_over();
|
||||
}
|
||||
if let Some(info) = self.current {
|
||||
if self.reload_particle {
|
||||
self.reload_particle = false;
|
||||
out.resumed = Some((info, self.travel));
|
||||
}
|
||||
let mut changed = self.travel > 0;
|
||||
self.travel = (self.travel - 1).max(0);
|
||||
if self.travel <= 0 {
|
||||
@@ -293,8 +299,11 @@ impl Data {
|
||||
Tag::compound(entries)
|
||||
}
|
||||
pub fn from_nbt(tag: &Tag) -> Self {
|
||||
let current = tag.get("event").and_then(info_from_nbt);
|
||||
let reload_particle = current.is_some();
|
||||
let mut data = Self {
|
||||
current: tag.get("event").and_then(info_from_nbt),
|
||||
current,
|
||||
reload_particle,
|
||||
..Self::default()
|
||||
};
|
||||
if let Some(Tag::Int(t)) = tag.get("event_delay") {
|
||||
@@ -979,4 +988,30 @@ mod tests {
|
||||
assert_eq!(back.spreader.cursors[0].charge, 1000);
|
||||
assert_eq!(back.spreader.cursors[1].charge, 500);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vibration_reloads_particle() {
|
||||
let tag = Tag::compound([
|
||||
(
|
||||
"event",
|
||||
Tag::compound([
|
||||
("game_event", Tag::String("minecraft:step".into())),
|
||||
("distance", Tag::Float(4.0f32.to_bits())),
|
||||
(
|
||||
"pos",
|
||||
Tag::List(vec![
|
||||
Tag::Double(1.0f64.to_bits()),
|
||||
Tag::Double(0),
|
||||
Tag::Double(0),
|
||||
]),
|
||||
),
|
||||
]),
|
||||
),
|
||||
("event_delay", Tag::Int(3)),
|
||||
]);
|
||||
let mut d = Data::from_nbt(&tag);
|
||||
assert!(d.reload_particle && d.current.is_some());
|
||||
let tick = d.tick(10, true, true, |dist| dist as i32);
|
||||
assert!(tick.resumed.is_some() && !d.reload_particle);
|
||||
}
|
||||
}
|
||||
@@ -965,13 +965,13 @@ const PI: f32 = 3.14159265;
|
||||
fn disc_distance(d: vec3<f32>, y: f32) -> vec2<f32> {
|
||||
if d.y * y <= 0.0 { return vec2(-1.0); }
|
||||
let p = d * (y / d.y);
|
||||
let h = length(p.xz);
|
||||
let phi = atan2(p.z, p.x);
|
||||
let sector = PI / 4.0;
|
||||
let delta = phi - (floor(phi / sector) * sector + sector * 0.5);
|
||||
let edge = 512.0 * cos(sector * 0.5) / cos(delta);
|
||||
if h > edge { return vec2(-1.0); }
|
||||
let s = h / edge;
|
||||
// The regular octagon's edge parameter, directly from its half planes.
|
||||
// max(|x| + tan(pi/8)*|z|, |z| + tan(pi/8)*|x|) / radius
|
||||
// equals length(p.xz) / edge without atan2, floor or cos per pixel.
|
||||
let a = abs(p.xz);
|
||||
let diagonal = 0.41421356237309503;
|
||||
let s = max(a.x + diagonal * a.y, a.y + diagonal * a.x) / 512.0;
|
||||
if s > 1.0 { return vec2(-1.0); }
|
||||
let ay = abs(y);
|
||||
let rim = sqrt(512.0 * 512.0 + y * y);
|
||||
return vec2(ay + s * (rim - ay), ay + s * (512.0 - ay));
|
||||
|
||||
@@ -14,6 +14,8 @@ pub enum Cursor {
|
||||
Arrow,
|
||||
PointingHand,
|
||||
NotAllowed,
|
||||
/// `CursorTypes.RESIZE_NS`: a scroller held vertically (`MerchantScreen`).
|
||||
ResizeNs,
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
|
||||
@@ -33,7 +33,8 @@ horizon-protocol = { path = "../horizon-protocol" }
|
||||
horizon-server-files = { path = "../horizon-server-files" }
|
||||
# The dedicated server's anticheat, off unless server.toml's [anticheat] turns it on (docs/anticheat.md).
|
||||
horizon-anticheat = { path = "../horizon-anticheat" }
|
||||
# The dedicated server's chat filter: [chat_filter], the slur list, mutes (docs/chat-filter.md).
|
||||
# The dedicated server's chat filter: [chat_filter], the slur list, mutes (docs/chat-filter.md);
|
||||
# `entry` installs it into the engine's hook (`horizon_server_files::chat_guard`).
|
||||
horizon-chat-filter = { path = "../horizon-chat-filter" }
|
||||
# Shader packs: reading them and turning their GLSL into naga modules.
|
||||
horizon-shaders = { path = "../horizon-shaders" }
|
||||
|
||||
@@ -274,8 +274,8 @@ impl ArrowState {
|
||||
/// `Arrow.getColor` of the server's own arrow: the colour of the potion on
|
||||
/// its pickup item, None for a plain arrow (`PotionContents.EMPTY`).
|
||||
pub fn potion_color(&self) -> Option<u32> {
|
||||
let potion = self.pickup_item.potion()?;
|
||||
Some(crate::brewing::potion_color(Some(potion)))
|
||||
(!crate::brewing::contents_empty(&self.pickup_item))
|
||||
.then(|| crate::brewing::contents_color(&self.pickup_item))
|
||||
}
|
||||
/// The `AbstractArrow` constructor's `pickupItemStack`: the drawn ammo
|
||||
/// (one item). Ammo drawn without being used up (`INTANGIBLE_PROJECTILE`)
|
||||
|
||||
@@ -85,6 +85,7 @@ pub mod join_seq;
|
||||
pub mod lan;
|
||||
pub mod landing;
|
||||
pub mod legacy_combat;
|
||||
pub mod living_death;
|
||||
pub mod locator_bar;
|
||||
pub mod lod_net;
|
||||
pub mod menu;
|
||||
|
||||
@@ -137,6 +137,15 @@ pub struct DebugKeys {
|
||||
}
|
||||
|
||||
impl DebugKeys {
|
||||
/// Window focus lost, or input reset: clear held modifier state.
|
||||
pub fn reset(&mut self) {
|
||||
self.modifier_down = false;
|
||||
self.used_debug_key_as_modifier = false;
|
||||
if self.crash_key_time > 0 {
|
||||
self.crash_key_time = -1;
|
||||
}
|
||||
}
|
||||
|
||||
/// `keyPress` once the open screen (if any) did not take the key. `key`
|
||||
/// is the physical key's name (`key.keyboard.f3`); `crash_key_down`
|
||||
/// whether the crash mapping's key is held (`InputConstants.isKeyDown`).
|
||||
@@ -487,4 +496,18 @@ mod tests {
|
||||
press(&mut k, &mut h, &b, "key.keyboard.k", 2);
|
||||
assert_eq!(h.actions, [(DebugAction::ShowChunkBorders, false)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_on_focus_loss_clears_held_modifier_and_avoids_spurious_shortcuts() {
|
||||
let (mut k, mut h, b) = (DebugKeys::default(), Host::default(), KeyBinds::default());
|
||||
press(&mut k, &mut h, &b, F3, 0);
|
||||
assert!(k.modifier_down);
|
||||
// Window loses focus while F3 was down (e.g. desktop shortcut Ctrl+F3)
|
||||
k.reset();
|
||||
assert!(!k.modifier_down);
|
||||
assert!(!k.used_debug_key_as_modifier);
|
||||
// User refocuses and presses 'b': it should NOT trigger ShowHitboxes
|
||||
assert!(press(&mut k, &mut h, &b, "key.keyboard.b", 1));
|
||||
assert!(h.actions.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,8 @@ pub fn main_with(mods: crate::mod_api::Mods) {
|
||||
log::warn!("Trait mods already installed; ignoring the second list");
|
||||
}
|
||||
horizon_threads::configure_allocator();
|
||||
// The platform's features the dedicated server loads from server.toml, through the engine's hooks.
|
||||
horizon_chat_filter::install();
|
||||
crate::horizon_look::give();
|
||||
// OptiFine's and Polytone's pack formats (platform, docs/layers.md), before any pack loads.
|
||||
horizon_client::pack_formats::install();
|
||||
@@ -28,6 +30,8 @@ pub fn main_with(mods: crate::mod_api::Mods) {
|
||||
// then the Horizon parts still in this crate; none registers after this point.
|
||||
game::player_hooks::register();
|
||||
horizon_hooks::engine::seal();
|
||||
// The text crates ship (`horizon_hooks::lang`), loaded with the game's own languages.
|
||||
horizon_client::lang::install_crate_files(horizon_hooks::lang::engine::all());
|
||||
horizon_client::spans::startup::init(std::time::Instant::now());
|
||||
horizon_client::spans::trace::start_from_env();
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
@@ -230,6 +234,14 @@ pub fn main_with(mods: crate::mod_api::Mods) {
|
||||
living_jump: |entity, kind| {
|
||||
crate::mod_api::dispatch_living_jump(crate::mod_api::mods(), entity, kind);
|
||||
},
|
||||
living_death_message: |entity, kind, message| {
|
||||
crate::mod_api::dispatch_living_death_message(
|
||||
crate::mod_api::mods(),
|
||||
entity,
|
||||
kind,
|
||||
message,
|
||||
);
|
||||
},
|
||||
register_goals: |mob| {
|
||||
crate::mod_api::dispatch_register_goals(crate::mod_api::mods(), mob);
|
||||
},
|
||||
|
||||
@@ -1505,7 +1505,7 @@ impl LevelAccess for GameLevel<'_> {
|
||||
{
|
||||
return signal;
|
||||
}
|
||||
machines::container_signal(self.world, self.behaviours, self.items, self.registry, pos)
|
||||
machines::container_signal(self, pos)
|
||||
}
|
||||
fn item_frame_analog_output(&self, pos: BlockPos, facing: Direction) -> Option<i32> {
|
||||
let mut frames = self
|
||||
@@ -2328,6 +2328,8 @@ struct Game<'a> {
|
||||
last_device_recovery: Option<Instant>,
|
||||
/// CPU geometry storage retained across frames, including empty/title frames.
|
||||
entity_vertices: Vec<Vertex>,
|
||||
/// Falling models by state and selected-model address list; cleared on pack/atlas replacement.
|
||||
falling_vertices: RefCell<HashMap<BlockStateId, Vec<(Vec<usize>, Vec<Vertex>)>>>,
|
||||
/// Horizon Extras' dynamic lights this frame (`dynamic_lights.rs`), and how brightly each
|
||||
/// item glows (built the first time the extra is on).
|
||||
dynamic_lights: Vec<[f32; 4]>,
|
||||
@@ -5033,8 +5035,8 @@ impl Game<'_> {
|
||||
self.loot_block_entity(pos, &facts.name)
|
||||
};
|
||||
if let Some(entity) = self.world.block_entities.get(&key).cloned() {
|
||||
let name = self.world.block_names.get(&key).cloned();
|
||||
self.pending.record(pos, &entity, &facts.name, name);
|
||||
self.pending
|
||||
.record_from(&self.world, pos, &entity, &facts.name);
|
||||
} else if let Some(tag) = spill::saved_entity(&self.world, pos) {
|
||||
self.pending.record_saved(pos, &tag);
|
||||
}
|
||||
@@ -7158,14 +7160,7 @@ impl Game<'_> {
|
||||
let packed = (u32::from(light >> 4) * 16) | ((u32::from(light & 15) * 16) << 8);
|
||||
let start = out.len();
|
||||
self.mark_motion(start, Self::MOTION_OBJECT | u64::from(f.id));
|
||||
self.block_model_vertices(
|
||||
f.state,
|
||||
horizon_core::random::position_seed(f.start_pos),
|
||||
glam::Mat4::from_translation(o),
|
||||
packed,
|
||||
false,
|
||||
&mut out,
|
||||
);
|
||||
self.falling_model_vertices(f.state, f.start_pos, o, packed, &mut out);
|
||||
let tint = horizon_client::mobs::render::abgr(tint);
|
||||
for vertex in &mut out[start..] {
|
||||
vertex.color = horizon_client::hud_layout::multiply(vertex.color, tint);
|
||||
@@ -8211,10 +8206,7 @@ impl Game<'_> {
|
||||
}
|
||||
if let Some(kind) = mob {
|
||||
if let Some(id) = self.spawn_mob_at(kind, (target[0], target[1], target[2]), true) {
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
|
||||
horizon_client::entity_nbt::apply_item_components(m, stack);
|
||||
}
|
||||
self.mob_placed(id)?;
|
||||
self.mob_placed(id, stack)?;
|
||||
}
|
||||
self.post_block_game_event(
|
||||
"minecraft:entity_place",
|
||||
@@ -9972,20 +9964,10 @@ impl Game<'_> {
|
||||
self.drag_button = None;
|
||||
}
|
||||
if let WindowEvent::ModifiersChanged(m) = &event {
|
||||
self.modifiers = m.state();
|
||||
horizon_client_input::modifiers::reconcile(
|
||||
self.modifiers,
|
||||
&mut self.physical_keys,
|
||||
&self.settings.keys,
|
||||
&mut self.toggles,
|
||||
[self.settings.toggle_crouch, self.settings.toggle_sprint],
|
||||
[
|
||||
self.pad.holds_action("key.sneak"),
|
||||
self.pad.holds_action("key.sprint"),
|
||||
],
|
||||
);
|
||||
self.sync_toggle(SNEAK);
|
||||
self.sync_toggle(SPRINT);
|
||||
self.update_modifiers(m.state());
|
||||
}
|
||||
if let WindowEvent::Focused(false) = &event {
|
||||
self.update_modifiers(winit::keyboard::ModifiersState::empty());
|
||||
}
|
||||
// `PackSelectionScreen.onFilesDrop` on the Create New World screen.
|
||||
if let WindowEvent::DroppedFile(path) = &event
|
||||
@@ -10391,6 +10373,8 @@ impl Game<'_> {
|
||||
let held = self.captured;
|
||||
if self.control.is_none() {
|
||||
self.keys.clear();
|
||||
self.modifiers = winit::keyboard::ModifiersState::empty();
|
||||
self.debug_keys.reset();
|
||||
// Latched sneak/sprint live in `toggles`, not in the
|
||||
// physical keys: put them back so a short alt-tab
|
||||
// (no pause screen) does not drop them. Only a mapping
|
||||
@@ -10398,21 +10382,18 @@ impl Game<'_> {
|
||||
// no state to keep, and an unfocused window never gets
|
||||
// its key release, so leaving it down would keep the
|
||||
// player sneaking or sprinting until the key is tapped.
|
||||
for (slot, needs_toggle) in [
|
||||
let toggles = [
|
||||
self.settings.toggle_crouch,
|
||||
self.settings.toggle_sprint,
|
||||
self.settings.toggle_attack,
|
||||
self.settings.toggle_use,
|
||||
]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
];
|
||||
for (slot, needs_toggle) in toggles.into_iter().enumerate() {
|
||||
self.toggles.0[slot].set_down(false, needs_toggle);
|
||||
}
|
||||
self.sync_toggle(0);
|
||||
self.sync_toggle(1);
|
||||
self.left = false;
|
||||
self.right = false;
|
||||
(self.left, self.right) = (false, false);
|
||||
self.capture(false);
|
||||
}
|
||||
self.regrab_on_refocus = held;
|
||||
@@ -11685,6 +11666,7 @@ impl<'a> Game<'a> {
|
||||
renderer: None,
|
||||
last_device_recovery: None,
|
||||
entity_vertices: Vec::new(),
|
||||
falling_vertices: Default::default(),
|
||||
dynamic_lights: Vec::new(),
|
||||
luminance: Default::default(),
|
||||
see_through_vertices: Vec::new(),
|
||||
|
||||
@@ -230,15 +230,10 @@ impl adv::World for View<'_, '_> {
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// `Level.canSeeSky`: sky light 15 at the position, whatever stands
|
||||
/// above it, as `MobWorld` reads it for the spawn rules.
|
||||
fn can_see_sky(&self, p: [i32; 3]) -> bool {
|
||||
(p[1] + 1..p[1] + 200).all(|y| {
|
||||
!self
|
||||
.0
|
||||
.res
|
||||
.facts()
|
||||
.get(self.0.world.block(p[0], y, p[2]))
|
||||
.full_collision
|
||||
})
|
||||
self.0.mob_world_base(&[]).can_see_sky(p[0], p[1], p[2])
|
||||
}
|
||||
fn light(&self, p: [i32; 3]) -> i32 {
|
||||
let l = self.0.world.light(p[0], p[1], p[2]);
|
||||
|
||||
@@ -153,7 +153,18 @@ impl Game<'_> {
|
||||
.shulkers
|
||||
.get_mut(&pos)
|
||||
.map(|shulker| shulker.tick(pos, facing));
|
||||
if let Some(push) = tick.and_then(|tick| tick.push) {
|
||||
let Some(tick) = tick else { continue };
|
||||
// `ShulkerBoxBlockEntity.doNeighborUpdates` at the start and the
|
||||
// end of an opening or closing lid: the box's collision shape
|
||||
// moved, so the neighbours' shapes and their redstone updates run.
|
||||
for _ in 0..tick.neighbor_updates {
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]);
|
||||
let at = BlockPos::new(pos[0], pos[1], pos[2]);
|
||||
self.with_level(|b, level, ticks| {
|
||||
b.shulker_neighbor_updates(level, ticks, at, state)
|
||||
});
|
||||
}
|
||||
if let Some(push) = tick.push {
|
||||
pushes.push((pos, facing, push));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,8 +47,9 @@ fn clone_item<'a>(name: &str, block_item: &'a str) -> &'a str {
|
||||
"minecraft:kelp_plant" => "minecraft:kelp",
|
||||
"minecraft:twisting_vines_plant" => "minecraft:twisting_vines",
|
||||
"minecraft:weeping_vines_plant" => "minecraft:weeping_vines",
|
||||
// CandleCakeBlock: new ItemStack(Blocks.CAKE), for every colour.
|
||||
_ if name.ends_with("_candle_cake") => "minecraft:cake",
|
||||
// CandleCakeBlock: new ItemStack(Blocks.CAKE), for every colour and
|
||||
// the plain `minecraft:candle_cake`, whose own item is air.
|
||||
_ if name == "minecraft:candle_cake" || name.ends_with("_candle_cake") => "minecraft:cake",
|
||||
_ => block_item,
|
||||
}
|
||||
}
|
||||
@@ -424,6 +425,7 @@ mod tests {
|
||||
("minecraft:kelp_plant", "minecraft:kelp"),
|
||||
("minecraft:twisting_vines_plant", "minecraft:twisting_vines"),
|
||||
("minecraft:weeping_vines_plant", "minecraft:weeping_vines"),
|
||||
("minecraft:candle_cake", "minecraft:cake"),
|
||||
("minecraft:white_candle_cake", "minecraft:cake"),
|
||||
("minecraft:light_blue_candle_cake", "minecraft:cake"),
|
||||
] {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! pixels, 1024 characters, 100 pages), signing it into a written book,
|
||||
//! reading written books, and lecterns (placing a book, turning its pages
|
||||
//! with a redstone pulse, the comparator output and taking the book).
|
||||
use super::{Game, lan::LanHost, screen_info};
|
||||
use super::{Game, chat::PLAYER_NAME, lan::LanHost, screen_info};
|
||||
use horizon_client::{
|
||||
containers::{BlockEntity, Lectern},
|
||||
first_person::Hand,
|
||||
@@ -18,6 +18,7 @@ use horizon_client::{
|
||||
use horizon_core::position::BlockPos;
|
||||
use horizon_protocol::packets::play::{self, Clientbound as C, Serverbound as S};
|
||||
use horizon_world::level::flags as level_flags;
|
||||
use horizon_world::nbt::Tag;
|
||||
use winit::{
|
||||
event::{ElementState, KeyEvent},
|
||||
keyboard::{KeyCode, PhysicalKey},
|
||||
@@ -213,6 +214,15 @@ impl Game<'_> {
|
||||
"minecraft:written_book" => true,
|
||||
_ => return false,
|
||||
};
|
||||
// `ServerPlayer.openItemGui` resolves a written book for the player
|
||||
// opening it before the screen reads it.
|
||||
let held;
|
||||
let stack = if written && self.resolve_held_book() {
|
||||
held = self.held().cloned();
|
||||
held.as_ref().unwrap_or(stack)
|
||||
} else {
|
||||
stack
|
||||
};
|
||||
if self.lan_acting.is_some() {
|
||||
if written {
|
||||
let hand = i32::from(self.hand == Hand::Off);
|
||||
@@ -227,6 +237,56 @@ impl Game<'_> {
|
||||
self.swing_arm();
|
||||
true
|
||||
}
|
||||
/// `ServerPlayer.openItemGui` -> `WrittenBookContent.resolveForItem`: the
|
||||
/// selectors and scores of the written book in the acting hand become
|
||||
/// their text for the player opening it, and the book is marked resolved
|
||||
/// (so it is never resolved again). True when the pages were rewritten.
|
||||
fn resolve_held_book(&mut self) -> bool {
|
||||
let Some(mut book) = self
|
||||
.held()
|
||||
.and_then(|s| s.data())
|
||||
.and_then(|d| d.written.clone())
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
if book.resolved {
|
||||
return false;
|
||||
}
|
||||
let string = |v: &Tag| match v {
|
||||
Tag::String(s) => Some(s.to_string_lossy()),
|
||||
_ => None,
|
||||
};
|
||||
let changed = book.resolve(|tag| {
|
||||
let Tag::Compound(c) = tag else { return None };
|
||||
if let Some(Tag::String(selector)) = c.get(&"selector".into()) {
|
||||
let selector = selector.to_string_lossy();
|
||||
return Some(self.resolve_selector_text(&selector));
|
||||
}
|
||||
let score = c.get(&"score".into())?;
|
||||
let name = score.get("name").and_then(string).unwrap_or_default();
|
||||
let objective = score.get("objective").and_then(string).unwrap_or_default();
|
||||
let name = if name == "*" {
|
||||
PLAYER_NAME
|
||||
} else {
|
||||
name.as_str()
|
||||
};
|
||||
Some(
|
||||
self.score
|
||||
.board
|
||||
.score(name, &objective)
|
||||
.map_or_else(String::new, |s| s.value.to_string()),
|
||||
)
|
||||
});
|
||||
if let Some(stack) = self.held_mut() {
|
||||
stack.data_mut().written = Some(book);
|
||||
}
|
||||
// A guest's copy of the book lives in its mirrored inventory: the
|
||||
// host sends the resolved slot back with the rest of the inventory.
|
||||
if self.lan_acting.is_some() {
|
||||
self.guest_inventory_resend = true;
|
||||
}
|
||||
changed
|
||||
}
|
||||
/// The screen a book in hand opens: `BookEditScreen` for a book and
|
||||
/// quill, `BookViewScreen` for a written book.
|
||||
fn book_screen(&self, stack: &ItemStack) -> Option<BookScreen> {
|
||||
@@ -349,6 +409,7 @@ impl Game<'_> {
|
||||
generation: 0,
|
||||
pages,
|
||||
page_tags: Vec::new(),
|
||||
resolved: false,
|
||||
});
|
||||
*stack = written;
|
||||
} else {
|
||||
|
||||
@@ -23,3 +23,34 @@ fn breeding_drops_one_orb_of_the_whole_roll() {
|
||||
}
|
||||
assert!(values.iter().any(|v| matches!(v, 4..=6)), "{values:?}");
|
||||
}
|
||||
|
||||
/// `Turtle.TurtleBreedGoal.breed` overrides `Animal.breed`, so a bred turtle
|
||||
/// never runs `finalizeSpawnChildFromBreeding`'s heart burst (entity event
|
||||
/// 18); `Frog.spawnChildFromBreeding` still does.
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources (HORIZON_TEST_RESOURCES)"]
|
||||
fn a_bred_turtle_posts_no_heart_burst_but_a_bred_frog_does() {
|
||||
let mut game = super::super::native_machines::tests::server_game();
|
||||
game.audio.capture = Some(Vec::new());
|
||||
for (kind_id, hearts) in [("minecraft:turtle", 0), ("minecraft:frog", 1)] {
|
||||
let kind = game.res.mob_types().get(kind_id).unwrap().clone();
|
||||
game.mobs
|
||||
.push(Mob::new(1, kind.clone(), Vec3::new(8.5, 64.0, 8.5), 0.0));
|
||||
game.mobs
|
||||
.push(Mob::new(2, kind, Vec3::new(9.5, 64.0, 8.5), 0.0));
|
||||
game.lan_entity_events.clear();
|
||||
game.breed(1, 2, None);
|
||||
let burst = game
|
||||
.lan_entity_events
|
||||
.iter()
|
||||
.filter(|(id, event, _)| *id == 1 && *event == 18)
|
||||
.count();
|
||||
assert_eq!(
|
||||
burst, hearts,
|
||||
"{kind_id} heartbeat events: {:?}",
|
||||
game.lan_entity_events
|
||||
);
|
||||
game.mobs.clear();
|
||||
game.orbs.clear();
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,8 @@
|
||||
use super::Game;
|
||||
use horizon_assets::item_model::DisplayContext;
|
||||
use horizon_client::{
|
||||
Result, block_entities::Pos, block_entity_render::vertex_light, containers::BlockEntity,
|
||||
items::ItemStack, mesher::Vertex, mobs::Who,
|
||||
Result, actions::Action, block_entities::Pos, block_entity_render::vertex_light,
|
||||
containers::BlockEntity, items::ItemStack, mesher::Vertex, mobs::Who,
|
||||
};
|
||||
use horizon_core::position::BlockPos;
|
||||
|
||||
@@ -25,6 +25,13 @@ impl Game<'_> {
|
||||
/// to the server. Whether the food went in; None when this isn't a
|
||||
/// campfire use, so the item's own use runs.
|
||||
pub(super) fn use_on_campfire(&mut self, pos: [i32; 3]) -> Result<Option<bool>> {
|
||||
// `ServerPlayerGameMode.useItemOn`'s `suppressUsingBlock`: sneaking
|
||||
// with a stack in either hand never reaches the block's `useItemOn`,
|
||||
// so the food stays in the hand and the item's own use (eating)
|
||||
// runs instead.
|
||||
if self.keys.held(Action::Sneak) {
|
||||
return Ok(None);
|
||||
}
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]);
|
||||
if !is_campfire(&self.res.facts().get(state).name) {
|
||||
return Ok(None);
|
||||
@@ -60,6 +67,11 @@ impl Game<'_> {
|
||||
);
|
||||
self.mark_block_entity_updated(key);
|
||||
}
|
||||
// `ServerPlayerGameMode.useItemOn` triggers ITEM_USED_ON_BLOCK with
|
||||
// the stack before the use once `useItemOn` consumed the click, both
|
||||
// when the food went in (SUCCESS_SERVER) and on a full campfire
|
||||
// (CONSUME).
|
||||
self.trigger_block("minecraft:item_used_on_block", pos, &held);
|
||||
Ok(Some(placed))
|
||||
}
|
||||
|
||||
@@ -198,3 +210,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "campfire_tests.rs"]
|
||||
mod campfire_tests;
|
||||
@@ -0,0 +1,125 @@
|
||||
//! Campfire food placement's gate and criterion: `CampfireBlock.useItemOn`
|
||||
//! reached through `ServerPlayerGameMode.useItemOn`, which skips the block
|
||||
//! while sneaking with something in hand and then triggers
|
||||
//! ITEM_USED_ON_BLOCK for the use it consumed.
|
||||
use super::*;
|
||||
use crate::game::{Hit, native_machines::tests::server_game};
|
||||
use horizon_client::{
|
||||
advancements::{Advancement, Criterion, Rewards, Tree},
|
||||
held::Source,
|
||||
items::ItemStack,
|
||||
motion::PlayerMotion,
|
||||
};
|
||||
use horizon_core::geometry::Vec3;
|
||||
use horizon_world::lazy_json::LazyJson;
|
||||
use std::sync::Arc;
|
||||
use winit::keyboard::KeyCode;
|
||||
|
||||
/// A server game with a campfire at `pos` (its block entity too) and beef in
|
||||
/// the main hand, the click forced onto that block.
|
||||
fn campfire_game(pos: [i32; 3]) -> Game<'static> {
|
||||
let mut game = server_game();
|
||||
// A local player, not a dedicated server's silent world: criteria and
|
||||
// statistics apply to them.
|
||||
game.options.dedicated = None;
|
||||
let state = game
|
||||
.pack
|
||||
.assets
|
||||
.registry
|
||||
.default_state("minecraft:campfire")
|
||||
.unwrap();
|
||||
game.world.set(pos[0], pos[1], pos[2], state).unwrap();
|
||||
game.world.block_entities.put(
|
||||
(pos[0], pos[1], pos[2]),
|
||||
BlockEntity::Campfire(Box::default()),
|
||||
);
|
||||
let mut player = PlayerMotion::new(
|
||||
game.motion,
|
||||
Vec3::new(
|
||||
f64::from(pos[0]) + 0.5,
|
||||
f64::from(pos[1]),
|
||||
f64::from(pos[2]) + 2.5,
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
// `Player.canEat`: hungry, so the beef's own use can start.
|
||||
player.health.food.food = 6;
|
||||
game.player = Some(player);
|
||||
game.creative = false;
|
||||
*game.held_mut() = Some(ItemStack::new("minecraft:beef", 4));
|
||||
game.forced_hit = Some(Hit {
|
||||
pos,
|
||||
face: [0, 1, 0],
|
||||
point: [
|
||||
f64::from(pos[0]) + 0.5,
|
||||
f64::from(pos[1]) + 1.0,
|
||||
f64::from(pos[2]) + 0.5,
|
||||
],
|
||||
});
|
||||
game
|
||||
}
|
||||
|
||||
/// The campfire's four cooking slots.
|
||||
fn slots(game: &Game<'_>, pos: [i32; 3]) -> usize {
|
||||
match game.world.block_entities.get(&(pos[0], pos[1], pos[2])) {
|
||||
Some(BlockEntity::Campfire(c)) => c.items.iter().filter(|i| i.is_some()).count(),
|
||||
_ => panic!("no campfire block entity at {pos:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// `ServerPlayerGameMode.useItemOn`: with something in hand, sneaking skips
|
||||
/// `state.useItemOn` entirely (`suppressUsingBlock`), so the beef is neither
|
||||
/// placed nor consumed; the item's own use, eating, starts instead. Without
|
||||
/// the sneak the campfire takes the beef, and the use it consumed triggers
|
||||
/// ITEM_USED_ON_BLOCK with the stack before the use.
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_sneak_keeps_the_food_and_placing_it_fires_item_used_on_block() {
|
||||
const POS: [i32; 3] = [2, 4, 3];
|
||||
let mut game = campfire_game(POS);
|
||||
// The tree a data pack with one `item_used_on_block` criterion on this
|
||||
// campfire would be indexed into.
|
||||
let mut tree = Tree::default();
|
||||
tree.list.push(Advancement {
|
||||
id: "horizon:test/campfire".into(),
|
||||
parent: None,
|
||||
display: None,
|
||||
criteria: vec![Criterion {
|
||||
name: "food".into(),
|
||||
trigger: "minecraft:item_used_on_block".into(),
|
||||
conditions: LazyJson::from(serde_json::json!({
|
||||
"location": {
|
||||
"type": "minecraft:location_check",
|
||||
"predicate": {"block": {"blocks": "minecraft:campfire"}}
|
||||
}
|
||||
})),
|
||||
}],
|
||||
requirements: vec![vec!["food".into()]],
|
||||
rewards: Rewards::default(),
|
||||
});
|
||||
tree.by_id.insert("horizon:test/campfire".into(), 0);
|
||||
tree.by_trigger
|
||||
.insert("minecraft:item_used_on_block".into(), vec![(0, 0)]);
|
||||
game.adv.tree = Arc::new(tree);
|
||||
let hit = game.forced_hit.unwrap();
|
||||
game.keys.press(KeyCode::ShiftLeft, Source::Keyboard);
|
||||
assert!(game.use_hand(Some(&hit)).unwrap());
|
||||
assert_eq!(slots(&game, POS), 0, "the sneak put no beef on the fire");
|
||||
assert_eq!(game.held().unwrap().count, 4, "the beef stayed in hand");
|
||||
assert!(game.using.is_some(), "the beef is being eaten");
|
||||
assert!(
|
||||
!game.adv.progress.has("horizon:test/campfire", "food"),
|
||||
"the skipped block use triggers nothing"
|
||||
);
|
||||
// Stop eating, let go of the sneak: the same click now feeds the fire.
|
||||
game.using = None;
|
||||
game.keys.release(&KeyCode::ShiftLeft, Source::Keyboard);
|
||||
assert!(game.use_hand(Some(&hit)).unwrap());
|
||||
assert_eq!(slots(&game, POS), 1, "the beef went on the fire");
|
||||
assert_eq!(game.held().unwrap().count, 3, "one beef was used");
|
||||
assert!(game.using.is_none(), "the click was consumed");
|
||||
assert!(
|
||||
game.adv.progress.has("horizon:test/campfire", "food"),
|
||||
"the placed food fired item_used_on_block"
|
||||
);
|
||||
}
|
||||
@@ -3,11 +3,11 @@
|
||||
//! (never as `chat`), a temporary ban goes into banned-players.json, and operators mute and unmute
|
||||
//! players by hand with `/mute`, `/tempmute`, `/mutelist` and `/unmute` (the same words on the console).
|
||||
use super::{Game, dedicated, lan::LanHost};
|
||||
use horizon_chat_filter::Action;
|
||||
use horizon_client::lan::hub::{HostEvent, chat_filter::Place};
|
||||
use horizon_client::text::Text;
|
||||
use horizon_protocol::packets::play::{self, Clientbound as C};
|
||||
use horizon_protocol::text::{literal, translatable};
|
||||
use horizon_server_files::chat_guard::{self, Action};
|
||||
use horizon_server_files::user_lists::{BanEntry, NameAndId};
|
||||
use horizon_world::nbt::Tag;
|
||||
|
||||
@@ -42,8 +42,8 @@ impl Reply {
|
||||
}
|
||||
|
||||
pub(super) fn duration(millis: i64) -> Text {
|
||||
use horizon_server_files::duration::{DurationUnit, duration_parts};
|
||||
let (count, unit) = duration_parts(millis);
|
||||
use chat_guard::DurationUnit;
|
||||
let (count, unit) = chat_guard::duration_parts(millis);
|
||||
let key = match (unit, count == 1) {
|
||||
(DurationUnit::Minute, true) => "horizon.moderation.duration.minute",
|
||||
(DurationUnit::Minute, false) => "horizon.moderation.duration.minutes",
|
||||
@@ -91,10 +91,7 @@ pub(super) const MUTE_USAGES: [&str; 4] = [
|
||||
/// `/mute <player> [duration] [reason]` (`words`, the command first): how long, when the word after
|
||||
/// the name is a duration (`10m`, `2h`, `1d`; none: until `/unmute`), and the index the reason starts at.
|
||||
fn mute_time(words: &[&str]) -> (Option<i64>, usize) {
|
||||
match words
|
||||
.get(2)
|
||||
.and_then(|w| horizon_server_files::duration::parse_duration(w))
|
||||
{
|
||||
match words.get(2).and_then(|w| chat_guard::parse_duration(w)) {
|
||||
Some(ms) => (Some(ms), 3),
|
||||
None => (None, 2),
|
||||
}
|
||||
@@ -133,7 +130,7 @@ impl Game<'_> {
|
||||
count: u32,
|
||||
action: Action,
|
||||
) {
|
||||
let now = horizon_server_files::duration::now_millis();
|
||||
let now = chat_guard::now_millis();
|
||||
let summary = format!(
|
||||
"[chat-filter] Refused {name}'s {} (hit {count}: {})",
|
||||
place.name(),
|
||||
@@ -145,12 +142,10 @@ impl Game<'_> {
|
||||
}
|
||||
// The text only goes to the filter's own log, for operators: never to the console or the stream.
|
||||
dedicated::log("INFO", &summary);
|
||||
if let Some(path) = host.hub.chat_filter_log() {
|
||||
let line =
|
||||
horizon_chat_filter::log_line(now, name, uuid, place.name(), count, action, text);
|
||||
if let Err(e) = horizon_chat_filter::append_log(std::path::Path::new(path), &line) {
|
||||
dedicated::log("WARN", &format!("[chat-filter] {path}: {e}"));
|
||||
}
|
||||
if let Some(guard) = host.hub.chat_filter.as_deref_mut()
|
||||
&& let Err(e) = guard.log(now, name, uuid, place, count, action, text)
|
||||
{
|
||||
dedicated::log("WARN", &format!("[chat-filter] {e}"));
|
||||
}
|
||||
dedicated::events::record(
|
||||
"chat_filtered",
|
||||
@@ -281,7 +276,7 @@ impl Game<'_> {
|
||||
let reason = |from: usize| (words.len() > from).then(|| words[from..].join(" "));
|
||||
match words {
|
||||
["mutelist"] => {
|
||||
let now = horizon_server_files::duration::now_millis();
|
||||
let now = chat_guard::now_millis();
|
||||
let mutes = host.hub.mutes();
|
||||
if mutes.is_empty() {
|
||||
return vec![Reply::translated("horizon.moderation.mutelist.empty", &[])];
|
||||
@@ -319,15 +314,13 @@ impl Game<'_> {
|
||||
let (time, from) = mute_time(words);
|
||||
vec![self.mute(host, name, time, reason(from), by)]
|
||||
}
|
||||
["tempmute", name, time, ..] => {
|
||||
match horizon_server_files::duration::parse_duration(time) {
|
||||
Some(ms) => vec![self.mute(host, name, Some(ms), reason(3), by)],
|
||||
None => vec![Reply::translated(
|
||||
"horizon.moderation.invalid_duration",
|
||||
&[Text::literal(*time)],
|
||||
)],
|
||||
}
|
||||
}
|
||||
["tempmute", name, time, ..] => match chat_guard::parse_duration(time) {
|
||||
Some(ms) => vec![self.mute(host, name, Some(ms), reason(3), by)],
|
||||
None => vec![Reply::translated(
|
||||
"horizon.moderation.invalid_duration",
|
||||
&[Text::literal(*time)],
|
||||
)],
|
||||
},
|
||||
["mute", ..] => vec![Reply::translated("horizon.moderation.usage.mute", &[])],
|
||||
["tempmute", ..] => vec![Reply::translated("horizon.moderation.usage.tempmute", &[])],
|
||||
["unmute", ..] => vec![Reply::translated("horizon.moderation.usage.unmute", &[])],
|
||||
@@ -355,10 +348,10 @@ impl Game<'_> {
|
||||
let Some((uuid, name)) = user else {
|
||||
return Reply::no_player();
|
||||
};
|
||||
let until = time.map(|ms| horizon_server_files::duration::now_millis() + ms);
|
||||
let until = time.map(|ms| chat_guard::now_millis() + ms);
|
||||
let what = time.map_or_else(
|
||||
|| "until unmuted".into(),
|
||||
|ms| format!("for {}", horizon_server_files::duration::duration_text(ms)),
|
||||
|ms| format!("for {}", chat_guard::duration_text(ms)),
|
||||
);
|
||||
let why = reason
|
||||
.as_deref()
|
||||
|
||||
@@ -608,7 +608,13 @@ impl Game<'_> {
|
||||
if !host && !guest {
|
||||
return;
|
||||
}
|
||||
let message = self.mob_death_message(m, killer);
|
||||
let mut built = Some(self.mob_death_message(m, killer));
|
||||
let kind = super::native_hooks::mob_kind(m);
|
||||
horizon_client::living_death::native_living_death_message(m.id, &kind, &mut built);
|
||||
// A native mod may have changed the message, or set it to None to tell the owner nothing.
|
||||
let Some(message) = built else {
|
||||
return;
|
||||
};
|
||||
if host {
|
||||
self.system_message(message);
|
||||
} else if let Some(lan) = &self.lan {
|
||||
|
||||
@@ -8,6 +8,7 @@ pub(super) use data::place_summoned;
|
||||
mod effects;
|
||||
pub(super) mod execute;
|
||||
pub(super) mod extra;
|
||||
mod item_count;
|
||||
pub(in crate::game) mod items;
|
||||
mod misc;
|
||||
mod place;
|
||||
@@ -3951,6 +3952,12 @@ impl Game<'_> {
|
||||
};
|
||||
let present = components.get(&id.as_str().into());
|
||||
let result = match term[split..].chars().next() {
|
||||
// Both count wrappers in `ItemPredicateArgument` use the live
|
||||
// stack count and the same integer bounds matcher.
|
||||
Some('=' | '~') if id == "minecraft:count" => {
|
||||
let value = horizon_world::nbt::parse_snbt(term[split + 1..].trim());
|
||||
value.is_ok_and(|(v, _)| item_count::matches(stack.count, &v))
|
||||
}
|
||||
Some('=') => {
|
||||
let value = horizon_world::nbt::parse_snbt(term[split + 1..].trim())
|
||||
.ok()
|
||||
@@ -3964,28 +3971,11 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
Some('~') => {
|
||||
// Sub-predicates: `count` and component predicates.
|
||||
// Component sub-predicates.
|
||||
let value = horizon_world::nbt::parse_snbt(term[split + 1..].trim())
|
||||
.ok()
|
||||
.map(|(t, _)| t);
|
||||
match (id.as_str(), value) {
|
||||
("minecraft:count", Some(v)) => {
|
||||
let n = i64::from(stack.count);
|
||||
match &v {
|
||||
horizon_world::nbt::Tag::Compound(_) => {
|
||||
let get = |k: &str| match v.get(k) {
|
||||
Some(horizon_world::nbt::Tag::Int(i)) => {
|
||||
Some(i64::from(*i))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
get("min").is_none_or(|m| n >= m)
|
||||
&& get("max").is_none_or(|m| n <= m)
|
||||
}
|
||||
horizon_world::nbt::Tag::Int(i) => n == i64::from(*i),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
// `MinMaxBounds.Ints.matches`: DamagePredicate's damage-only
|
||||
// Int bounds shape; effective DAMAGE presence is required.
|
||||
// Other fields keep the entire existing predicate path.
|
||||
@@ -6044,3 +6034,7 @@ mod global_selector_order_tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "item_count_predicate_tests.rs"]
|
||||
mod item_count_predicate_tests;
|
||||
@@ -516,9 +516,11 @@ impl Game<'_> {
|
||||
new.glow = old.glow;
|
||||
// ItemFrame.readAdditionalSaveData: ItemStack.matches includes
|
||||
// count, item and components, before adopting the new stack.
|
||||
// It compares the stack as read; setItem keeps one of it
|
||||
// only after, as `new.item` already does.
|
||||
let read = tag.get("Item").and_then(save::item_from_nbt);
|
||||
if old.item.as_ref().is_some_and(|a| {
|
||||
new.item
|
||||
.as_ref()
|
||||
read.as_ref()
|
||||
.is_none_or(|b| a.count != b.count || !a.same_item_same_components(b))
|
||||
}) {
|
||||
let old = old.clone();
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
//! The `count` wrapper of an item predicate (`ItemPredicateArgument`): both
|
||||
//! `count=` and `count~` read the live stack count.
|
||||
|
||||
use horizon_world::nbt::Tag;
|
||||
|
||||
/// `MinMaxBounds.Ints.matches` on the stack count: an exact Int, or a
|
||||
/// compound with optional Int `min` and `max`.
|
||||
pub(super) fn matches(count: impl Into<i64>, want: &Tag) -> bool {
|
||||
let n = count.into();
|
||||
match want {
|
||||
Tag::Compound(_) => {
|
||||
let get = |k: &str| match want.get(k) {
|
||||
Some(Tag::Int(i)) => Some(i64::from(*i)),
|
||||
_ => None,
|
||||
};
|
||||
get("min").is_none_or(|m| n >= m) && get("max").is_none_or(|m| n <= m)
|
||||
}
|
||||
Tag::Int(i) => n == i64::from(*i),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
@@ -535,6 +535,15 @@ impl Game<'_> {
|
||||
let Some(stack) = stack.filter(|s| &*s.item != "minecraft:air" && s.count > 0) else {
|
||||
return true;
|
||||
};
|
||||
// SulfurCube.getEquipmentSlotForItem: a swallowable item is a body item.
|
||||
if let Slot::Mob(id, "body") = s
|
||||
&& self.mobs.iter().find(|m| m.id == *id).is_some_and(|m| {
|
||||
m.species() == horizon_client::mobs::Species::SulfurCube
|
||||
&& horizon_client::mobs::sulfur_cube::swallowable(m, &stack.item)
|
||||
})
|
||||
{
|
||||
return true;
|
||||
}
|
||||
self.items()
|
||||
.get(&stack.item)
|
||||
.is_some_and(|info| info.equip_slot.as_deref() == Some(name))
|
||||
@@ -1979,6 +1988,31 @@ mod mob_equipment_command_tests {
|
||||
}
|
||||
});
|
||||
}
|
||||
/// `SulfurCube.getEquipmentSlotForItem`: a `#sulfur_cube_swallowable`
|
||||
/// item is a body item, so the body's `SlotAccess` takes it (#12038).
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_sulfur_cubes_body_takes_a_swallowable_block() {
|
||||
with_game(|game| {
|
||||
assert!(game.run_command("summon sulfur_cube 4.5 200 4.5 {NoAI:1b,Tags:[\"cube\"]}"));
|
||||
let body = |game: &Game<'_>| {
|
||||
let m = game
|
||||
.mobs
|
||||
.iter()
|
||||
.find(|m| m.kind.id == "minecraft:sulfur_cube");
|
||||
m.and_then(|m| m.extra.body.clone())
|
||||
};
|
||||
let replace = "item replace entity @e[tag=cube,limit=1] armor.body with";
|
||||
assert!(game.run_command(&format!("{replace} white_wool")));
|
||||
assert_eq!(body(game).as_deref(), Some("minecraft:white_wool"));
|
||||
// An item outside the tag stays out, and the body stays on.
|
||||
assert!(!game.run_command(&format!("{replace} diamond")));
|
||||
assert_eq!(body(game).as_deref(), Some("minecraft:white_wool"));
|
||||
// An empty stack always fits: the body comes off.
|
||||
assert!(game.run_command(&format!("{replace} air")));
|
||||
assert_eq!(body(game), None);
|
||||
});
|
||||
}
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn effective_item_composition_rejects_before_destination_mutation() {
|
||||
|
||||
@@ -770,7 +770,13 @@ impl Game<'_> {
|
||||
let mut pending = crate::game::spill::Pending::default();
|
||||
let key = (p[0], p[1], p[2]);
|
||||
let has_block_entity = if let Some(entity) = self.world.block_entities.get(&key) {
|
||||
pending.record(p, entity, &name, self.world.block_names.get(&key).cloned());
|
||||
pending.record(
|
||||
p,
|
||||
entity,
|
||||
&name,
|
||||
self.world.block_names.get(&key).cloned(),
|
||||
self.world.block_lock(key),
|
||||
);
|
||||
true
|
||||
} else if let Some(saved) = self.block_entity_nbt(p) {
|
||||
pending.record_saved(p, &saved);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
//! chunk-generation spawning (`NaturalSpawner`), mob events (attacks,
|
||||
//! breeding, conversions, drops), projectiles, player interactions
|
||||
//! (feeding, shearing, milking, spawn eggs, throwing) and rendering.
|
||||
mod bred_draws;
|
||||
mod mob_geometry;
|
||||
mod projectile_world;
|
||||
mod pufferfish_player;
|
||||
@@ -1145,8 +1146,8 @@ impl Game<'_> {
|
||||
/// `DistanceManager.inEntityTickingRange` in an entity-ticking chunk
|
||||
/// (block coordinates), from every player's tickets (`update_chunk_tickets`).
|
||||
pub(super) fn entity_ticking_area(&self) -> impl Fn(i32, i32) -> bool + use<> {
|
||||
self.tickets
|
||||
.entity_ticking_area(&self.world.chunks, self.remote.is_none())
|
||||
let levels = self.tickets.levels();
|
||||
move |x, z| levels.entity_ticking_at(x, z)
|
||||
}
|
||||
/// The entity phase of `tick_creatures`: every mob's tick, deaths, and the
|
||||
/// projectiles and effects that follow from them.
|
||||
@@ -3869,18 +3870,18 @@ impl Game<'_> {
|
||||
q.extra.in_love = 0;
|
||||
}
|
||||
let q = self.mobs.iter().find(|m| m.id == partner).cloned();
|
||||
// Animal.finalizeSpawnChildFromBreeding: entity event 18 for the parent.
|
||||
self.mob_event_particles(parent, 18);
|
||||
// Animal.finalizeSpawnChildFromBreeding's heart burst, not a turtle's.
|
||||
if p.species() != Species::Turtle {
|
||||
self.mob_event_particles(parent, 18);
|
||||
}
|
||||
// Sniffer.spawnChildFromBreeding: an egg instead of a baby.
|
||||
if p.species() == Species::Sniffer {
|
||||
self.lay_sniffer_egg(&p);
|
||||
return;
|
||||
}
|
||||
// TurtleBreedGoal.breed: the goal gave the mother her egg; only the
|
||||
// experience is left, and only with mob loot on.
|
||||
// TurtleBreedGoal.breed and Frog.spawnChildFromBreeding: only experience.
|
||||
if matches!(p.species(), Species::Turtle | Species::Frog) {
|
||||
// One `new ExperienceOrb` of 1 to 7 from the mother's random (not
|
||||
// split up as `ExperienceOrb.award` does).
|
||||
// One `new ExperienceOrb` of 1 to 7 from the mother's random.
|
||||
if self.ui.rules.bool("mob_drops")
|
||||
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == parent)
|
||||
{
|
||||
@@ -3889,9 +3890,7 @@ impl Game<'_> {
|
||||
}
|
||||
return;
|
||||
}
|
||||
// (Mooshroom draws its own coin in getOffspringVariant.)
|
||||
let coin = p.species() != Species::Mooshroom
|
||||
&& self.world.random.next_int_bound(2).unwrap_or(0) == 0;
|
||||
let coin = self.bred_coat_coin(parent, p.species());
|
||||
// Axolotl.getBreedOffspring: useRareVariant (one in 1200) makes it blue,
|
||||
// else either parent's variant. The draws come from the mother's random.
|
||||
let axolotl = if p.species() == Species::Axolotl {
|
||||
@@ -3943,7 +3942,7 @@ impl Game<'_> {
|
||||
// No Mob.finalizeSpawn either: no follow range bonus, right-handed.
|
||||
spawning::bred_offspring(child);
|
||||
// getBreedOffspring creates the baby without finalizeSpawn: it keeps
|
||||
// the default (`classic`) sound variant.
|
||||
// the default (`classic`) sound variant (a wolf picks its own below).
|
||||
child.extra.sound_variant = None;
|
||||
// Fox.getBreedOffspring runs no finalizeSpawn: nothing in the
|
||||
// baby fox's mouth.
|
||||
@@ -4058,6 +4057,7 @@ impl Game<'_> {
|
||||
c.extra.color = mixed;
|
||||
}
|
||||
}
|
||||
self.pick_bred_wolf_voice(parent, id);
|
||||
self.breeding_orb(parent, [p.position.x, p.position.y, p.position.z]);
|
||||
return;
|
||||
}
|
||||
@@ -4546,6 +4546,7 @@ impl Game<'_> {
|
||||
// The impacts the mods may cancel (`native_projectile_impact`), on a server.
|
||||
let hear = self.native_hears_impacts();
|
||||
let mut impacts: Vec<Impact> = Vec::new();
|
||||
let mut vehicle_winds = self.vehicle_winds();
|
||||
{
|
||||
let targets = self.projectile_targets();
|
||||
let breezes: Vec<u32> = self
|
||||
@@ -4653,6 +4654,7 @@ impl Game<'_> {
|
||||
};
|
||||
// The projectile as it was, to fly on if a mod cancels its impact.
|
||||
let before = (hear && !p.stuck).then(|| p.clone());
|
||||
let wind_before = vehicle_winds.before(p);
|
||||
let mob_shot = !matches!(p.owner, Some(Who::Player));
|
||||
// A player's arrows, snowballs and eggs meet every player; a
|
||||
// guest's flies from that guest (Projectile.getOwner), not
|
||||
@@ -4787,6 +4789,7 @@ impl Game<'_> {
|
||||
keep_flags[i] = false;
|
||||
continue;
|
||||
}
|
||||
vehicle_winds.met(wind_before, hit);
|
||||
if hit != Hit::None {
|
||||
if let Some(before) = before {
|
||||
impacts.push(Impact {
|
||||
@@ -4859,6 +4862,9 @@ impl Game<'_> {
|
||||
}
|
||||
self.creatures.projectiles.append(&mut projectiles);
|
||||
for (p, hit) in hits {
|
||||
let Some((p, hit)) = self.broken_vehicle_retick(p, hit, &mut vehicle_winds) else {
|
||||
continue;
|
||||
};
|
||||
// A player's arrow on another player lands as the victim's hurt.
|
||||
if let Some(victim) = self.projectile_pvp_victim(&p, hit) {
|
||||
self.projectile_hits_player(&p, hit, victim)?;
|
||||
@@ -4973,17 +4979,6 @@ impl Game<'_> {
|
||||
8.0,
|
||||
)
|
||||
}
|
||||
/// A projectile's hurt of `amount` on the boat or minecart `id`
|
||||
/// (`VehicleEntity.hurtServer`): even 0 shakes it (`Snowball`, `ThrownEgg`,
|
||||
/// `ThrownEnderpearl` `.onHitEntity`), 5 from a small fireball destroys it.
|
||||
fn hurt_vehicle(&mut self, p: &Projectile, id: u32, amount: f32) {
|
||||
let creative = p.owner == Some(Who::Player) && self.creative;
|
||||
if let Some(i) = self.boats.iter().position(|b| b.id == id) {
|
||||
self.hurt_boat(i, amount, creative);
|
||||
} else if let Some(i) = self.carts.iter().position(|c| c.id == id) {
|
||||
self.hurt_cart(i, amount, creative, false);
|
||||
}
|
||||
}
|
||||
/// `BellBlock.onProjectileHit`: a projectile that strikes the bell proper
|
||||
/// rings it, and a player's shot counts as the player's ring.
|
||||
fn bell_projectile_hit(
|
||||
@@ -5168,19 +5163,16 @@ impl Game<'_> {
|
||||
ArrowEvent::ShootEvent | ArrowEvent::Drop => {}
|
||||
}
|
||||
}
|
||||
/// `Level.isRainingAt` for arrows.
|
||||
/// The boats, minecarts and primed TNT a projectile can strike
|
||||
/// (`VehicleEntity.isPickable`, `PrimedTnt.isPickable`).
|
||||
pub(super) fn pickable_object_targets(&self) -> Vec<(Who, Aabb)> {
|
||||
let boats = self.boats.iter().map(|b| (Who::Mob(b.id), b.bounds()));
|
||||
let carts = self.carts.iter().map(|c| (Who::Mob(c.id), c.bounds()));
|
||||
let tnts = self.tnts.iter().map(|t| (Who::Mob(t.id), t.bounds()));
|
||||
boats.chain(carts).chain(tnts).collect()
|
||||
}
|
||||
/// What a projectile can hit: the mobs, the player, the dragon's parts and
|
||||
/// the End crystals (under reserved ids, game/dragon.rs).
|
||||
/// The boats and minecarts a projectile can strike.
|
||||
pub(super) fn vehicle_targets(&self) -> Vec<(Who, Aabb)> {
|
||||
let mut targets: Vec<(Who, Aabb)> = self
|
||||
.boats
|
||||
.iter()
|
||||
.map(|b| (Who::Mob(b.id), b.bounds()))
|
||||
.collect();
|
||||
targets.extend(self.carts.iter().map(|c| (Who::Mob(c.id), c.bounds())));
|
||||
targets
|
||||
}
|
||||
pub(super) fn projectile_targets(&self) -> Vec<(Who, Aabb)> {
|
||||
let mut targets: Vec<(Who, Aabb)> = self
|
||||
.mobs
|
||||
@@ -5198,9 +5190,9 @@ impl Game<'_> {
|
||||
.into_iter()
|
||||
.map(|(id, b)| (Who::Mob(id), b)),
|
||||
);
|
||||
// Boats and minecarts are pickable for every projectile
|
||||
// (`VehicleEntity.isPickable`), so every kind shares this one list.
|
||||
targets.extend(self.vehicle_targets());
|
||||
// Boats, minecarts and primed TNT are pickable for every projectile,
|
||||
// so every kind shares this one list.
|
||||
targets.extend(self.pickable_object_targets());
|
||||
targets
|
||||
}
|
||||
pub(super) fn arrow_rain(&self) -> impl Fn(BlockPos) -> bool + '_ {
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
//! The breeding parent's own random in `getBreedOffspring`: the offspring's
|
||||
//! coat coin and a wolf pup's voice.
|
||||
use super::super::Game;
|
||||
use horizon_client::mobs::Species;
|
||||
use horizon_core::random::RandomSource;
|
||||
|
||||
impl Game<'_> {
|
||||
/// Cow, Pig, Chicken, Fox, Cat and Wolf.getBreedOffspring: the offspring's
|
||||
/// variant coin is the breeding parent's own first draw, not the level's.
|
||||
/// (Mooshroom draws its own coin in getOffspringVariant.)
|
||||
pub(super) fn bred_coat_coin(&mut self, parent: u32, species: Species) -> bool {
|
||||
matches!(
|
||||
species,
|
||||
Species::Cow
|
||||
| Species::Pig
|
||||
| Species::Chicken
|
||||
| Species::Fox
|
||||
| Species::Cat
|
||||
| Species::Wolf
|
||||
) && self
|
||||
.mobs
|
||||
.iter_mut()
|
||||
.find(|m| m.id == parent)
|
||||
.is_some_and(|m| m.random.next_boolean())
|
||||
}
|
||||
/// WolfSoundVariants.pickRandomSoundVariant: Registry.getRandom over the
|
||||
/// wolf voice registry, from the parent's own random, after the collar
|
||||
/// colour's mix.
|
||||
pub(super) fn pick_bred_wolf_voice(&mut self, parent: u32, child: u32) {
|
||||
let voices = horizon_client::mobs::finalize::sound_variants(Species::Wolf);
|
||||
let voice = self.mobs.iter_mut().find(|m| m.id == parent).and_then(|m| {
|
||||
let i = m.random.next_int_bound(voices.len() as i32).ok()?;
|
||||
Some(i as u8)
|
||||
});
|
||||
if let Some(c) = self.mobs.iter_mut().find(|m| m.id == child) {
|
||||
c.extra.sound_variant = voice;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::*;
|
||||
|
||||
/// `Cow`, `Pig`, `Chicken`, `Fox`, `Cat` and `Wolf.getBreedOffspring` draw
|
||||
/// the offspring's coat from the breeding parent's own random, and only a
|
||||
/// wolf picks a voice there (`WolfSoundVariants.pickRandomSoundVariant`,
|
||||
/// that same random, after the collar colour's mix). The level's random
|
||||
/// answers neither: two runs that differ only in the level's seed must
|
||||
/// agree, and the parent's stream must read as vanilla's own draws in
|
||||
/// vanilla's order.
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources (HORIZON_TEST_RESOURCES)"]
|
||||
fn a_pup_takes_its_coat_and_voice_from_the_parents_own_random() {
|
||||
use horizon_core::random::{LegacyRandom, RandomSource};
|
||||
// The voice registry, not a hardcoded count of wolf voices.
|
||||
let voices = horizon_client::mobs::finalize::sound_variants(Species::Wolf).len() as i32;
|
||||
assert!(voices > 1, "a wolf has several voices");
|
||||
let cases = [
|
||||
("minecraft:wolf", 2u8, 8u8),
|
||||
("minecraft:wolf", 6, 0),
|
||||
("minecraft:cow", 1, 0),
|
||||
("minecraft:pig", 2, 0),
|
||||
("minecraft:chicken", 0, 1),
|
||||
("minecraft:fox", 1, 0),
|
||||
("minecraft:cat", 4, 0),
|
||||
];
|
||||
let mut heard = std::collections::BTreeSet::new();
|
||||
// One server game: its resources load once, and only the level's seed
|
||||
// and the pair change between runs.
|
||||
let mut game = super::super::super::native_machines::tests::server_game();
|
||||
for (n, &(kind, own, mate)) in cases.iter().enumerate() {
|
||||
// Independent entity randoms, kept clear of the ids the spawner
|
||||
// hands out: the parent is `part`, the partner `mate_id`.
|
||||
let parent_seed = 4_242 + 1_009 * n as i64;
|
||||
let mut outcomes = Vec::new();
|
||||
for (r, level_seed) in [11i64, 2_000_003].into_iter().enumerate() {
|
||||
game.world.random = LegacyRandom::new(level_seed);
|
||||
game.mobs.clear();
|
||||
let (part, mate_id) = (7u32, 8u32);
|
||||
// Ids the spawner hands out stay clear of the pair's.
|
||||
game.next_entity = 1_000 + 10 * n as u32 + 2 * r as u32;
|
||||
let t = game
|
||||
.res
|
||||
.mob_types()
|
||||
.get(kind)
|
||||
.unwrap_or_else(|| panic!("a {kind}"))
|
||||
.clone();
|
||||
for (id, variant) in [(part, own), (mate_id, mate)] {
|
||||
let mut m = Mob::new(id, t.clone(), Vec3::new(8.5, 64.0, 8.5), 0.0);
|
||||
m.random = LegacyRandom::new(parent_seed + i64::from(id));
|
||||
m.extra.variant = variant;
|
||||
m.extra.in_love = 600;
|
||||
game.mobs.push(m);
|
||||
}
|
||||
game.breed(part, mate_id, None);
|
||||
let child = game
|
||||
.mobs
|
||||
.iter()
|
||||
.find(|m| m.id != part && m.id != mate_id)
|
||||
.unwrap_or_else(|| panic!("{kind}: a pup"));
|
||||
assert_eq!(child.extra.age, -24000, "{kind}: a baby");
|
||||
// What the parent's own stream answers, draw for draw.
|
||||
let mut expected = LegacyRandom::new(parent_seed + i64::from(part));
|
||||
let coin = expected.next_boolean();
|
||||
let coat = if coin { own } else { mate };
|
||||
let voice = (kind == "minecraft:wolf").then(|| {
|
||||
let i = expected.next_int_bound(voices).unwrap() as u8;
|
||||
heard.insert(i);
|
||||
i
|
||||
});
|
||||
assert_eq!(
|
||||
child.extra.variant, coat,
|
||||
"{kind}: the parent's coin picks the coat (level seed {level_seed})"
|
||||
);
|
||||
assert_eq!(
|
||||
child.extra.sound_variant, voice,
|
||||
"{kind}: the parent's next draw picks the voice (level seed {level_seed})"
|
||||
);
|
||||
// The stream ends where vanilla's does: the coin, a wolf's
|
||||
// voice, then finalizeSpawnChildFromBreeding's 1-to-7 roll.
|
||||
if game.ui.rules.bool("mob_drops") {
|
||||
let _ = expected.next_int_bound(7);
|
||||
}
|
||||
let mut parent = game
|
||||
.mobs
|
||||
.iter()
|
||||
.find(|m| m.id == part)
|
||||
.unwrap()
|
||||
.random
|
||||
.clone();
|
||||
for _ in 0..4 {
|
||||
assert_eq!(
|
||||
parent.next_int(),
|
||||
expected.next_int(),
|
||||
"{kind}: the parent's stream goes on where vanilla leaves it"
|
||||
);
|
||||
}
|
||||
outcomes.push((child.extra.variant, child.extra.sound_variant));
|
||||
}
|
||||
assert_eq!(
|
||||
outcomes[0], outcomes[1],
|
||||
"{kind}: the level's seed does not decide the pup"
|
||||
);
|
||||
}
|
||||
assert!(heard.len() > 1, "the wolf's voices vary: {heard:?}");
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,9 @@ impl ProjectileWorld for ProjWorld<'_, '_> {
|
||||
fn in_water(&self, bounds: Aabb) -> bool {
|
||||
self.w.fluids(bounds, bounds.max_position().y).in_water
|
||||
}
|
||||
fn pushed_by_currents(&self, bounds: Aabb, velocity: Vec3) -> Vec3 {
|
||||
self.w.pushed_by_currents(bounds, velocity)
|
||||
}
|
||||
fn targets(&self) -> &[(Who, Aabb)] {
|
||||
&self.targets
|
||||
}
|
||||
|
||||
@@ -208,35 +208,15 @@ fn load_anticheat() -> horizon_anticheat::Config {
|
||||
}
|
||||
}
|
||||
|
||||
/// `server.toml`'s `[chat_filter]` (docs/chat-filter.md), like `[anticheat]`: on by default, off (with an
|
||||
/// error logged) when the section is wrong. The mutes are kept beside server.properties.
|
||||
fn load_chat_filter() -> Option<horizon_chat_filter::Guard> {
|
||||
/// `server.toml`'s `[chat_filter]` (docs/chat-filter.md), through the filter the executable installed
|
||||
/// (`horizon_server_files::chat_guard`): none without one.
|
||||
fn load_chat_filter() -> Option<Box<dyn horizon_server_files::chat_guard::ChatGuard>> {
|
||||
let text = std::fs::read_to_string("server.toml").unwrap_or_default();
|
||||
let config = match horizon_chat_filter::Config::from_server_toml(&text) {
|
||||
Ok(config) => config,
|
||||
Err(e) => {
|
||||
log(
|
||||
"ERROR",
|
||||
&format!("server.toml: {e}; the chat filter stays off"),
|
||||
);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
if !config.enabled {
|
||||
return None;
|
||||
let loaded = horizon_server_files::chat_guard::load(&text, std::path::Path::new("."));
|
||||
for (level, line) in &loaded.log {
|
||||
log(level, line);
|
||||
}
|
||||
let (guard, problem) = horizon_chat_filter::Guard::load(config, std::path::Path::new("."));
|
||||
if let Some(problem) = problem {
|
||||
log("WARN", &problem);
|
||||
}
|
||||
log(
|
||||
"INFO",
|
||||
&format!(
|
||||
"Chat filter on: {} words (server.toml [chat_filter]; enabled = false turns it off)",
|
||||
guard.filter.len()
|
||||
),
|
||||
);
|
||||
Some(guard)
|
||||
loaded.guard
|
||||
}
|
||||
|
||||
/// `server.toml`'s `[combat]` (docs/pvp.md): `legacy = true` resolves melee hits by 1.8.9's rules. Off without
|
||||
|
||||
@@ -1123,6 +1123,8 @@ impl Game<'_> {
|
||||
let mut stand = horizon_client::entities::ArmorStand::new(id, at, yaw);
|
||||
stand.marker = e["marker"].as_bool().unwrap_or(false);
|
||||
stand.on_ground = e["ground"].as_bool().unwrap_or(false);
|
||||
stand.show_arms = e["arms"].as_bool().unwrap_or(false);
|
||||
stand.disabled_slots = e["disabled"].as_i64().unwrap_or(0) as i32;
|
||||
if let Some(last_hit) = e["last_hit"].as_i64() {
|
||||
stand.last_hit = last_hit;
|
||||
}
|
||||
|
||||
@@ -85,6 +85,14 @@ fn block_box(p: Pos) -> Aabb {
|
||||
Vec3::new(f64::from(p.0 + 1), f64::from(p.1 + 1), f64::from(p.2 + 1)),
|
||||
)
|
||||
}
|
||||
/// `EntitySectionStorage.forEachAccessibleNonEmptySection`: x ascending, then
|
||||
/// the packed key (`SectionPos.asLong`: z as 22 unsigned bits, then y as 20);
|
||||
/// within a section the entities go in the order they joined, which ids follow.
|
||||
fn section_order(at: Vec3) -> (i32, u32, u32) {
|
||||
let s = |v: f64| (v.floor() as i32) >> 4;
|
||||
let (x, y, z) = (s(at.x), s(at.y), s(at.z));
|
||||
(x, z as u32 & 0x3F_FFFF, y as u32 & 0xF_FFFF)
|
||||
}
|
||||
fn overlaps(a: Aabb, b: Aabb) -> bool {
|
||||
let (a0, a1, b0, b1) = (
|
||||
a.min_position(),
|
||||
@@ -131,6 +139,8 @@ fn optional_event(behaviour: &'static str) -> i32 {
|
||||
#[derive(Clone, Copy)]
|
||||
enum Target {
|
||||
Player,
|
||||
/// A server player, by connection: the integrated owner and every guest.
|
||||
Guest(u32),
|
||||
Mob(usize),
|
||||
Stand(usize),
|
||||
}
|
||||
@@ -459,7 +469,7 @@ impl Game<'_> {
|
||||
r.triangle(0.2, spread),
|
||||
r.triangle(f64::from(s[2]) * pow, spread),
|
||||
];
|
||||
self.spawn_item(stack, at, v, 0);
|
||||
self.spawn_item_thrown(stack, at, v);
|
||||
}
|
||||
/// `DefaultDispenseItemBehavior.execute`: drop one item.
|
||||
fn default_execute(
|
||||
@@ -719,34 +729,57 @@ impl Game<'_> {
|
||||
|
||||
// ---- entities ----
|
||||
|
||||
/// Entities whose box overlaps the block at `p`, in the order the level
|
||||
/// added them (an entity section keeps insertion order; ids grow with it).
|
||||
/// Entities whose box overlaps the block at `p`, in `getEntitiesOfClass`
|
||||
/// order ([`section_order`]): players are entities of the level like any
|
||||
/// other, so they sort with the mobs and stands.
|
||||
fn targets_at(&self, p: Pos) -> Vec<Target> {
|
||||
let block = block_box(p);
|
||||
let mut found: Vec<(u32, Target)> = self
|
||||
let key = |at: Vec3, id: u32| (section_order(at), id);
|
||||
let mut found: Vec<(_, Target)> = self
|
||||
.mobs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, m)| !m.removed && overlaps(m.bounds(), block))
|
||||
.map(|(i, m)| (m.id, Target::Mob(i)))
|
||||
.map(|(i, m)| (key(m.position, m.id), Target::Mob(i)))
|
||||
.collect();
|
||||
found.extend(
|
||||
self.armor_stands
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, s)| overlaps(s.bounds(), block))
|
||||
.map(|(i, s)| (s.id, Target::Stand(i))),
|
||||
.map(|(i, s)| (key(s.position, s.id), Target::Stand(i))),
|
||||
);
|
||||
found.sort_by_key(|(id, _)| *id);
|
||||
let mut out: Vec<Target> = found.into_iter().map(|(_, t)| t).collect();
|
||||
// `ServerLevel.getEntities`: every player placed in this level, the
|
||||
// integrated owner included (a server has no player of its own), its
|
||||
// client loaded or not (it joins the level at placeNewPlayer/respawn).
|
||||
if let Some(host) = &self.lan {
|
||||
found.extend(
|
||||
(host.hub.guests.iter())
|
||||
.filter(|g| host.hub.here(g) && !g.dead && !g.spectator())
|
||||
.filter(|g| overlaps(g.bounds(), block))
|
||||
.map(|g| {
|
||||
(
|
||||
key(
|
||||
Vec3::new(g.position[0], g.position[1], g.position[2]),
|
||||
g.entity_id as u32,
|
||||
),
|
||||
Target::Guest(g.conn),
|
||||
)
|
||||
}),
|
||||
);
|
||||
}
|
||||
if let Some(v) = self.player_view()
|
||||
&& v.alive
|
||||
&& !self.spectator()
|
||||
&& overlaps(v.bounds, block)
|
||||
{
|
||||
out.push(Target::Player);
|
||||
// The local player joins before the chunks around it load their
|
||||
// entities (prepareLevels loads only saved tickets): the lowest id,
|
||||
// 0 as `own_entity_id` has it.
|
||||
found.push((key(v.position, 0), Target::Player));
|
||||
}
|
||||
out
|
||||
found.sort_by_key(|(k, _)| *k);
|
||||
found.into_iter().map(|(_, t)| t).collect()
|
||||
}
|
||||
/// `EntityType.getYOffset(level, pos, false, box)`: [`Self::drop_offset_from`] without
|
||||
/// `movedUp`.
|
||||
@@ -851,6 +884,22 @@ impl Game<'_> {
|
||||
self.spawn_jockeys(id, false);
|
||||
Some(id)
|
||||
}
|
||||
/// `Player.setItemSlot` of a dispensed piece for the player in place:
|
||||
/// `onEquipItem` plays the sound and the event; the modifiers follow.
|
||||
fn put_on_player(&mut self, slot: Slot, one: &ItemStack) {
|
||||
let name = match slot.armor_index() {
|
||||
Some(i) => {
|
||||
self.armor[i] = Some(one.clone());
|
||||
["head", "chest", "legs", "feet"][i]
|
||||
}
|
||||
None => {
|
||||
self.offhand = Some(one.clone());
|
||||
"offhand"
|
||||
}
|
||||
};
|
||||
self.player_equipped(name, Some(one));
|
||||
self.apply_equipment();
|
||||
}
|
||||
/// `EquipmentDispenseItemBehavior.dispenseEquipment`: the first living
|
||||
/// entity in front that `canEquipWithDispenser` gets one of the item.
|
||||
fn dispense_equipment(&mut self, pos: Pos, facing: Direction, stack: &mut ItemStack) -> bool {
|
||||
@@ -887,8 +936,23 @@ impl Game<'_> {
|
||||
_ => slot.armor_index().is_some_and(|i| self.armor[i].is_none()),
|
||||
}
|
||||
}
|
||||
Target::Guest(conn) => {
|
||||
allows("minecraft:player")
|
||||
&& self
|
||||
.lan
|
||||
.as_ref()
|
||||
.and_then(|h| h.hub.guest(conn))
|
||||
.is_some_and(|g| match slot {
|
||||
Slot::OffHand => g.offhand.is_none(),
|
||||
_ => slot.armor_index().is_some_and(|i| g.armor[i].is_none()),
|
||||
})
|
||||
}
|
||||
// ArmorStand.canUseSlot: not a locked slot, and a hand needs arms.
|
||||
Target::Stand(i) => slot.stand_index().is_some_and(|s| {
|
||||
allows("minecraft:armor_stand") && self.armor_stands[i].equipment[s].is_none()
|
||||
let stand = &self.armor_stands[i];
|
||||
allows("minecraft:armor_stand")
|
||||
&& !stand.is_disabled(s)
|
||||
&& stand.equipment[s].is_none()
|
||||
}),
|
||||
Target::Mob(i) => {
|
||||
let m = &self.mobs[i];
|
||||
@@ -905,20 +969,28 @@ impl Game<'_> {
|
||||
stack.count -= 1;
|
||||
// setItemSlot: onEquipItem plays the equip sound and the equip event.
|
||||
let (at, sound) = match target {
|
||||
Target::Guest(conn) => {
|
||||
// The guest's own turn: their state in place, so the slot, the
|
||||
// sound, the game event and the equipment sync run as for the host.
|
||||
let done = self
|
||||
.for_player(super::commands::Target::Guest(conn), |g| {
|
||||
g.put_on_player(slot, &one)
|
||||
})
|
||||
.is_some();
|
||||
if !done {
|
||||
stack.count += 1;
|
||||
return false;
|
||||
}
|
||||
self.native_block_dispense_armor(
|
||||
self.dim.id(),
|
||||
[pos.0, pos.1, pos.2],
|
||||
&stack.item,
|
||||
"player",
|
||||
);
|
||||
return true;
|
||||
}
|
||||
Target::Player => {
|
||||
// Player.setItemSlot: onEquipItem plays the sound and the event.
|
||||
let name = match slot.armor_index() {
|
||||
Some(i) => {
|
||||
self.armor[i] = Some(one.clone());
|
||||
["head", "chest", "legs", "feet"][i]
|
||||
}
|
||||
None => {
|
||||
self.offhand = Some(one.clone());
|
||||
"offhand"
|
||||
}
|
||||
};
|
||||
self.player_equipped(name, Some(&one));
|
||||
self.apply_equipment();
|
||||
self.put_on_player(slot, &one);
|
||||
self.native_block_dispense_armor(
|
||||
self.dim.id(),
|
||||
[pos.0, pos.1, pos.2],
|
||||
@@ -953,7 +1025,7 @@ impl Game<'_> {
|
||||
}
|
||||
let who = match target {
|
||||
Target::Mob(i) => Some(horizon_client::mobs::Who::Mob(self.mobs[i].id)),
|
||||
Target::Player | Target::Stand(_) => None,
|
||||
Target::Player | Target::Guest(_) | Target::Stand(_) => None,
|
||||
};
|
||||
let ctx = self.who_context(who, None);
|
||||
self.post_game_event("minecraft:equip", [at.x, at.y, at.z], ctx);
|
||||
@@ -965,7 +1037,7 @@ impl Game<'_> {
|
||||
match target {
|
||||
Target::Stand(_) => "armor_stand",
|
||||
Target::Mob(_) => "mob",
|
||||
Target::Player => "player",
|
||||
Target::Player | Target::Guest(_) => "player",
|
||||
},
|
||||
);
|
||||
true
|
||||
@@ -987,22 +1059,35 @@ impl Game<'_> {
|
||||
self.mobs.insert(i, m);
|
||||
self.handle_mob_events(events)
|
||||
}
|
||||
/// A mob a bucket emptied: `setFromBucket` keeps it, then it calls out.
|
||||
/// Fish and axolotls carry the flag itself (saved as `FromBucket`, read by
|
||||
/// `requiresCustomPersistence`); the others stay persistent instead.
|
||||
pub(super) fn mob_placed(&mut self, id: u32) -> Result<()> {
|
||||
/// A mob a bucket emptied (`MobBucketItem.spawn`): the bucket's components
|
||||
/// and entity data (`loadFromBucketTag`), then `setFromBucket(true)`, which
|
||||
/// `requiresCustomPersistence` reads, then it calls out. Only the bucket's
|
||||
/// data can make it persistent, not the spawn: `spawn_mob_at` creates a
|
||||
/// persistent mob. A tadpole's setter does nothing: its `fromBucket` is
|
||||
/// always true.
|
||||
pub(super) fn mob_placed(&mut self, id: u32, stack: &ItemStack) -> Result<()> {
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
|
||||
if matches!(
|
||||
m.species(),
|
||||
m.persistent = false;
|
||||
horizon_client::entity_nbt::apply_item_components(m, stack);
|
||||
match m.species() {
|
||||
Species::Cod
|
||||
| Species::Salmon
|
||||
| Species::Pufferfish
|
||||
| Species::TropicalFish
|
||||
| Species::Axolotl
|
||||
) {
|
||||
m.extra.from_bucket = true;
|
||||
} else {
|
||||
m.persistent = true;
|
||||
| Species::Salmon
|
||||
| Species::Pufferfish
|
||||
| Species::TropicalFish
|
||||
| Species::Axolotl => m.extra.from_bucket = true,
|
||||
Species::SulfurCube => {
|
||||
// SulfurCube.loadFromBucketTag: its own lowercase keys, 0
|
||||
// and false when absent, whatever `Age` the data holds.
|
||||
let data = stack
|
||||
.data()
|
||||
.and_then(|c| c.other("minecraft:bucket_entity_data"));
|
||||
let age = data.and_then(|d| horizon_client::save::number(d.get("age")));
|
||||
m.extra.age = age.unwrap_or(0) as i32;
|
||||
m.extra.age_locked =
|
||||
data.is_some_and(|d| horizon_client::entity_nbt::flag(d, "age_locked"));
|
||||
m.extra.scratch[3] = 1;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
self.mob_ambient(id)
|
||||
@@ -1353,13 +1438,10 @@ impl Game<'_> {
|
||||
self.evaporation_smoke([target.0, target.1, target.2]);
|
||||
}
|
||||
if let Some(kind) = mob {
|
||||
// MobBucketItem.checkExtraContent: the mob (from the bucket, so
|
||||
// persistent) calls out, then ENTITY_PLACE.
|
||||
// MobBucketItem.checkExtraContent: the mob from the bucket calls
|
||||
// out, then ENTITY_PLACE.
|
||||
if let Some(id) = self.spawn_mob_at(kind, target, true) {
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
|
||||
horizon_client::entity_nbt::apply_item_components(m, &stack);
|
||||
}
|
||||
self.mob_placed(id)?;
|
||||
self.mob_placed(id, &stack)?;
|
||||
}
|
||||
self.post_block_game_event(
|
||||
"minecraft:entity_place",
|
||||
@@ -2083,7 +2165,7 @@ impl Game<'_> {
|
||||
};
|
||||
(Holder::Player, v.bounds.center(), None)
|
||||
}
|
||||
Target::Stand(_) => return false,
|
||||
Target::Guest(_) | Target::Stand(_) => return false,
|
||||
};
|
||||
let area = Aabb::new(centre, centre).inflate(Vec3::new(16.0, 16.0, 16.0));
|
||||
let mut leashed: Vec<(u32, usize)> = self
|
||||
@@ -2334,4 +2416,229 @@ mod tests {
|
||||
Some((None, 0))
|
||||
);
|
||||
}
|
||||
|
||||
use horizon_client::lan::{Joined, NetConfig, PlayerSender, guest::Guest, hub::HubConfig};
|
||||
use horizon_protocol::packets::configuration::ClientInformation;
|
||||
use std::sync::{Arc, atomic::AtomicI32, mpsc};
|
||||
|
||||
/// A server with no player of its own, an open hub and one guest (conn 2)
|
||||
/// standing at (8.5, 200, 8.5) on a stone floor.
|
||||
fn server_with_guest() -> Game<'static> {
|
||||
let mut game = super::super::native_machines::tests::server_game();
|
||||
game.build_registries();
|
||||
game.set_allow_commands(true);
|
||||
game.block_ticks
|
||||
.add_empty_chunk(horizon_core::position::ChunkPos::new(0, 0))
|
||||
.unwrap();
|
||||
assert!(game.run_command("fill 0 199 0 15 199 15 stone"));
|
||||
game.lan_host(
|
||||
super::super::lan::LanSettings {
|
||||
port: 0,
|
||||
guest_commands: true,
|
||||
force_game_mode: false,
|
||||
},
|
||||
NetConfig {
|
||||
announce: false,
|
||||
..NetConfig::loopback(0, "dispenser-equip-test".into(), 2)
|
||||
},
|
||||
HubConfig {
|
||||
default_game_mode: 0,
|
||||
guest_commands: true,
|
||||
force_game_mode: false,
|
||||
hardcore: false,
|
||||
view_distance: 2,
|
||||
simulation_distance: 2,
|
||||
dimension: "minecraft:overworld".into(),
|
||||
allow_flight: false,
|
||||
seed: 0,
|
||||
max_players: 2,
|
||||
integrated: false,
|
||||
levels: Default::default(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let (tx, _rx) = mpsc::channel();
|
||||
let mut guest = Guest::new(
|
||||
Joined {
|
||||
conn: 2,
|
||||
uuid: 208,
|
||||
name: "Wearer".into(),
|
||||
properties: Vec::new(),
|
||||
info: ClientInformation::new(2),
|
||||
brand: "test".into(),
|
||||
sender: PlayerSender::new(tx, Arc::default()),
|
||||
latency: Arc::new(AtomicI32::new(0)),
|
||||
address: "127.0.0.1:25565".parse().unwrap(),
|
||||
expected: false,
|
||||
configured: Default::default(),
|
||||
},
|
||||
2,
|
||||
[8.5, 200.0, 8.5],
|
||||
2,
|
||||
);
|
||||
guest.dimension = "minecraft:overworld".into();
|
||||
guest.placed = true;
|
||||
guest.loaded = true;
|
||||
game.lan.as_mut().unwrap().hub.guests.push(guest);
|
||||
game
|
||||
}
|
||||
|
||||
fn dispense_at_guest(game: &mut Game<'_>, item: &str) -> (bool, u32) {
|
||||
let mut stack = ItemStack::new(item, 2);
|
||||
// The dispenser sits one block over the guest's feet, facing down.
|
||||
let done = game.dispense_equipment((8, 201, 8), Direction::Down, &mut stack);
|
||||
(done, stack.count)
|
||||
}
|
||||
|
||||
/// `EquipmentDispenseItemBehavior`: a server's player, the integrated
|
||||
/// owner or a guest, takes the piece (a server has no player of its own).
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_dispenser_equips_a_server_player_in_front() {
|
||||
let mut game = server_with_guest();
|
||||
assert!(game.player.is_none(), "the server thread has no player");
|
||||
// The guest's feet are in block (8, 200, 8): the dispenser at y 201
|
||||
// facing down targets the block under it.
|
||||
let mut stack = ItemStack::new("minecraft:carved_pumpkin", 3);
|
||||
assert!(game.dispense_equipment((8, 201, 8), Direction::Down, &mut stack));
|
||||
assert_eq!(stack.count, 2);
|
||||
let guest = game.lan.as_ref().unwrap().hub.guest(2).unwrap();
|
||||
assert_eq!(
|
||||
guest.armor[0].as_ref().map(|s| (&*s.item, s.count)),
|
||||
Some(("minecraft:carved_pumpkin", 1))
|
||||
);
|
||||
// The head is taken now: a second pumpkin is not dispensed onto them.
|
||||
assert!(!game.dispense_equipment((8, 201, 8), Direction::Down, &mut stack));
|
||||
assert_eq!(stack.count, 2);
|
||||
// A boots piece still has its own empty slot.
|
||||
let (done, left) = dispense_at_guest(&mut game, "minecraft:iron_boots");
|
||||
assert_eq!((done, left), (true, 1), "boots go on the free feet slot");
|
||||
let guest = game.lan.as_ref().unwrap().hub.guest(2).unwrap();
|
||||
assert!(guest.armor[3].is_some() && guest.armor[1].is_none());
|
||||
}
|
||||
|
||||
/// A spectator is not a target (`canEquipWithDispenser`: `!isSpectator()`).
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_dispenser_skips_a_spectator_and_a_dead_player() {
|
||||
let mut game = server_with_guest();
|
||||
for spectate in [true, false] {
|
||||
let g = &mut game.lan.as_mut().unwrap().hub.guests[0];
|
||||
g.game_mode = if spectate { 3 } else { 0 };
|
||||
g.dead = !spectate;
|
||||
let mut stack = ItemStack::new("minecraft:iron_helmet", 1);
|
||||
assert!(!game.dispense_equipment((8, 201, 8), Direction::Down, &mut stack));
|
||||
assert_eq!(stack.count, 1);
|
||||
}
|
||||
}
|
||||
|
||||
/// `ArmorStand.canUseSlot` through `isDisabled`: the first stand in front
|
||||
/// that can wear the piece gets it. Bit `1 << filterBit` locks a slot
|
||||
/// (head 4, chest 3, legs 2, feet 1); a hand needs arms.
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_dispenser_skips_an_armor_stand_whose_slot_is_locked() {
|
||||
use horizon_client::entities::ArmorStand;
|
||||
let mut game = server_with_guest();
|
||||
game.lan.as_mut().unwrap().hub.guests.clear();
|
||||
let at = horizon_core::geometry::Vec3::new(8.5, 200.0, 8.5);
|
||||
let mut stands = [(1, 1u32 << 4), (2, 0)].map(|(id, locked)| {
|
||||
let mut s = ArmorStand::new(id, at, 0.0);
|
||||
s.disabled_slots = locked as i32;
|
||||
s
|
||||
});
|
||||
stands[0].show_arms = true;
|
||||
game.armor_stands = stands.to_vec().into();
|
||||
// The head-locked first stand is skipped for a helmet; the second wears it.
|
||||
let mut stack = ItemStack::new("minecraft:iron_helmet", 1);
|
||||
assert!(game.dispense_equipment((8, 201, 8), Direction::Down, &mut stack));
|
||||
assert!(game.armor_stands[0].equipment[5].is_none());
|
||||
assert!(game.armor_stands[1].equipment[5].is_some());
|
||||
// The same stand takes chest armour: only its head is locked.
|
||||
let mut stack = ItemStack::new("minecraft:iron_chestplate", 1);
|
||||
assert!(game.dispense_equipment((8, 201, 8), Direction::Down, &mut stack));
|
||||
assert!(game.armor_stands[0].equipment[4].is_some());
|
||||
// Every stand locked for that slot: nothing takes it (the dispenser ejects it).
|
||||
game.armor_stands[0].disabled_slots = 1 << 4;
|
||||
game.armor_stands[1].disabled_slots = 1 << 4;
|
||||
game.armor_stands[1].equipment[5] = None;
|
||||
let mut stack = ItemStack::new("minecraft:iron_helmet", 1);
|
||||
assert!(!game.dispense_equipment((8, 201, 8), Direction::Down, &mut stack));
|
||||
assert_eq!(stack.count, 1);
|
||||
}
|
||||
|
||||
/// A stand of entity id `id` standing at `x`, 200, `z`.
|
||||
fn stand_at(id: u32, x: f64, z: f64) -> horizon_client::entities::ArmorStand {
|
||||
let at = horizon_core::geometry::Vec3::new(x, 200.0, z);
|
||||
horizon_client::entities::ArmorStand::new(id, at, 0.0)
|
||||
}
|
||||
|
||||
/// A guest whose client is still on its loading screen (joined or
|
||||
/// respawned, no ServerboundPlayerLoadedPacket yet) is in the level all the
|
||||
/// same: `getEntitiesOfClass` finds it and it wears the piece.
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_dispenser_equips_a_player_whose_client_is_still_loading() {
|
||||
let mut game = server_with_guest();
|
||||
let g = &mut game.lan.as_mut().unwrap().hub.guests[0];
|
||||
(g.loaded, g.loading) = (false, true);
|
||||
let (done, left) = dispense_at_guest(&mut game, "minecraft:iron_helmet");
|
||||
assert_eq!((done, left), (true, 1));
|
||||
let guest = game.lan.as_ref().unwrap().hub.guest(2).unwrap();
|
||||
let head = guest.armor[0].as_ref().map(|s| &*s.item);
|
||||
assert_eq!(head, Some("minecraft:iron_helmet"));
|
||||
}
|
||||
|
||||
/// Players are entities of the level: in one section they go in join
|
||||
/// order (entity ids) with the mobs and stands, not after them.
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_player_and_a_stand_in_front_go_in_join_order() {
|
||||
// (stand id, guest id) -> who wears the helmet: the lower id joined first.
|
||||
for (stand_id, guest_id, guest_first) in [(7, 5, true), (3, 5, false)] {
|
||||
let mut game = server_with_guest();
|
||||
game.lan.as_mut().unwrap().hub.guests[0].entity_id = guest_id;
|
||||
game.armor_stands = vec![stand_at(stand_id, 8.5, 8.5)].into();
|
||||
let (done, _) = dispense_at_guest(&mut game, "minecraft:iron_helmet");
|
||||
assert!(done);
|
||||
let guest = game.lan.as_ref().unwrap().hub.guest(2).unwrap();
|
||||
assert_eq!(
|
||||
guest.armor[0].is_some(),
|
||||
guest_first,
|
||||
"stand {stand_id}, guest {guest_id}"
|
||||
);
|
||||
assert_eq!(game.armor_stands[0].equipment[5].is_some(), !guest_first);
|
||||
}
|
||||
}
|
||||
|
||||
/// `forEachAccessibleNonEmptySection` walks sections before join order:
|
||||
/// a stand whose feet are in section x 1 but whose box reaches into block
|
||||
/// x 15 comes after one in section x 0, even with the lower id.
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_dispenser_walks_entity_sections_before_ids() {
|
||||
let mut game = server_with_guest();
|
||||
game.lan.as_mut().unwrap().hub.guests.clear();
|
||||
// Stand boxes are 0.5 wide: x 16.2 spans 15.95..16.45.
|
||||
game.armor_stands = vec![stand_at(1, 16.2, 8.5), stand_at(2, 15.5, 8.5)].into();
|
||||
let mut stack = ItemStack::new("minecraft:iron_helmet", 1);
|
||||
assert!(game.dispense_equipment((15, 201, 8), Direction::Down, &mut stack));
|
||||
assert!(game.armor_stands[0].equipment[5].is_none());
|
||||
assert!(game.armor_stands[1].equipment[5].is_some());
|
||||
}
|
||||
|
||||
/// `SectionPos.asLong` packs z (22 bits) over y (20 bits) under a fixed x,
|
||||
/// and the section set is sorted as longs: z -1 packs as 0x3FFFFF << 20,
|
||||
/// above z 0's 0; y -1 at z 0 packs as 0xFFFFF, below z 1's 1 << 20.
|
||||
#[test]
|
||||
fn sections_sort_as_their_packed_keys() {
|
||||
use horizon_core::geometry::Vec3;
|
||||
let at = |x: f64, y: f64, z: f64| super::section_order(Vec3::new(x, y, z));
|
||||
assert!(at(0.0, 0.0, -1.0) > at(0.0, 0.0, 0.0));
|
||||
assert!(at(0.0, -1.0, 0.0) < at(0.0, 0.0, 16.0));
|
||||
assert!(at(0.0, -1.0, 0.0) > at(0.0, 0.0, 0.0));
|
||||
// x is the outer loop, ascending as a signed coordinate.
|
||||
assert!(at(-1.0, 0.0, 0.0) < at(0.0, 0.0, 0.0));
|
||||
assert!(at(15.9, 0.0, 0.0) < at(16.0, 0.0, 0.0));
|
||||
}
|
||||
}
|
||||
@@ -285,3 +285,109 @@ fn a_teleporting_shulker_leaves_its_rider_behind() {
|
||||
}
|
||||
assert!(teleported > 0, "no shulker teleported");
|
||||
}
|
||||
|
||||
/// `Shulker.teleportSomewhere`: the teleport posts `GameEvent.TELEPORT` at
|
||||
/// the block it left, between its sound and level event 2016, so a sculk
|
||||
/// sensor where it stood hears the shulker arrive.
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_teleporting_shulker_posts_teleport_where_it_stood() {
|
||||
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
|
||||
let mut game = floored_game(&root);
|
||||
game.build_registries();
|
||||
game.set_allow_commands(true);
|
||||
// A sensor just above the shulker hears the vibration where it stands.
|
||||
game.sculk_prepare();
|
||||
assert!(game.run_command("setblock 8 5 8 sculk_sensor"));
|
||||
game.sculk_sync_index();
|
||||
let at = Vec3::new(8.5, 4.0, 8.5);
|
||||
let old = [at.x, at.y, at.z];
|
||||
// `LevelAccessor.gameEvent(Holder, BlockPos, ..)` centers the block.
|
||||
let old_center = [at.x, at.y + 0.5, at.z];
|
||||
let mut teleported = false;
|
||||
for seed in 0..40 {
|
||||
let id = 900 + seed as u32;
|
||||
let mut mob = shulker(&game, id, at, seed);
|
||||
// What `after_hurt` leaves for a low-health teleport.
|
||||
mob.extra.flag = true;
|
||||
let uuid = game.level.player_uuid;
|
||||
let others: Vec<_> = game
|
||||
.mobs
|
||||
.iter()
|
||||
.map(|m| super::creatures::seen(m, uuid))
|
||||
.collect();
|
||||
let mut level_random = game.world.random.clone();
|
||||
let mut events = Vec::new();
|
||||
{
|
||||
let world = game.mob_world_base(&others);
|
||||
horizon_client::mobs::shulker::run_pending_teleports(
|
||||
&mut mob,
|
||||
&world,
|
||||
&mut level_random,
|
||||
&mut events,
|
||||
);
|
||||
}
|
||||
if mob.position.distance(at) <= 0.5 {
|
||||
continue;
|
||||
}
|
||||
teleported = true;
|
||||
let sound = events.iter().position(|e| {
|
||||
matches!(e, MobEvent::Sound(name, p, ..) if *name == "entity.shulker.teleport" && *p == old)
|
||||
});
|
||||
let teleport = events.iter().position(|e| {
|
||||
matches!(
|
||||
e,
|
||||
MobEvent::GameEvent { event, at: p, source, .. }
|
||||
if *event == "minecraft:teleport" && *p == old_center && *source == id
|
||||
)
|
||||
});
|
||||
let level = events.iter().position(
|
||||
|e| matches!(e, MobEvent::LevelEvent { id: 2016, pos, .. } if *pos == [8, 4, 8]),
|
||||
);
|
||||
assert!(
|
||||
sound.is_some(),
|
||||
"seed {seed}: no teleport sound: {events:?}"
|
||||
);
|
||||
assert!(
|
||||
teleport.is_some(),
|
||||
"seed {seed}: no minecraft:teleport game event: {events:?}"
|
||||
);
|
||||
assert!(
|
||||
level.is_some(),
|
||||
"seed {seed}: no level event 2016: {events:?}"
|
||||
);
|
||||
assert!(
|
||||
sound < teleport && teleport < level,
|
||||
"seed {seed}: the game event is not between the sound and level event: {events:?}"
|
||||
);
|
||||
game.mobs.push(mob);
|
||||
super::sculk::TEST_POSTED_EVENTS.with(|events| *events.borrow_mut() = Some(Vec::new()));
|
||||
game.handle_mob_events(events).unwrap();
|
||||
let posted: Vec<_> = super::sculk::TEST_POSTED_EVENTS.with(|events| {
|
||||
events
|
||||
.borrow_mut()
|
||||
.take()
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.filter(|(event, _, _)| *event == "minecraft:teleport")
|
||||
.map(|(_, pos, source)| (pos, source))
|
||||
.collect()
|
||||
});
|
||||
assert_eq!(
|
||||
posted,
|
||||
[(old_center, Some(horizon_client::mobs::Who::Mob(id)))],
|
||||
"seed {seed}: the teleport event is posted from the shulker at its old position"
|
||||
);
|
||||
// The sensor above the old block holds that vibration.
|
||||
game.chat_capture = Some(Vec::new());
|
||||
assert!(game.run_command("data get block 8 5 8 listener.selector.event"));
|
||||
let said = game.chat_capture.take().unwrap_or_default();
|
||||
let said: String = said.iter().map(horizon_client::text::Text::plain).collect();
|
||||
assert!(
|
||||
said.contains("minecraft:teleport") && said.contains("[8.5d, 4.5d, 8.5d]"),
|
||||
"seed {seed}: the sensor did not hear the teleport: {said}"
|
||||
);
|
||||
break;
|
||||
}
|
||||
assert!(teleported, "no shulker teleported");
|
||||
}
|
||||
@@ -262,6 +262,14 @@ pub(super) trait Entity: EntityRules {
|
||||
fn is_vehicle(&self) -> bool {
|
||||
false
|
||||
}
|
||||
/// `Entity.getDimensionChangingDelay`, overridden by vehicles and projectiles.
|
||||
fn dimension_changing_delay(&self) -> i32 {
|
||||
horizon_client::nether_portal::ENTITY_DELAY
|
||||
}
|
||||
/// Whether movement applies portal block effects before Entity's base tick.
|
||||
fn portal_after_move(&self) -> bool {
|
||||
false
|
||||
}
|
||||
/// Where it stood at the start of its tick, for the kinds that store it
|
||||
/// (None: `portals.before` has it).
|
||||
fn portal_from(&self) -> Option<Vec3> {
|
||||
@@ -323,7 +331,14 @@ impl RenderEntityBounds for Mob {
|
||||
self.species(),
|
||||
Species::Guardian | Species::ElderGuardian | Species::Phantom
|
||||
) {
|
||||
let radius = width.max(height) * 2.5 + 3.0;
|
||||
// The model's root scales its appendages too (the elder guardian
|
||||
// uses 2.35), so the margin must grow along with the model.
|
||||
let root_scale = self.kind.model.pose[6..]
|
||||
.iter()
|
||||
.copied()
|
||||
.map(f32::abs)
|
||||
.fold(1.0, f32::max);
|
||||
let radius = width.max(height) * 2.5 + 3.0 * f64::from(root_scale * self.model_scale());
|
||||
return Aabb::of_size(at, Vec3::new(radius * 2.0, radius * 2.0, radius * 2.0));
|
||||
}
|
||||
|
||||
@@ -1322,6 +1337,9 @@ impl Entity for ExperienceOrb {
|
||||
}
|
||||
|
||||
impl Entity for Boat {
|
||||
fn dimension_changing_delay(&self) -> i32 {
|
||||
10
|
||||
}
|
||||
fn set_on_ground(&mut self, on_ground: bool) {
|
||||
self.on_ground = on_ground;
|
||||
}
|
||||
@@ -1397,6 +1415,9 @@ impl Entity for Boat {
|
||||
}
|
||||
|
||||
impl Entity for Minecart {
|
||||
fn dimension_changing_delay(&self) -> i32 {
|
||||
10
|
||||
}
|
||||
fn set_on_ground(&mut self, on_ground: bool) {
|
||||
self.on_ground = on_ground;
|
||||
}
|
||||
@@ -1723,6 +1744,16 @@ impl Entity for FallingBlock {
|
||||
}
|
||||
|
||||
impl Entity for Projectile {
|
||||
fn portal_after_move(&self) -> bool {
|
||||
!matches!(
|
||||
self.kind,
|
||||
horizon_client::projectiles::ProjectileKind::LlamaSpit
|
||||
| horizon_client::projectiles::ProjectileKind::ShulkerBullet { .. }
|
||||
)
|
||||
}
|
||||
fn dimension_changing_delay(&self) -> i32 {
|
||||
2
|
||||
}
|
||||
fn rotation(&self) -> [f32; 2] {
|
||||
[self.yaw, self.pitch]
|
||||
}
|
||||
@@ -1843,6 +1874,9 @@ impl Entity for Projectile {
|
||||
}
|
||||
|
||||
impl Entity for Rocket {
|
||||
fn dimension_changing_delay(&self) -> i32 {
|
||||
2
|
||||
}
|
||||
fn rotation(&self) -> [f32; 2] {
|
||||
self.rotation
|
||||
}
|
||||
@@ -1910,6 +1944,12 @@ impl Entity for Rocket {
|
||||
}
|
||||
|
||||
impl Entity for ThrownTrident {
|
||||
fn portal_after_move(&self) -> bool {
|
||||
true
|
||||
}
|
||||
fn dimension_changing_delay(&self) -> i32 {
|
||||
2
|
||||
}
|
||||
fn rotation(&self) -> [f32; 2] {
|
||||
[self.yaw, self.pitch]
|
||||
}
|
||||
|
||||
@@ -1130,7 +1130,8 @@ impl Game<'_> {
|
||||
}
|
||||
if let Some(entity) = self.world.block_entities.get(&key).cloned() {
|
||||
let custom = self.world.block_names.get(&key).cloned();
|
||||
self.pending.record(pos, &entity, &name, custom);
|
||||
self.pending
|
||||
.record(pos, &entity, &name, custom, self.world.block_lock(key));
|
||||
} else if let Some(tag) = super::spill::saved_entity(&self.world, pos) {
|
||||
self.pending.record_saved(pos, &tag);
|
||||
}
|
||||
@@ -1793,3 +1794,158 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod shulker_lock_drop_tests {
|
||||
use super::super::native_machines::tests::server_game;
|
||||
use super::*;
|
||||
use horizon_client::{containers::BlockEntity, save};
|
||||
use horizon_world::nbt::Tag;
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES and scratch homes/cwd"]
|
||||
fn destruction_captures_the_external_shulker_lock_before_removal() {
|
||||
let mut game = server_game();
|
||||
game.build_registries();
|
||||
game.set_allow_commands(true);
|
||||
game.block_ticks
|
||||
.add_empty_chunk(BlockPos::new(0, 0, 0).chunk())
|
||||
.unwrap();
|
||||
assert!(game.run_command("fill 0 199 0 15 199 15 stone"));
|
||||
assert!(
|
||||
game.ui.rules.bool("block_drops"),
|
||||
"actual drop rule must be enabled"
|
||||
);
|
||||
game.creative = false;
|
||||
game.selected = 0;
|
||||
game.inventory[0] = Some(ItemStack::new("minecraft:diamond_pickaxe", 1));
|
||||
let clock = game.world.game_time;
|
||||
let mut missing = Vec::new();
|
||||
for (x, z, kind, locked, route) in [
|
||||
(3, 3, "shulker_box", true, "command"),
|
||||
(11, 3, "shulker_box", true, "player"),
|
||||
(3, 11, "shulker_box", true, "explosion"),
|
||||
(11, 11, "red_shulker_box", false, "command"),
|
||||
(7, 3, "chest", true, "command"),
|
||||
(7, 11, "crafter", true, "command"),
|
||||
] {
|
||||
let shulker = kind.ends_with("shulker_box");
|
||||
let mut fields = Vec::new();
|
||||
if shulker {
|
||||
fields.push("CustomName:\"Box\"");
|
||||
fields.push("Items:[{Slot:0b,id:\"minecraft:diamond\",count:3,components:{\"minecraft:custom_data\":{h211:1}}},{Slot:26b,id:\"minecraft:apple\",count:5}]");
|
||||
}
|
||||
if locked {
|
||||
fields.push("lock:{items:\"minecraft:diamond\"}");
|
||||
}
|
||||
assert!(game.run_command(&format!(
|
||||
"setblock {x} 200 {z} minecraft:{kind}{{{}}}",
|
||||
fields.join(",")
|
||||
)));
|
||||
let pos = [x, 200, z];
|
||||
let key = (x, 200, z);
|
||||
assert_eq!(
|
||||
&*game.res.facts().get(game.world.block(x, 199, z)).name,
|
||||
"minecraft:stone",
|
||||
"positive floor"
|
||||
);
|
||||
let actual_lock = game.world.block_lock(key).cloned();
|
||||
assert_eq!(actual_lock.is_some(), locked, "actual external source lock");
|
||||
if locked {
|
||||
assert!(matches!(&actual_lock,Some(Tag::Compound(c)) if !c.is_empty()));
|
||||
}
|
||||
let entity = game
|
||||
.world
|
||||
.block_entities
|
||||
.get(&key)
|
||||
.expect("actual command-created source");
|
||||
assert!(
|
||||
save::encode_block_entity(key, entity, &format!("minecraft:{kind}"))
|
||||
.get("lock")
|
||||
.is_none(),
|
||||
"external lock must not be invented in modeled serializer"
|
||||
);
|
||||
if shulker {
|
||||
let BlockEntity::Chest(slots) = entity else {
|
||||
panic!("modeled shulker inventory missing");
|
||||
};
|
||||
assert_eq!(slots[0].as_ref().unwrap().count, 3);
|
||||
assert_eq!(slots[26].as_ref().unwrap().count, 5);
|
||||
assert_eq!(
|
||||
game.world.block_names.get(&key),
|
||||
Some(&Tag::String("Box".into()))
|
||||
);
|
||||
}
|
||||
let start = game.dropped.len();
|
||||
match route {
|
||||
"command" => {
|
||||
assert!(game.run_command(&format!("setblock {x} 200 {z} air destroy")))
|
||||
}
|
||||
"player" => game.break_block(pos).unwrap(),
|
||||
"explosion" => game
|
||||
.server_explode(
|
||||
pos.map(|v| f64::from(v) + 0.5),
|
||||
2.0,
|
||||
BlockInteraction::Destroy,
|
||||
false,
|
||||
)
|
||||
.unwrap(),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
game.flush_spill();
|
||||
assert!(
|
||||
game.res.facts().is_air(game.world.block(x, 200, z)),
|
||||
"actual destruction occurred: {route}"
|
||||
);
|
||||
assert!(!game.world.block_entities.contains_key(&key));
|
||||
assert!(
|
||||
game.world.block_lock(key).is_none(),
|
||||
"actual external source was removed"
|
||||
);
|
||||
let item_id = format!("minecraft:{kind}");
|
||||
let drops: Vec<_> = game.dropped[start..]
|
||||
.iter()
|
||||
.filter(|d| *d.stack.item == item_id)
|
||||
.collect();
|
||||
assert_eq!(drops.len(), 1, "actual one-item drop: {kind}/{route}");
|
||||
let stack = &drops[0].stack;
|
||||
assert_eq!(stack.count, 1);
|
||||
let data = stack.data();
|
||||
let dropped_lock = data.and_then(|d| d.other("minecraft:lock"));
|
||||
if shulker {
|
||||
let data = data.expect("actual shulker components");
|
||||
assert_eq!(data.custom_name, Some(Tag::String("Box".into())));
|
||||
let slots = &data.container;
|
||||
assert_eq!(slots.len(), 2);
|
||||
let diamond = &slots.iter().find(|(slot, _)| *slot == 0).unwrap().1;
|
||||
assert_eq!((&*diamond.item, diamond.count), ("minecraft:diamond", 3));
|
||||
assert_eq!(
|
||||
diamond
|
||||
.data()
|
||||
.and_then(|d| d.other("minecraft:custom_data"))
|
||||
.and_then(|t| t.get("h211")),
|
||||
Some(&Tag::Int(1))
|
||||
);
|
||||
assert_eq!(
|
||||
slots.iter().find(|(slot, _)| *slot == 26).unwrap().1.count,
|
||||
5
|
||||
);
|
||||
}
|
||||
if shulker && locked {
|
||||
if dropped_lock != actual_lock.as_ref() {
|
||||
missing.push((route, dropped_lock.cloned(), actual_lock));
|
||||
}
|
||||
} else {
|
||||
assert!(
|
||||
dropped_lock.is_none(),
|
||||
"non-shulker/unlocked policy unchanged"
|
||||
);
|
||||
}
|
||||
assert_eq!(game.world.game_time, clock, "zero observation ticks");
|
||||
}
|
||||
assert!(
|
||||
missing.is_empty(),
|
||||
"actual external lock lost at destruction capture sites: {missing:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,66 @@
|
||||
use super::*;
|
||||
|
||||
impl Game<'_> {
|
||||
/// Cache only model-dependent work, never an entity's seed, light or pose.
|
||||
pub(super) fn falling_model_vertices(
|
||||
&self,
|
||||
state: BlockStateId,
|
||||
start_pos: BlockPos,
|
||||
origin: glam::Vec3,
|
||||
light: u32,
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
// `FallingBlockRenderer`: the model is picked by the block's starting position.
|
||||
let seed = horizon_core::random::position_seed(start_pos);
|
||||
let Ok(models) = self.pack.render.model_catalog.collect_seeded(state, seed) else {
|
||||
return;
|
||||
};
|
||||
// Baked models live at stable addresses until the pack is replaced.
|
||||
// Keep multipart order and duplicates: both affect the emitted vertices.
|
||||
let mut cache = self.falling_vertices.borrow_mut();
|
||||
let entries = cache.entry(state).or_default();
|
||||
let index = entries.iter().position(|(ptrs, _)| {
|
||||
ptrs.iter()
|
||||
.copied()
|
||||
.eq(models.iter().map(|(_, m)| *m as *const _ as usize))
|
||||
});
|
||||
let index = index.unwrap_or_else(|| {
|
||||
let mut vertices = Vec::new();
|
||||
self.block_model_vertices(state, seed, glam::Mat4::IDENTITY, 0, false, &mut vertices);
|
||||
// Preserve the original coordinates, including signed zero, rather
|
||||
// than composing an identity transform with the frame's translation.
|
||||
let positions = models.iter().flat_map(|(_, model)| {
|
||||
model
|
||||
.faces
|
||||
.iter()
|
||||
.filter(|face| {
|
||||
self.pack
|
||||
.layer_names
|
||||
.get(&face.layer)
|
||||
.is_some_and(|name| self.gui.atlas.sprites.contains_key(name))
|
||||
})
|
||||
.flat_map(|face| face.quad.positions)
|
||||
});
|
||||
for (vertex, pos) in vertices.iter_mut().zip(positions) {
|
||||
vertex.pos = pos;
|
||||
}
|
||||
let ptrs = models
|
||||
.iter()
|
||||
.map(|(_, m)| *m as *const _ as usize)
|
||||
.collect();
|
||||
entries.push((ptrs, vertices));
|
||||
entries.len() - 1
|
||||
});
|
||||
let start = out.len();
|
||||
out.extend_from_slice(&entries[index].1);
|
||||
let matrix = glam::Mat4::from_translation(origin);
|
||||
for vertex in &mut out[start..] {
|
||||
vertex.pos = matrix
|
||||
.transform_point3(glam::Vec3::from_array(vertex.pos))
|
||||
.to_array();
|
||||
vertex.light = light;
|
||||
}
|
||||
}
|
||||
/// FallingBlockEntity.tick: fall with 0.04 gravity and 0.98F drag, then land.
|
||||
/// One falling block's `tick`: places or drops its block in its own tick,
|
||||
/// so a block falling onto one that just landed lands on top of it. `i` is
|
||||
@@ -14,6 +74,25 @@ impl Game<'_> {
|
||||
/// shifted every block after it twice, for every falling block, every tick.
|
||||
/// One that lands, drops or goes leaves the list before its block is placed
|
||||
/// or dropped, as before; on an error the list keeps it as it was.
|
||||
/// The end of a piston's `Entity.move(MoverType.PISTON, …)` for falling
|
||||
/// block `i` after a step of `moved`: `checkFallDamage` adds the drop and,
|
||||
/// on the ground, runs fallOn and posts HIT_GROUND after a fall, then
|
||||
/// forgets it, so its own next tick does not land again.
|
||||
pub(super) fn finish_falling_piston_move(&mut self, i: usize, moved: Vec3) {
|
||||
let mut f = self.falling[i].clone();
|
||||
if moved.y < 0.0 {
|
||||
f.fall_distance -= f64::from(moved.y as f32);
|
||||
}
|
||||
if f.on_ground {
|
||||
self.falling_block_impact(&mut f);
|
||||
if f.fall_distance > 0.0 {
|
||||
let bounds = horizon_client::falling_block::bounds(f.position);
|
||||
self.post_landing_event(f.position, bounds, false);
|
||||
}
|
||||
f.fall_distance = 0.0;
|
||||
}
|
||||
self.falling[i] = f;
|
||||
}
|
||||
pub(super) fn tick_one_falling(&mut self, i: usize, entities: &[Aabb]) -> Result<()> {
|
||||
let entity_drops = self.ui.rules.bool("entity_drops");
|
||||
let join = self.falling[i].join;
|
||||
@@ -134,7 +213,12 @@ impl Game<'_> {
|
||||
f.velocity.z = 0.0;
|
||||
}
|
||||
if on_ground {
|
||||
// fallOn (causeFallDamage), then HIT_GROUND after a fall.
|
||||
self.falling_block_impact(f);
|
||||
if f.fall_distance > 0.0 {
|
||||
let bounds = horizon_client::falling_block::bounds(f.position);
|
||||
self.post_landing_event(f.position, bounds, false);
|
||||
}
|
||||
f.fall_distance = 0.0;
|
||||
}
|
||||
// applyEffectsFromBlocks: WebBlock.entityInside makes it stuck
|
||||
@@ -233,3 +317,98 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod geometry_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn falling_model_cache_matches_direct_vertices() {
|
||||
let root = PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
|
||||
let settings = Settings::default();
|
||||
let loaded = load_resources(
|
||||
root.clone(),
|
||||
None,
|
||||
&root,
|
||||
&settings,
|
||||
Instant::now(),
|
||||
false,
|
||||
false,
|
||||
&Progress::new(),
|
||||
&mut |_, _| unreachable!(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
|
||||
options.resources = root.clone();
|
||||
options.execution_role = execution_role::ExecutionRole::IntegratedServer;
|
||||
let game = Game::new(
|
||||
&loaded.resources,
|
||||
loaded.pack,
|
||||
loaded.gui.unwrap(),
|
||||
options,
|
||||
settings,
|
||||
Audio::silent(&root),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
for frame in 0..2 {
|
||||
for (name, pos, origin, light) in [
|
||||
(
|
||||
"minecraft:sand",
|
||||
BlockPos::new(1, 80, 3),
|
||||
glam::Vec3::new(-3.25, 12.5, 0.0),
|
||||
0x7010,
|
||||
),
|
||||
(
|
||||
"minecraft:sand",
|
||||
BlockPos::new(9, 90, -4),
|
||||
glam::Vec3::new(8.125, -0.0, -9.5),
|
||||
0xf020,
|
||||
),
|
||||
(
|
||||
"minecraft:gravel",
|
||||
BlockPos::new(-2, 70, 5),
|
||||
glam::Vec3::new(-0.0, -17.75, 2.5),
|
||||
0x5080,
|
||||
),
|
||||
] {
|
||||
let state = game.pack.assets.registry.default_state(name).unwrap();
|
||||
let seed = horizon_core::random::position_seed(pos);
|
||||
let mut expected = Vec::new();
|
||||
game.block_model_vertices(
|
||||
state,
|
||||
seed,
|
||||
glam::Mat4::from_translation(origin),
|
||||
light,
|
||||
false,
|
||||
&mut expected,
|
||||
);
|
||||
let mut actual = Vec::new();
|
||||
game.falling_model_vertices(state, pos, origin, light, &mut actual);
|
||||
let (_, tint) = game.block_face_sprite(state).unwrap();
|
||||
let tint = horizon_client::mobs::render::abgr(tint);
|
||||
for vertices in [&mut expected, &mut actual] {
|
||||
for v in vertices {
|
||||
v.color = horizon_client::hud_layout::multiply(v.color, tint);
|
||||
}
|
||||
}
|
||||
assert!(!expected.is_empty());
|
||||
assert_eq!(actual.len(), expected.len());
|
||||
for (a, b) in actual.iter().zip(&expected) {
|
||||
assert_eq!(
|
||||
a.pos.map(f32::to_bits),
|
||||
b.pos.map(f32::to_bits),
|
||||
"frame {frame}"
|
||||
);
|
||||
assert_eq!(a.uv.map(f32::to_bits), b.uv.map(f32::to_bits));
|
||||
assert_eq!(a.glint_uv.map(f32::to_bits), b.glint_uv.map(f32::to_bits));
|
||||
assert_eq!(
|
||||
(a.color, a.light, a.normal, a.layer),
|
||||
(b.color, b.light, b.normal, b.layer)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -342,6 +342,8 @@ impl Game<'_> {
|
||||
if !self.creative {
|
||||
self.consume_held();
|
||||
}
|
||||
// `ItemStack.useOn` awards ITEM_USED for `FireworkRocketItem.useOn`'s SUCCESS.
|
||||
self.award_stat("used", "minecraft:firework_rocket", 1);
|
||||
self.swing_arm();
|
||||
true
|
||||
}
|
||||
@@ -571,6 +573,8 @@ impl Game<'_> {
|
||||
host.hub
|
||||
.entity_event(r.id as i32, FIREWORK_EXPLODE_EVENT, at);
|
||||
}
|
||||
// `this.gameEvent(GameEvent.EXPLODE, this.getOwner())`, before the blast.
|
||||
self.rocket_explode_event(r.owner, r.guest, at);
|
||||
if !r.explosions.is_empty() {
|
||||
let damage = 5.0 + r.explosions.len() as f32 * 2.0;
|
||||
let from = r.position;
|
||||
@@ -648,9 +652,34 @@ impl Game<'_> {
|
||||
let camera = self.eye_position().unwrap_or(at);
|
||||
let v = [r.velocity.x, r.velocity.y, r.velocity.z];
|
||||
self.particles
|
||||
.fireworks(at, v, r.explosions.clone(), camera);
|
||||
.fireworks(at, v, r.explosions.clone(), camera, !r.silent);
|
||||
self.native_firework_explode(r.id, at, r.explosions.len());
|
||||
}
|
||||
/// `FireworkRocketEntity.explode`'s game event: EXPLODE at the rocket, from
|
||||
/// its owner (`Projectile.getOwner`), a guest's rocket as theirs.
|
||||
fn rocket_explode_event(&mut self, mut owner: Option<Who>, guest: Option<u32>, at: [f64; 3]) {
|
||||
if let Some(conn) = guest.filter(|conn| self.lan_acting != Some(*conn)) {
|
||||
let posted = if let Some(mut host) = self.lan.take() {
|
||||
let posted = self
|
||||
.as_guest(&mut host, conn, |g| {
|
||||
let ctx = g.who_context(owner, None);
|
||||
g.post_game_event("minecraft:explode", at, ctx);
|
||||
})
|
||||
.is_some();
|
||||
self.lan = Some(host);
|
||||
posted
|
||||
} else {
|
||||
false
|
||||
};
|
||||
if posted {
|
||||
return;
|
||||
}
|
||||
// An owner who left cannot resolve to the host: no source entity.
|
||||
owner = None;
|
||||
}
|
||||
let ctx = self.who_context(owner, None);
|
||||
self.post_game_event("minecraft:explode", at, ctx);
|
||||
}
|
||||
fn rocket_hurt_mobs(&mut self, hits: &[(u32, f32)], attacker: Option<Who>, at: [f64; 3]) {
|
||||
let _attacker = self.knockback_scope(if attacker == Some(Who::Player) {
|
||||
self.player.as_ref().map_or(0, |p| p.entity_id)
|
||||
@@ -751,7 +780,7 @@ impl Game<'_> {
|
||||
let at = [r.position.x, r.position.y, r.position.z];
|
||||
let v = [r.velocity.x, r.velocity.y, r.velocity.z];
|
||||
self.particles
|
||||
.fireworks(at, v, r.explosions.clone(), camera.unwrap_or(at));
|
||||
.fireworks(at, v, r.explosions.clone(), camera.unwrap_or(at), !r.silent);
|
||||
}
|
||||
}
|
||||
/// `dealExplosionDamage` on a player that is not the rocket's
|
||||
@@ -861,6 +890,10 @@ fn use_on_passes(fall_flying: bool) -> bool {
|
||||
fall_flying
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "fireworks_events_tests.rs"]
|
||||
mod events_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
/// A rocket draws its UUID's two longs first, then the motion's two triangles
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
//! `FireworkRocketEntity.explode`'s game event and sound gate, and the
|
||||
//! `ITEM_USED` an `useOn` counts. Prepared resources only.
|
||||
use super::Game;
|
||||
use crate::game::{Audio, Options, Progress, Settings, load_resources};
|
||||
use horizon_client::{
|
||||
chunk::Chunk,
|
||||
items::ItemStack,
|
||||
mobs::Who,
|
||||
particles::{FireworkExplosion, FireworkShape, ParticleLevel},
|
||||
};
|
||||
use horizon_core::{geometry::Vec3, position::ChunkPos, random::LegacyRandom};
|
||||
use std::sync::Arc;
|
||||
|
||||
fn with_game(test: impl FnOnce(&mut Game<'_>)) {
|
||||
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
|
||||
let settings = Settings::default();
|
||||
let loaded = load_resources(
|
||||
root.clone(),
|
||||
None,
|
||||
&root,
|
||||
&settings,
|
||||
std::time::Instant::now(),
|
||||
false,
|
||||
true,
|
||||
&Progress::new(),
|
||||
&mut |_, _| unreachable!(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
|
||||
options.resources = root.clone();
|
||||
options.execution_role = crate::game::execution_role::ExecutionRole::IntegratedServer;
|
||||
let mut game = Game::new(
|
||||
&loaded.resources,
|
||||
loaded.pack,
|
||||
loaded.gui.unwrap(),
|
||||
options,
|
||||
settings,
|
||||
Audio::silent(&root),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
game.world
|
||||
.chunks
|
||||
.insert((0, 0), Arc::new(Chunk::empty(0, 0)));
|
||||
game.block_ticks
|
||||
.add_empty_chunk(ChunkPos::new(0, 0))
|
||||
.unwrap();
|
||||
game.player = Some(
|
||||
horizon_client::motion::PlayerMotion::new(game.motion, Vec3::new(2.5, 200.0, 2.5)).unwrap(),
|
||||
);
|
||||
game.world.random = LegacyRandom::new(114);
|
||||
test(&mut game);
|
||||
}
|
||||
|
||||
/// One `small_ball` that twinkles, as `/summon` reads it: a `Starter` of 16
|
||||
/// ticks that plays a blast first and a twinkle last.
|
||||
fn twinkling() -> FireworkExplosion {
|
||||
FireworkExplosion {
|
||||
shape: FireworkShape::SmallBall,
|
||||
colors: vec![0xFF5555],
|
||||
fade: Vec::new(),
|
||||
trail: true,
|
||||
twinkle: true,
|
||||
}
|
||||
}
|
||||
|
||||
fn tick_particles(game: &mut Game<'_>) {
|
||||
let level = ParticleLevel {
|
||||
world: &game.world,
|
||||
facts: game.res.facts(),
|
||||
collision: game.collision,
|
||||
player: None,
|
||||
cached_chunk: std::cell::Cell::new(None),
|
||||
};
|
||||
game.particles.tick(&level);
|
||||
}
|
||||
|
||||
/// `FireworkParticles.Starter.playSound`: a silent rocket launches quietly (as
|
||||
/// it already did) and bursts quietly too, drawing its sparks and flash only.
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_silent_rocket_bursts_without_blast_or_twinkle() {
|
||||
with_game(|game| {
|
||||
let stack = ItemStack::new("minecraft:firework_rocket", 1);
|
||||
let mut launching = game.new_rocket(
|
||||
Vec3::new(6.5, 201.5, 10.5),
|
||||
&stack,
|
||||
false,
|
||||
&mut LegacyRandom::new(0),
|
||||
);
|
||||
launching.silent = true;
|
||||
launching.lifetime = 100;
|
||||
game.rockets.push(launching);
|
||||
game.audio.capture = Some(Vec::new());
|
||||
game.tick_rockets();
|
||||
let heard = game.audio.capture.take().unwrap_or_default();
|
||||
assert!(
|
||||
heard
|
||||
.iter()
|
||||
.all(|s| s.event != "minecraft:entity.firework_rocket.launch"),
|
||||
"a silent rocket launches quietly: {heard:?}"
|
||||
);
|
||||
game.rockets.clear();
|
||||
|
||||
let (quiet_at, loud_at) = ([6.5, 201.5, 10.5], [12.5, 201.5, 10.5]);
|
||||
let mut quiet = game.new_rocket(
|
||||
Vec3::new(quiet_at[0], quiet_at[1], quiet_at[2]),
|
||||
&stack,
|
||||
false,
|
||||
&mut LegacyRandom::new(0),
|
||||
);
|
||||
quiet.silent = true;
|
||||
quiet.explosions = vec![twinkling()];
|
||||
let mut loud = game.new_rocket(
|
||||
Vec3::new(loud_at[0], loud_at[1], loud_at[2]),
|
||||
&stack,
|
||||
false,
|
||||
&mut LegacyRandom::new(0),
|
||||
);
|
||||
loud.explosions = vec![twinkling()];
|
||||
game.explode_rocket(&quiet);
|
||||
game.explode_rocket(&loud);
|
||||
game.particles.sounds.clear();
|
||||
for _ in 0..20 {
|
||||
tick_particles(game);
|
||||
}
|
||||
let bursts: Vec<(&str, [f64; 3])> = game
|
||||
.particles
|
||||
.sounds
|
||||
.iter()
|
||||
.filter(|s| s.0.starts_with("minecraft:entity.firework_rocket."))
|
||||
.map(|s| (s.0, s.1))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
bursts,
|
||||
vec![
|
||||
("minecraft:entity.firework_rocket.blast", loud_at),
|
||||
("minecraft:entity.firework_rocket.twinkle", loud_at),
|
||||
],
|
||||
"only the loud rocket bangs and twinkles, both at its own position"
|
||||
);
|
||||
assert!(
|
||||
!game.particles.is_empty(),
|
||||
"the silent rocket still drew its sparks and flash"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// Recording of `Game::post_game_event`, as the sculk tests read it.
|
||||
fn start_events() {
|
||||
crate::game::sculk::TEST_POSTED_EVENTS.with(|e| *e.borrow_mut() = Some(Vec::new()));
|
||||
}
|
||||
|
||||
fn take_explosions() -> Vec<(&'static str, [f64; 3], Option<Who>)> {
|
||||
crate::game::sculk::TEST_POSTED_EVENTS.with(|e| {
|
||||
std::mem::take(e.borrow_mut().as_mut().unwrap())
|
||||
.into_iter()
|
||||
.filter(|e| e.0 == "minecraft:explode")
|
||||
.collect()
|
||||
})
|
||||
}
|
||||
|
||||
fn stop_events() {
|
||||
crate::game::sculk::TEST_POSTED_EVENTS.with(|e| *e.borrow_mut() = None);
|
||||
}
|
||||
|
||||
/// `FireworkRocketEntity.explode`: `gameEvent(GameEvent.EXPLODE, getOwner())` at
|
||||
/// the rocket, once, with and without explosions; a rocket nobody owns (a
|
||||
/// dispenser's) has no source entity.
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_detonating_rocket_posts_one_explode_from_its_owner() {
|
||||
with_game(|game| {
|
||||
let stack = ItemStack::new("minecraft:firework_rocket", 1);
|
||||
let mut burst = game.new_rocket(
|
||||
Vec3::new(8.5, 202.0, 8.5),
|
||||
&stack,
|
||||
false,
|
||||
&mut LegacyRandom::new(0),
|
||||
);
|
||||
burst.explosions = vec![twinkling()];
|
||||
burst.owner = Some(Who::Player);
|
||||
let mut plain = game.new_rocket(
|
||||
Vec3::new(9.5, 202.0, 8.5),
|
||||
&stack,
|
||||
false,
|
||||
&mut LegacyRandom::new(0),
|
||||
);
|
||||
plain.owner = Some(Who::Player);
|
||||
let mut stray = game.new_rocket(
|
||||
Vec3::new(10.5, 202.0, 8.5),
|
||||
&stack,
|
||||
false,
|
||||
&mut LegacyRandom::new(0),
|
||||
);
|
||||
stray.explosions = vec![twinkling()];
|
||||
|
||||
start_events();
|
||||
game.explode_rocket(&burst);
|
||||
assert_eq!(
|
||||
take_explosions(),
|
||||
vec![("minecraft:explode", [8.5, 202.0, 8.5], Some(Who::Player))],
|
||||
"the burst is heard once, at the rocket, from the player who shot it"
|
||||
);
|
||||
game.explode_rocket(&plain);
|
||||
assert_eq!(
|
||||
take_explosions(),
|
||||
vec![("minecraft:explode", [9.5, 202.0, 8.5], Some(Who::Player))],
|
||||
"a rocket with no explosions still posts it"
|
||||
);
|
||||
game.explode_rocket(&stray);
|
||||
assert_eq!(
|
||||
take_explosions(),
|
||||
vec![("minecraft:explode", [10.5, 202.0, 8.5], None)],
|
||||
"`getOwner()` is null: no source entity"
|
||||
);
|
||||
stop_events();
|
||||
});
|
||||
}
|
||||
|
||||
/// `ItemStack.useOn` awards `ITEM_USED` for `FireworkRocketItem.useOn`'s
|
||||
/// SUCCESS; `FireworkRocketItem.use` while gliding awards its own.
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn launching_from_a_block_and_while_gliding_use_the_rocket_up() {
|
||||
with_game(|game| {
|
||||
game.creative = false;
|
||||
let stack = ItemStack::new("minecraft:firework_rocket", 1);
|
||||
let hit = super::Hit {
|
||||
pos: [2, 199, 2],
|
||||
face: [0, 1, 0],
|
||||
point: [2.5, 200.0, 2.5],
|
||||
};
|
||||
for used in 1..=5 {
|
||||
game.inventory[game.selected] = Some(stack.clone());
|
||||
assert!(game.launch_firework(&hit, &stack), "a rocket goes up");
|
||||
assert_eq!(
|
||||
game.stats.get("used", "minecraft:firework_rocket"),
|
||||
used,
|
||||
"each right-click on the block counts one use"
|
||||
);
|
||||
}
|
||||
game.player.as_mut().unwrap().fall_flying = true;
|
||||
game.inventory[game.selected] = Some(stack.clone());
|
||||
assert!(game.boost_firework(&stack), "the gliding boost launches");
|
||||
assert_eq!(game.stats.get("used", "minecraft:firework_rocket"), 6);
|
||||
});
|
||||
}
|
||||
@@ -297,23 +297,23 @@ impl Game<'_> {
|
||||
action = "put";
|
||||
self.audio
|
||||
.play(&sound(&self.item_frames[i], "add_item"), Some(at), 1.0, 1.0);
|
||||
if !self.creative {
|
||||
self.consume_held();
|
||||
}
|
||||
} else {
|
||||
let s = sound(&self.item_frames[i], "rotate_item");
|
||||
self.audio.play(&s, Some(at), 1.0, 1.0);
|
||||
let f = &mut self.item_frames[i];
|
||||
f.rotation = (f.rotation + 1) % 8;
|
||||
action = "rotate";
|
||||
let s = sound(f, "rotate_item");
|
||||
self.audio.play(&s, Some(at), 1.0, 1.0);
|
||||
}
|
||||
let p = self.item_frames[i].pos;
|
||||
self.world.modified.insert((p[0] >> 4, p[2] >> 4));
|
||||
self.frame_output_changed(p);
|
||||
// ItemFrame.interact: BLOCK_CHANGE by the player, after setItem or
|
||||
// setRotation.
|
||||
// ItemFrame.interact: BLOCK_CHANGE by the player after setItem or
|
||||
// setRotation, then the held item is consumed.
|
||||
let ctx = self.who_context(Some(horizon_client::mobs::Who::Player), None);
|
||||
self.post_game_event("minecraft:block_change", at, ctx);
|
||||
if action == "put" && !self.creative {
|
||||
self.consume_held();
|
||||
}
|
||||
if let (Some(player), Some(name)) = (self.lan_acting_uuid, self.lan_acting_name.clone()) {
|
||||
let frame = self.item_frames.iter().find(|f| f.id == entity_id);
|
||||
let item = frame
|
||||
@@ -362,18 +362,16 @@ impl Game<'_> {
|
||||
} else {
|
||||
self.item_frames[i].clone()
|
||||
};
|
||||
self.audio.play(
|
||||
&sound(&f, if with_frame { "break" } else { "remove_item" }),
|
||||
Some(at),
|
||||
1.0,
|
||||
1.0,
|
||||
);
|
||||
if !with_frame {
|
||||
self.item_frames[i].item = None;
|
||||
} else {
|
||||
// BlockAttachedEntity.kill, before dropItem. A frame holding an
|
||||
// item only gives the item up (ItemFrame.hurtServer).
|
||||
// BlockAttachedEntity.kill precedes ItemFrame.dropItem's break sound.
|
||||
self.entity_killed_event(at, true);
|
||||
self.audio.play(&sound(&f, "break"), Some(at), 1.0, 1.0);
|
||||
}
|
||||
// ItemFrame.dropItem clears the item (updating comparators) before drops.
|
||||
if f.item.is_some() {
|
||||
self.frame_output_changed(f.pos);
|
||||
}
|
||||
self.drop_frame_items(&mut f, with_frame, true);
|
||||
if !with_frame {
|
||||
@@ -383,10 +381,12 @@ impl Game<'_> {
|
||||
// for the item, ItemFrame.dropItem(level, causedBy) for the frame.
|
||||
let ctx = self.who_context(Some(horizon_client::mobs::Who::Player), None);
|
||||
self.post_game_event("minecraft:block_change", at, ctx);
|
||||
self.world.modified.insert((f.pos[0] >> 4, f.pos[2] >> 4));
|
||||
if f.item.is_some() {
|
||||
self.frame_output_changed(f.pos);
|
||||
if !with_frame {
|
||||
// ItemFrame.hurtServer plays this after the event.
|
||||
self.audio
|
||||
.play(&sound(&f, "remove_item"), Some(at), 1.0, 1.0);
|
||||
}
|
||||
self.world.modified.insert((f.pos[0] >> 4, f.pos[2] >> 4));
|
||||
if let (Some(player), Some(name)) = (self.lan_acting_uuid, self.lan_acting_name.clone()) {
|
||||
self.native_player_item_frame_change(
|
||||
player,
|
||||
@@ -519,16 +519,16 @@ impl Game<'_> {
|
||||
if !self.frame_survives(&f) && !self.hanging_break_cancelled(f.kind(), f.pos, false)
|
||||
{
|
||||
self.item_frames.remove(i);
|
||||
self.entity_killed_event(center(&f), false);
|
||||
// BlockAttachedEntity.tick discards without a death event.
|
||||
self.audio
|
||||
.play(&sound(&f, "break"), Some(center(&f)), 1.0, 1.0);
|
||||
self.drop_frame_items(&mut f, true, false);
|
||||
// ItemFrame.dropItem(level, null): BLOCK_CHANGE after the drops.
|
||||
let ctx = self.who_context(None, None);
|
||||
self.post_game_event("minecraft:block_change", center(&f), ctx);
|
||||
if f.item.is_some() {
|
||||
self.frame_output_changed(f.pos);
|
||||
}
|
||||
self.drop_frame_items(&mut f, true, false);
|
||||
// ItemFrame.dropItem(level, null): sound, drops, then event.
|
||||
let ctx = self.who_context(None, None);
|
||||
self.post_game_event("minecraft:block_change", center(&f), ctx);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
@@ -840,3 +840,121 @@ mod map_limit_tests {
|
||||
assert_eq!(framed.and_then(|s| s.data()?.map_id), Some(8));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod game_event_tests {
|
||||
use super::super::native_machines::tests::server_game;
|
||||
use super::*;
|
||||
use horizon_client::mobs::Who;
|
||||
|
||||
fn frame(id: u32, pos: [i32; 3], item: Option<ItemStack>) -> ItemFrame {
|
||||
ItemFrame {
|
||||
id,
|
||||
pos,
|
||||
facing: 3,
|
||||
glow: false,
|
||||
item,
|
||||
rotation: 0,
|
||||
drop_chance: 1.0,
|
||||
fixed: false,
|
||||
invisible: false,
|
||||
check_interval: 0,
|
||||
custom_name: None,
|
||||
random: horizon_core::random::LegacyRandom::new(0),
|
||||
}
|
||||
}
|
||||
fn at(id: u32) -> Option<InteractTarget> {
|
||||
Some(InteractTarget {
|
||||
id: id as i32,
|
||||
location: [0.0; 3],
|
||||
})
|
||||
}
|
||||
/// The events posted since [`reset`].
|
||||
fn posted() -> Vec<(&'static str, [f64; 3], Option<Who>)> {
|
||||
super::super::sculk::TEST_POSTED_EVENTS.with(|e| e.borrow().clone().unwrap_or_default())
|
||||
}
|
||||
fn reset() {
|
||||
super::super::sculk::TEST_POSTED_EVENTS.with(|e| *e.borrow_mut() = Some(Vec::new()));
|
||||
}
|
||||
|
||||
/// `ItemFrame.interact`: putting an item in and turning it each post
|
||||
/// `GameEvent.BLOCK_CHANGE` from the player, at the frame's position.
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn putting_and_rotating_a_frame_item_posts_block_change() {
|
||||
let mut game = server_game();
|
||||
game.item_frames = vec![frame(1, [0, 64, 1], None)];
|
||||
let center = [0.5, 64.5, 1.03125];
|
||||
*game.held_mut() = Some(ItemStack::new("minecraft:stick", 3));
|
||||
|
||||
let expected = [("minecraft:block_change", center, Some(Who::Player))];
|
||||
reset();
|
||||
assert!(game.interact_frame(at(1)));
|
||||
assert_eq!(posted(), expected);
|
||||
assert_eq!(game.held().map(|s| s.count), Some(2), "one goes in");
|
||||
assert_eq!(game.item_frames[0].item.as_ref().map(|s| s.count), Some(1));
|
||||
|
||||
reset();
|
||||
assert!(game.interact_frame(at(1)));
|
||||
assert_eq!(posted(), expected);
|
||||
assert_eq!(game.item_frames[0].rotation, 1);
|
||||
}
|
||||
|
||||
/// `ItemFrame.hurtServer`: the item coming out posts `BLOCK_CHANGE` from
|
||||
/// the player hitting the frame.
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn taking_a_frame_item_out_posts_block_change_from_the_player() {
|
||||
let mut game = server_game();
|
||||
game.item_frames = vec![frame(
|
||||
1,
|
||||
[0, 64, 1],
|
||||
Some(ItemStack::new("minecraft:stick", 1)),
|
||||
)];
|
||||
let center = [0.5, 64.5, 1.03125];
|
||||
// A player looking straight at the frame's centre from two blocks away
|
||||
// (eye height 1.62 above the feet).
|
||||
let feet = Vec3::new(0.5, 64.5 - 1.62, -1.0);
|
||||
game.player =
|
||||
Some(horizon_client::motion::PlayerMotion::new(game.motion, feet).expect("a player"));
|
||||
game.yaw = 0.0;
|
||||
game.pitch = 0.0;
|
||||
|
||||
let expected = [("minecraft:block_change", center, Some(Who::Player))];
|
||||
reset();
|
||||
assert!(game.attack_frame(), "the frame is in reach");
|
||||
assert!(game.item_frames[0].item.is_none());
|
||||
assert_eq!(posted(), expected);
|
||||
|
||||
// The empty frame itself then breaks (`BlockAttachedEntity.hurtServer`
|
||||
// then `ItemFrame.dropItem`), with the same event.
|
||||
reset();
|
||||
assert!(game.attack_frame());
|
||||
assert!(game.item_frames.is_empty());
|
||||
assert_eq!(
|
||||
posted(),
|
||||
[
|
||||
("minecraft:entity_die", center, Some(Who::Player)),
|
||||
("minecraft:block_change", center, Some(Who::Player))
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/// `ItemFrame.dropItem` from `BlockAttachedEntity.tick`, when a frame
|
||||
/// loses the block it hangs on: the event has no source.
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_frame_that_falls_posts_block_change_without_source() {
|
||||
let mut game = server_game();
|
||||
let mut f = frame(1, [0, 64, 1], None);
|
||||
// BlockAttachedEntity.tick checks every 100 ticks.
|
||||
f.check_interval = 99;
|
||||
game.item_frames = vec![f];
|
||||
let center = [0.5, 64.5, 1.03125];
|
||||
|
||||
reset();
|
||||
game.tick_item_frames();
|
||||
assert!(game.item_frames.is_empty(), "nothing holds the frame");
|
||||
assert_eq!(posted(), [("minecraft:block_change", center, None)]);
|
||||
}
|
||||
}
|
||||
@@ -253,11 +253,17 @@ impl Game<'_> {
|
||||
registries.function_tags = store.function_tags.keys().cloned().collect();
|
||||
registries.predicates = store.predicates.keys().cloned().collect();
|
||||
registries.item_modifiers = store.item_modifiers.keys().cloned().collect();
|
||||
registries
|
||||
let sources = registries
|
||||
.by_registry
|
||||
.entry("minecraft:slot_source".into())
|
||||
.or_default()
|
||||
.ids = store.slot_sources.keys().cloned().collect();
|
||||
.or_default();
|
||||
sources.ids = store.slot_sources.keys().cloned().collect();
|
||||
sources.tags = store
|
||||
.slot_source_tags
|
||||
.keys()
|
||||
.filter(|id| store.slot_source_tag(id).is_ok())
|
||||
.cloned()
|
||||
.collect();
|
||||
for (id, ids) in [
|
||||
("minecraft:predicate", ®istries.predicates),
|
||||
("minecraft:item_modifier", ®istries.item_modifiers),
|
||||
@@ -311,10 +317,19 @@ impl Game<'_> {
|
||||
reg.function_tags = self.fns.store.function_tags.keys().cloned().collect();
|
||||
reg.predicates = self.fns.store.predicates.keys().cloned().collect();
|
||||
reg.item_modifiers = self.fns.store.item_modifiers.keys().cloned().collect();
|
||||
reg.by_registry
|
||||
let sources = reg
|
||||
.by_registry
|
||||
.entry("minecraft:slot_source".into())
|
||||
.or_default()
|
||||
.ids = self.fns.store.slot_sources.keys().cloned().collect();
|
||||
.or_default();
|
||||
sources.ids = self.fns.store.slot_sources.keys().cloned().collect();
|
||||
sources.tags = self
|
||||
.fns
|
||||
.store
|
||||
.slot_source_tags
|
||||
.keys()
|
||||
.filter(|id| self.fns.store.slot_source_tag(id).is_ok())
|
||||
.cloned()
|
||||
.collect();
|
||||
reg.schedules = self.fns.schedule.iter().map(|s| s.id.clone()).collect();
|
||||
reg.datapacks_enabled = std::iter::once("vanilla".to_owned())
|
||||
.chain(self.fns.enabled.iter().cloned())
|
||||
|
||||
@@ -740,18 +740,24 @@ impl Game<'_> {
|
||||
/// Haste and Mining Fatigue modifiers (`ADD_MULTIPLIED_TOTAL`), clamped to
|
||||
/// the attribute's range.
|
||||
pub(super) fn main_hand_attack_speed(&self) -> f64 {
|
||||
// The base comes from the player's attribute map so `/attribute ... base set` applies.
|
||||
if let Some(player) = &self.player
|
||||
&& let Ok(speed) = player
|
||||
.attributes
|
||||
.computed_value_of("minecraft:attack_speed")
|
||||
&& (self.remote.is_some()
|
||||
|| !self.applied_modifiers.is_empty()
|
||||
|| !self.potions.applied.is_empty())
|
||||
{
|
||||
return speed.clamp(0.0, 1024.0);
|
||||
}
|
||||
let base = self
|
||||
.player
|
||||
.as_ref()
|
||||
.and_then(|player| {
|
||||
let key = player.attributes.key("minecraft:attack_speed")?;
|
||||
let handle = player.attributes.lookup(Some(&key))?;
|
||||
player
|
||||
.attributes
|
||||
.instance(&handle)
|
||||
.computed_value_of("minecraft:attack_speed")
|
||||
.ok()
|
||||
.map(|value| value.base_value())
|
||||
})
|
||||
.unwrap_or(horizon_client::attack_strength::BASE_ATTACK_SPEED);
|
||||
let value = base
|
||||
|
||||
@@ -21,6 +21,27 @@ pub(super) struct ItemAreas {
|
||||
}
|
||||
|
||||
impl ItemAreas {
|
||||
/// Build the grid for villager item sensors only when many due sensors
|
||||
/// would otherwise repeatedly scan a large, spatially spread item list.
|
||||
/// Sampling a few 32-block columns avoids overhead for crowded piles.
|
||||
pub(super) fn worthwhile(items: &[ItemEntity], queries: usize) -> bool {
|
||||
if items.len() < 2048 || queries < 32 {
|
||||
return false;
|
||||
}
|
||||
let stride = (items.len() / 32).max(1);
|
||||
let mut columns = std::collections::HashSet::new();
|
||||
for item in items.iter().step_by(stride) {
|
||||
let p = item.position;
|
||||
if p.is_finite() {
|
||||
columns.insert((cell(p.x) >> 2, cell(p.z) >> 2));
|
||||
if columns.len() >= 9 {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Whether an item of `items` may meet `area`: false only when none can.
|
||||
pub(super) fn may_meet(&mut self, items: &[ItemEntity], next_entity: u32, area: Aabb) -> bool {
|
||||
self.build(items, next_entity);
|
||||
@@ -308,6 +329,57 @@ mod tests {
|
||||
assert_eq!(scan.next_from(2, items.len()), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn villager_sensing_candidates_match_full_scan_order() {
|
||||
let items: Vec<_> = (0..2600)
|
||||
.map(|i| {
|
||||
let x = f64::from(i % 52) * 31.0 - 800.0;
|
||||
let z = f64::from(i / 52) * 31.0 - 750.0;
|
||||
item_at(x, 64.0 + f64::from(i % 3) * 0.25, z)
|
||||
})
|
||||
.collect();
|
||||
assert!(ItemAreas::worthwhile(&items, 32));
|
||||
assert!(!ItemAreas::worthwhile(&items, 31));
|
||||
|
||||
let clustered: Vec<_> = (0..2048).map(|_| item_at(1.0, 64.0, 1.0)).collect();
|
||||
assert!(!ItemAreas::worthwhile(&clustered, 32));
|
||||
|
||||
let mut cells = ItemAreas::default();
|
||||
for (x, z) in [
|
||||
(-800.0, -750.0),
|
||||
(-80.0, 40.0),
|
||||
(0.0, 0.0),
|
||||
(220.0, -130.0),
|
||||
(790.0, 770.0),
|
||||
] {
|
||||
// A villager's inflated item sensor box, with a conservative
|
||||
// fraction for the item bounding box at cell boundaries.
|
||||
let area = Aabb::new(
|
||||
Vec3::new(x - 32.4, 48.0, z - 32.4),
|
||||
Vec3::new(x + 32.4, 80.0, z + 32.4),
|
||||
);
|
||||
let expected: Vec<usize> = items
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(i, e)| area.intersects(e.bounds()).then_some(i))
|
||||
.collect();
|
||||
let mut scan = cells.scan(&items, 1, area);
|
||||
let mut actual = Vec::new();
|
||||
let mut j = 0;
|
||||
loop {
|
||||
j = scan.next_from(j, items.len());
|
||||
let Some(e) = items.get(j) else {
|
||||
break;
|
||||
};
|
||||
if area.intersects(e.bounds()) {
|
||||
actual.push(j);
|
||||
}
|
||||
j += 1;
|
||||
}
|
||||
assert_eq!(actual, expected, "sensor at {x}, {z}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_item_spawned_after_the_cells_were_built_is_seen() {
|
||||
let mut items = vec![item_at(100.5, 64.0, 100.5)];
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
use super::super::native_machines::tests::server_game;
|
||||
use super::*;
|
||||
use horizon_core::position::BlockPos;
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES and scratch homes/cwd"]
|
||||
fn count_equality_uses_live_stack_bounds_and_command_flow() {
|
||||
let mut game = server_game();
|
||||
game.build_registries();
|
||||
game.set_allow_commands(true);
|
||||
game.block_ticks
|
||||
.add_empty_chunk(BlockPos::new(0, 0, 0).chunk())
|
||||
.unwrap();
|
||||
assert!(game.run_command("forceload add 0 0 15 15"));
|
||||
assert!(game.world.loaded(4, 4), "actual loaded world chunk");
|
||||
assert!(game.run_command("setblock 4 199 4 stone"));
|
||||
assert!(game.run_command("setblock 4 200 4 chest{Items:[{Slot:0b,id:\"minecraft:diamond\",count:1},{Slot:1b,id:\"minecraft:diamond\",count:3},{Slot:2b,id:\"minecraft:diamond\",count:4},{Slot:3b,id:\"minecraft:dirt\",count:3},{Slot:5b,id:\"minecraft:stone\",count:2,components:{\"minecraft:custom_data\":{h194:1}}}]}"));
|
||||
assert!(game.run_command("scoreboard objectives add h194 dummy"));
|
||||
let clock = game.world.game_time;
|
||||
let counts = |g: &Game<'_>| {
|
||||
let horizon_client::containers::BlockEntity::Chest(slots) =
|
||||
g.world.block_entities.get(&(4, 200, 4)).unwrap()
|
||||
else {
|
||||
panic!("actual chest model missing")
|
||||
};
|
||||
assert!(slots[4].is_none(), "actual empty slot");
|
||||
[
|
||||
slots[0].as_ref().unwrap().count,
|
||||
slots[1].as_ref().unwrap().count,
|
||||
slots[2].as_ref().unwrap().count,
|
||||
]
|
||||
};
|
||||
assert_eq!(counts(&game), [1, 3, 4]);
|
||||
let mut failed = Vec::new();
|
||||
for (slot, count) in [1, 3, 4].into_iter().enumerate() {
|
||||
assert!(
|
||||
game.run_command(&format!(
|
||||
"execute if items block 4 200 4 container.{slot} diamond"
|
||||
)),
|
||||
"plain admitted command path"
|
||||
);
|
||||
let stack = ItemStack::new("minecraft:diamond", count);
|
||||
assert!(
|
||||
horizon_client::item_data::encode_components(&stack)
|
||||
.is_none_or(|t| t.get("minecraft:count").is_none()),
|
||||
"count is not a component patch"
|
||||
);
|
||||
for (term, expected) in [
|
||||
("count=3", count == 3),
|
||||
("count=4", count == 4),
|
||||
("!count=3", count != 3),
|
||||
("count={min:2}", count >= 2),
|
||||
("count~3", count == 3),
|
||||
("minecraft:count={min:2,max:3}", (2..=3).contains(&count)),
|
||||
] {
|
||||
let predicate = ItemInput {
|
||||
id: "minecraft:diamond".into(),
|
||||
tag: false,
|
||||
any: false,
|
||||
components: Some(format!("[{term}]")),
|
||||
};
|
||||
let actual = game.stack_matches(&predicate, &stack);
|
||||
if actual != expected {
|
||||
failed.push(format!(
|
||||
"direct count{count} {term}: {actual}, expected {expected}"
|
||||
));
|
||||
}
|
||||
assert_eq!(stack.count, count, "read-only evaluator");
|
||||
assert!(game.run_command("scoreboard players set #r h194 91"));
|
||||
assert!(game.run_command("scoreboard players set #s h194 17"));
|
||||
let accepted = game.run_command(&format!("execute store result score #r h194 store success score #s h194 if items block 4 200 4 container.{slot} diamond[{term}]"));
|
||||
let score = |name| game.score.board.score(name, "h194").unwrap().value;
|
||||
let got = (accepted, score("#r"), score("#s"));
|
||||
let want = if expected {
|
||||
(true, count as i32, 1)
|
||||
} else {
|
||||
(false, 0, 0)
|
||||
};
|
||||
if got != want {
|
||||
failed.push(format!(
|
||||
"command slot{slot} {term}: {got:?}, expected {want:?}"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(game.run_command("execute unless items block 4 200 4 container.3 diamond[count=3]"));
|
||||
assert!(game.run_command("execute unless items block 4 200 4 container.4 diamond[count=3]"));
|
||||
assert!(
|
||||
game.run_command("execute if items block 4 200 4 container.5 stone[custom_data={h194:1}]")
|
||||
);
|
||||
assert_eq!(counts(&game), [1, 3, 4], "no inventory mutation");
|
||||
assert_eq!(game.world.game_time, clock, "zero actor ticks");
|
||||
assert!(
|
||||
failed.is_empty(),
|
||||
"actual count predicate mismatches: {failed:?}"
|
||||
);
|
||||
}
|
||||
@@ -309,17 +309,13 @@ impl Game<'_> {
|
||||
self.spawn_destroyed_contents(destroyed);
|
||||
}
|
||||
/// `ItemUtils.onContainerDestroyed`: each stack a destroyed item held
|
||||
/// becomes an item entity where that item lay (`new ItemEntity(level, x,
|
||||
/// y, z, stack)`), nudged by 0.2 less 0.1 per axis and pickable at once.
|
||||
/// becomes an item entity where that item lay, made by
|
||||
/// `new ItemEntity(level, x, y, z, stack)`: its motion is that
|
||||
/// constructor's own, 0.2 up and ±0.1 sideways from the item's random,
|
||||
/// and it is pickable at once.
|
||||
pub(super) fn spawn_destroyed_contents(&mut self, destroyed: Vec<(ItemStack, Vec3)>) {
|
||||
for (stack, at) in destroyed {
|
||||
let r = &mut self.world.random;
|
||||
let motion = [
|
||||
r.next_double() * 0.2 - 0.1,
|
||||
r.next_double() * 0.2 - 0.1,
|
||||
r.next_double() * 0.2 - 0.1,
|
||||
];
|
||||
self.spawn_item(stack, [at.x, at.y, at.z], motion, 0);
|
||||
self.spawn_item_own(stack, [at.x, at.y, at.z], 0);
|
||||
}
|
||||
}
|
||||
/// `ExperienceOrb.award`: orbs of the largest sizes; each draws the id
|
||||
@@ -671,27 +667,55 @@ impl Game<'_> {
|
||||
self.add_item_entity(stack, position, None, pickup_delay)
|
||||
}
|
||||
|
||||
/// `DefaultDispenseItemBehavior.spawnItem`: `new ItemEntity(level, x, y, z,
|
||||
/// stack)` draws its default motion from the item's own random, then
|
||||
/// `setDeltaMovement` throws it with `velocity` before `addFreshEntity`.
|
||||
pub(super) fn spawn_item_thrown(
|
||||
&mut self,
|
||||
stack: ItemStack,
|
||||
position: [f64; 3],
|
||||
velocity: [f64; 3],
|
||||
) {
|
||||
self.add_item_entity_drawn(stack, position, Some(velocity), true, 0);
|
||||
}
|
||||
|
||||
pub(super) fn add_item_entity(
|
||||
&mut self,
|
||||
stack: ItemStack,
|
||||
position: [f64; 3],
|
||||
velocity: Option<[f64; 3]>,
|
||||
pickup_delay: u32,
|
||||
) -> [f64; 3] {
|
||||
self.add_item_entity_drawn(stack, position, velocity, velocity.is_none(), pickup_delay)
|
||||
}
|
||||
|
||||
/// `add_item_entity`; `constructor_motion` draws the five-argument
|
||||
/// constructor's default motion, which a given `velocity` then replaces.
|
||||
fn add_item_entity_drawn(
|
||||
&mut self,
|
||||
stack: ItemStack,
|
||||
position: [f64; 3],
|
||||
velocity: Option<[f64; 3]>,
|
||||
constructor_motion: bool,
|
||||
pickup_delay: u32,
|
||||
) -> [f64; 3] {
|
||||
let (item, count) = (stack.item.clone(), stack.count as i32);
|
||||
self.next_entity += 1;
|
||||
let p = Vec3::new(position[0], position[1], position[2]);
|
||||
// The item's own random, in vanilla's order: Entity's UUID, ItemEntity's
|
||||
// bobOffs and yaw, then (without a given motion) the constructor's motion.
|
||||
// bobOffs and yaw, then (the five-argument constructor) its default motion.
|
||||
let mut own = random_source::entity();
|
||||
let uuid = horizon_client::save::insecure_uuid(&mut own);
|
||||
self.persist.uuids.insert(self.next_entity, uuid);
|
||||
let bob = own.next_float() * std::f32::consts::PI * 2.0;
|
||||
let yaw = own.next_float() * 360.0;
|
||||
let velocity = velocity.unwrap_or_else(|| {
|
||||
let constructed = constructor_motion.then(|| {
|
||||
let x = own.next_double() * 0.2 - 0.1;
|
||||
[x, 0.2, own.next_double() * 0.2 - 0.1]
|
||||
});
|
||||
let velocity = velocity
|
||||
.or(constructed)
|
||||
.expect("an item entity without a velocity takes the constructor's");
|
||||
self.dropped.push(ItemEntity {
|
||||
rotation: [yaw, 0.0],
|
||||
pickup_delay: i64::from(pickup_delay),
|
||||
@@ -850,6 +874,100 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod destroyed_contents_tests {
|
||||
use super::super::native_machines::tests::server_game;
|
||||
use super::*;
|
||||
use horizon_client::items::ItemStack;
|
||||
use horizon_core::random::RandomSource;
|
||||
|
||||
/// The motion of each spilled stack, in the order the spill made them.
|
||||
fn spilled_motions(game: &Game<'_>) -> Vec<[f64; 3]> {
|
||||
game.dropped
|
||||
.iter()
|
||||
.map(|item| [item.velocity.x, item.velocity.y, item.velocity.z])
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `ItemUtils.onContainerDestroyed` makes each stack with
|
||||
/// `new ItemEntity(level, x, y, z, stack)`, whose motion is 0.2 up and
|
||||
/// ±0.1 sideways from the *item's* random (ItemEntity.java:61-66), and
|
||||
/// draws nothing from the level's. Every destroyed container funnels here:
|
||||
/// lava, fire and cactus in the item's environment tick, an explosion, and
|
||||
/// the damage command.
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn spilled_stacks_rise_from_their_own_randoms() {
|
||||
let mut game = server_game();
|
||||
let at = Vec3::new(8.5, 70.0, 8.5);
|
||||
let stacks = ["minecraft:stone", "minecraft:dirt", "minecraft:glass"]
|
||||
.into_iter()
|
||||
.map(|id| (ItemStack::new(id, 1), at))
|
||||
.collect();
|
||||
let mut level = game.world.random.clone();
|
||||
game.spawn_destroyed_contents(stacks);
|
||||
let motions = spilled_motions(&game);
|
||||
assert_eq!(motions.len(), 3);
|
||||
for motion in &motions {
|
||||
assert_eq!(motion[1], 0.2, "the four-argument constructor rises 0.2");
|
||||
assert!((-0.1..0.1).contains(&motion[0]), "{motion:?}");
|
||||
assert!((-0.1..0.1).contains(&motion[2]), "{motion:?}");
|
||||
}
|
||||
// Three stacks, three different sideways motions: each drew its own.
|
||||
assert_ne!(motions[0], motions[1]);
|
||||
assert_ne!(motions[1], motions[2]);
|
||||
assert_ne!(motions[0], motions[2]);
|
||||
// The spill leaves the level's random alone.
|
||||
assert_eq!(game.world.random.next_double(), level.next_double());
|
||||
assert_eq!(game.world.random.next_double(), level.next_double());
|
||||
}
|
||||
|
||||
/// The damage command is one of those callers: killing a shulker box item
|
||||
/// spills its contents with the same upward motion.
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_damaged_shulker_box_spills_its_stacks_upward() {
|
||||
let mut game = server_game();
|
||||
game.build_registries();
|
||||
game.set_allow_commands(true);
|
||||
game.world.chunks.insert(
|
||||
(0, 0),
|
||||
std::sync::Arc::new(horizon_client::chunk::Chunk::empty(0, 0)),
|
||||
);
|
||||
let feet = Vec3::new(8.5, 70.0, 8.5);
|
||||
game.player = Some(horizon_client::motion::PlayerMotion::new(game.motion, feet).unwrap());
|
||||
game.update_chunk_tickets();
|
||||
let boxed = horizon_client::item_data::parse_stack(
|
||||
r#"{id:"minecraft:shulker_box",count:1,components:{"minecraft:container":[
|
||||
{slot:0,item:{id:"minecraft:stone",count:2}},
|
||||
{slot:4,item:{id:"minecraft:dirt",count:1}},
|
||||
{slot:8,item:{id:"minecraft:glass",count:3}}]}}"#,
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
game.spawn_item(boxed, [8.5, 70.0, 8.5], [0.0; 3], 0);
|
||||
assert_eq!(game.dropped.len(), 1);
|
||||
assert!(game.run_command("damage @e[type=minecraft:item,limit=1] 100 minecraft:generic"));
|
||||
let mut spilled: Vec<_> = game
|
||||
.dropped
|
||||
.iter()
|
||||
.map(|item| (item.stack.item.to_string(), item.stack.count))
|
||||
.collect();
|
||||
spilled.sort();
|
||||
assert_eq!(
|
||||
spilled,
|
||||
[
|
||||
("minecraft:dirt".to_owned(), 1),
|
||||
("minecraft:glass".to_owned(), 3),
|
||||
("minecraft:stone".to_owned(), 2)
|
||||
]
|
||||
);
|
||||
for item in &game.dropped {
|
||||
assert_eq!(item.velocity.y, 0.2, "{item:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod escape_tests {
|
||||
use super::super::native_machines::tests::server_game;
|
||||
|
||||
@@ -68,6 +68,11 @@ impl Game<'_> {
|
||||
self.with_level(|b, level, ticks| {
|
||||
b.set_block(level, ticks, p, new, level_flags::UPDATE_CLIENTS)
|
||||
});
|
||||
// `JukeboxBlockEntity.notifyItemChangedInJukebox`: the disc coming
|
||||
// or going is a block change the sculk sensors hear.
|
||||
if self.world.block(pos[0], pos[1], pos[2]) != state {
|
||||
self.post_block_game_event("minecraft:block_change", pos, None, Some(new));
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `JukeboxBlock.setPlacedBy`: a jukebox placed from an item whose
|
||||
@@ -762,3 +767,69 @@ mod jukebox_component_tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod block_change_tests {
|
||||
use super::*;
|
||||
use crate::game::{Audio, Options, Progress, Settings, load_resources};
|
||||
use horizon_client::chunk::Chunk;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// `JukeboxBlockEntity.notifyItemChangedInJukebox`: the disc coming or
|
||||
/// going posts `block_change` at the box, so sculk sensors hear it.
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_disc_change_posts_block_change() {
|
||||
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
|
||||
let settings = Settings::default();
|
||||
let loaded = load_resources(
|
||||
root.clone(),
|
||||
None,
|
||||
&root,
|
||||
&settings,
|
||||
std::time::Instant::now(),
|
||||
false,
|
||||
true,
|
||||
&Progress::new(),
|
||||
&mut |_, _| unreachable!(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
|
||||
options.resources = root.clone();
|
||||
options.execution_role = crate::game::execution_role::ExecutionRole::IntegratedServer;
|
||||
let mut game = Game::new(
|
||||
&loaded.resources,
|
||||
loaded.pack,
|
||||
loaded.gui.unwrap(),
|
||||
options,
|
||||
settings,
|
||||
Audio::silent(&root),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
game.world
|
||||
.chunks
|
||||
.insert((0, 0), Arc::new(Chunk::empty(0, 0)));
|
||||
let jukebox = game
|
||||
.pack
|
||||
.assets
|
||||
.registry
|
||||
.default_state("minecraft:jukebox")
|
||||
.unwrap();
|
||||
game.world.set(2, 4, 3, jukebox).unwrap();
|
||||
super::super::sculk::TEST_POSTED_EVENTS
|
||||
.with(|events| *events.borrow_mut() = Some(Vec::new()));
|
||||
game.set_has_record([2, 4, 3], true);
|
||||
super::super::sculk::TEST_POSTED_EVENTS.with(|events| {
|
||||
assert!(
|
||||
events.borrow().as_ref().unwrap().contains(&(
|
||||
"minecraft:block_change",
|
||||
[2.5, 4.5, 3.5],
|
||||
None
|
||||
)),
|
||||
"{:?}",
|
||||
events.borrow()
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -304,11 +304,10 @@ impl Game<'_> {
|
||||
self.post_attack(index);
|
||||
return;
|
||||
};
|
||||
let weapon = self
|
||||
.res
|
||||
.items()
|
||||
.get(&stack.item)
|
||||
.and_then(|i| i.weapon_damage.map(|d| (d, i.max_damage)));
|
||||
let items = self.res.items();
|
||||
let weapon = items
|
||||
.weapon_damage(&stack)
|
||||
.map(|damage| (damage, items.get(&stack.item).map_or(0, |i| i.max_damage)));
|
||||
self.native_hurt_enemy_event(&stack, index);
|
||||
let smash = self.smash_attack();
|
||||
if smash {
|
||||
@@ -327,11 +326,10 @@ impl Game<'_> {
|
||||
/// `ItemStack.postHurtEnemy`: `hurtAndBreak(weapon.itemDamagePerAttack)` of
|
||||
/// the weapon in the acting hand.
|
||||
pub(super) fn weapon_wear(&mut self, stack: &ItemStack) {
|
||||
let weapon = self
|
||||
.res
|
||||
.items()
|
||||
.get(&stack.item)
|
||||
.and_then(|i| i.weapon_damage.map(|d| (d, i.max_damage)));
|
||||
let items = self.res.items();
|
||||
let weapon = items
|
||||
.weapon_damage(stack)
|
||||
.map(|damage| (damage, items.get(&stack.item).map_or(0, |i| i.max_damage)));
|
||||
if let Some((per_attack, max)) = weapon
|
||||
&& max > 0
|
||||
&& !self.creative
|
||||
@@ -392,12 +390,7 @@ impl Game<'_> {
|
||||
if smash {
|
||||
self.mace_smash_self(victim_on_ground);
|
||||
}
|
||||
if self
|
||||
.res
|
||||
.items()
|
||||
.get(&stack.item)
|
||||
.is_some_and(|i| i.weapon_damage.is_some())
|
||||
{
|
||||
if self.res.items().weapon_damage(&stack).is_some() {
|
||||
self.award_stat("used", &stack.item, 1);
|
||||
}
|
||||
self.post_attack_bursts(&stack, "minecraft:player");
|
||||
@@ -421,12 +414,7 @@ impl Game<'_> {
|
||||
{
|
||||
self.smash_knockback(center, Spared::Stand(id), fall);
|
||||
}
|
||||
if self
|
||||
.res
|
||||
.items()
|
||||
.get(&stack.item)
|
||||
.is_some_and(|i| i.weapon_damage.is_some())
|
||||
{
|
||||
if self.res.items().weapon_damage(&stack).is_some() {
|
||||
self.award_stat("used", &stack.item, 1);
|
||||
}
|
||||
self.post_attack_bursts(&stack, "minecraft:armor_stand");
|
||||
|
||||
@@ -12,10 +12,15 @@ use horizon_client::{
|
||||
Result,
|
||||
containers::{BlockEntity, HOPPER_SLOTS, ModContainer},
|
||||
items::{ItemStack, Items},
|
||||
mobs::Mob,
|
||||
save,
|
||||
world::World,
|
||||
};
|
||||
use horizon_core::{geometry::Direction, position::BlockPos, random::RandomSource};
|
||||
use horizon_core::{
|
||||
geometry::{Aabb, Direction, Vec3},
|
||||
position::BlockPos,
|
||||
random::RandomSource,
|
||||
};
|
||||
use horizon_world::{
|
||||
level::{Behaviours, LevelAccess},
|
||||
nbt::Tag,
|
||||
@@ -224,6 +229,8 @@ struct Containers<'a> {
|
||||
registry: &'static horizon_world::registry::BlockRegistry,
|
||||
/// Brewing recipes, for what a brewing stand accepts (not needed for signals).
|
||||
brewing: Option<&'a horizon_client::brewing::Brewing>,
|
||||
/// The level's mobs, for the cat half of `ChestBlock.isChestBlockedAt`.
|
||||
mobs: &'a [Mob],
|
||||
}
|
||||
impl Containers<'_> {
|
||||
fn prop(&self, state: BlockStateId, key: &str) -> Option<&str> {
|
||||
@@ -251,7 +258,8 @@ impl Containers<'_> {
|
||||
}
|
||||
/// `HopperBlockEntity.getBlockContainer` (double chests combined, right half
|
||||
/// first). `blocked_chests`: `ChestBlock.getContainer(.., false)` returns
|
||||
/// nothing for a chest under a redstone conductor.
|
||||
/// nothing for a blocked chest (`isChestBlockedAt`: a redstone conductor
|
||||
/// above it or a cat sitting on it).
|
||||
fn view(&self, pos: Pos, blocked_chests: bool) -> Option<View> {
|
||||
let state = self.world.block(pos.0, pos.1, pos.2);
|
||||
let name = self.name(state);
|
||||
@@ -308,6 +316,7 @@ impl Containers<'_> {
|
||||
self.behaviours.facts(above).flags
|
||||
& horizon_world::level::data::state_flags::CONDUCTOR
|
||||
!= 0
|
||||
|| cat_sitting_above(self.mobs, p)
|
||||
};
|
||||
if blocked_chests && is_blocked(pos) {
|
||||
return None;
|
||||
@@ -611,20 +620,36 @@ fn bone_meal_stack() -> &'static ItemStack {
|
||||
&STACK
|
||||
}
|
||||
|
||||
/// Comparator input from a container block entity (0 when there is none).
|
||||
pub(super) fn container_signal(
|
||||
world: &World,
|
||||
behaviours: &Behaviours,
|
||||
items: &Items,
|
||||
registry: &'static horizon_world::registry::BlockRegistry,
|
||||
pos: BlockPos,
|
||||
) -> i32 {
|
||||
/// `ChestBlock.isCatSittingOnChest`: a sitting cat whose box meets the block
|
||||
/// above `key` (the box of the block above, not the chest's own).
|
||||
pub(super) fn cat_sitting_above(mobs: &[Mob], key: Pos) -> bool {
|
||||
let area = Aabb::new(
|
||||
Vec3::new(f64::from(key.0), f64::from(key.1 + 1), f64::from(key.2)),
|
||||
Vec3::new(
|
||||
f64::from(key.0 + 1),
|
||||
f64::from(key.1 + 2),
|
||||
f64::from(key.2 + 1),
|
||||
),
|
||||
);
|
||||
mobs.iter().any(|m| {
|
||||
m.species() == horizon_client::mobs::Species::Cat
|
||||
&& m.alive()
|
||||
&& horizon_client::mobs::tame::cat_sitting(m)
|
||||
&& m.bounds().intersects(area)
|
||||
})
|
||||
}
|
||||
|
||||
/// Comparator input from a container block entity (0 when there is none):
|
||||
/// `ChestBlock.getContainer(.., false)`, whose blocked check sees the level's
|
||||
/// cats (`isCatSittingOnChest`).
|
||||
pub(super) fn container_signal(level: &super::GameLevel<'_>, pos: BlockPos) -> i32 {
|
||||
let c = Containers {
|
||||
world,
|
||||
behaviours,
|
||||
items,
|
||||
registry,
|
||||
world: &*level.world,
|
||||
behaviours: level.behaviours,
|
||||
items: level.items,
|
||||
registry: level.registry,
|
||||
brewing: None,
|
||||
mobs: level.entities.mobs,
|
||||
};
|
||||
c.view((pos.x, pos.y, pos.z), true)
|
||||
.filter(|v| !matches!(v.kind, Kind::Composter(_)))
|
||||
@@ -680,6 +705,7 @@ impl Game<'_> {
|
||||
items: self.items(),
|
||||
registry: self.pack.assets.registry,
|
||||
brewing: Some(&self.stations().brewing),
|
||||
mobs: &self.mobs,
|
||||
}
|
||||
}
|
||||
pub(super) fn block_can_place(&self, pos: [i32; 3], slot: usize, stack: &ItemStack) -> bool {
|
||||
@@ -2442,3 +2468,7 @@ mod tests {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "machines_chest_cat_tests.rs"]
|
||||
mod machines_chest_cat_tests;
|
||||
@@ -0,0 +1,134 @@
|
||||
//! `ChestBlock.isChestBlockedAt` (ChestBlock.java:318-319) is a redstone
|
||||
//! conductor above the chest *or* a cat sitting in the block above it
|
||||
//! (`isCatSittingOnChest`, :327-338). `getAnalogOutputSignal` (:346-348)
|
||||
//! reads `getContainer(.., false)`, whose `combine(.., false)` (:279) uses
|
||||
//! that predicate, so a sitting cat leaves the comparator at 0. A hopper's
|
||||
//! `getContainer(.., true)` ignores the block, and the menu path keeps
|
||||
//! closing as before.
|
||||
use super::super::native_machines::tests::server_game;
|
||||
use super::*;
|
||||
use horizon_client::chunk::Chunk;
|
||||
use horizon_core::geometry::Vec3;
|
||||
use std::sync::Arc;
|
||||
|
||||
const CHEST: Pos = (0, 64, 0);
|
||||
const COMPARATOR: Pos = (1, 64, 0);
|
||||
const WIRE: Pos = (2, 64, 0);
|
||||
|
||||
/// A full chest at `CHEST`, a comparator east of it reading west into it,
|
||||
/// and the wire that carries the comparator's output, on a floor in a
|
||||
/// ticking chunk.
|
||||
fn game() -> Game<'static> {
|
||||
let mut game = server_game();
|
||||
game.build_registries();
|
||||
game.set_allow_commands(true);
|
||||
for x in -1..=1 {
|
||||
for z in -1..=1 {
|
||||
let mut chunk = Chunk::empty(x, z);
|
||||
chunk.lit = true;
|
||||
game.world.chunks.insert((x, z), Arc::new(chunk));
|
||||
game.block_ticks
|
||||
.add_empty_chunk(BlockPos::new(x * 16, 0, z * 16).chunk())
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
game.player = Some(
|
||||
horizon_client::motion::PlayerMotion::new(game.motion, Vec3::new(8.5, 64.0, 8.5)).unwrap(),
|
||||
);
|
||||
for command in [
|
||||
"forceload add 0 0",
|
||||
"fill 0 63 0 15 63 15 stone",
|
||||
"setblock 0 64 0 minecraft:chest{Items:[{Slot:0b,id:\"minecraft:stone\",count:64}]}",
|
||||
"setblock 1 64 0 minecraft:comparator[facing=west]",
|
||||
"setblock 2 64 0 minecraft:redstone_wire",
|
||||
] {
|
||||
assert!(game.run_command(command), "{command}");
|
||||
}
|
||||
game.update_chunk_tickets();
|
||||
assert!(game.tickets.levels().block_ticking(0, 0));
|
||||
game
|
||||
}
|
||||
|
||||
/// A block change beside the comparator, so it reads its input again, then
|
||||
/// the ticks that settle the comparator and the wire.
|
||||
fn poke(game: &mut Game<'_>, block: &str) {
|
||||
assert!(game.run_command(&format!(
|
||||
"setblock {} {} {} {block}",
|
||||
COMPARATOR.0,
|
||||
COMPARATOR.1,
|
||||
COMPARATOR.2 + 1
|
||||
)));
|
||||
for _ in 0..4 {
|
||||
game.world.game_time += 1;
|
||||
game.update_chunk_tickets();
|
||||
game.tick_blocks().unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn summon_cat(game: &mut Game<'_>, sitting: bool) {
|
||||
assert!(game.run_command(&format!(
|
||||
"summon cat 0.5 65 0.5 {{Sitting:{}b,Tame:1b}}",
|
||||
i8::from(sitting)
|
||||
)));
|
||||
}
|
||||
|
||||
fn wire_power(game: &Game<'_>) -> i32 {
|
||||
let state = game.world.block(WIRE.0, WIRE.1, WIRE.2);
|
||||
let info = game
|
||||
.pack
|
||||
.assets
|
||||
.registry
|
||||
.state(state)
|
||||
.expect("the wire is a known state");
|
||||
assert_eq!(info.name, "minecraft:redstone_wire");
|
||||
info.properties["power"]
|
||||
.parse()
|
||||
.expect("a wire has a power property")
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_sitting_cat_silences_the_comparator_reading_a_chest() {
|
||||
let mut game = game();
|
||||
poke(&mut game, "glass");
|
||||
assert_eq!(wire_power(&game), 1, "a full chest reads 1");
|
||||
|
||||
summon_cat(&mut game, true);
|
||||
poke(&mut game, "stone");
|
||||
assert_eq!(wire_power(&game), 0, "a sitting cat blocks the chest");
|
||||
|
||||
// Standing the cat up gives the signal back.
|
||||
assert!(game.run_command("data merge entity @e[type=minecraft:cat,limit=1] {Sitting:0b}"));
|
||||
poke(&mut game, "dirt");
|
||||
assert_eq!(wire_power(&game), 1, "a standing cat blocks nothing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_sitting_cat_leaves_the_hopper_and_the_menu_alone() {
|
||||
let mut game = game();
|
||||
summon_cat(&mut game, true);
|
||||
assert!(
|
||||
game.containers().view(CHEST, false).is_some(),
|
||||
"a hopper's getContainer(.., true) ignores the cat"
|
||||
);
|
||||
assert!(
|
||||
game.containers().view(CHEST, true).is_none(),
|
||||
"the comparator's getContainer(.., false) combines to nothing"
|
||||
);
|
||||
assert!(
|
||||
game.chest_menu_blocked(CHEST, "minecraft:chest"),
|
||||
"a chest with a cat on it opens no menu"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_conductor_above_a_chest_still_silences_the_comparator() {
|
||||
let mut game = game();
|
||||
poke(&mut game, "glass");
|
||||
assert_eq!(wire_power(&game), 1);
|
||||
assert!(game.run_command("setblock 0 65 0 stone"));
|
||||
poke(&mut game, "stone");
|
||||
assert_eq!(wire_power(&game), 0, "a conductor above blocks the chest");
|
||||
}
|
||||
@@ -707,6 +707,17 @@ impl MapData {
|
||||
Some(map)
|
||||
}
|
||||
}
|
||||
/// What a read of a map gives: from the cache when the map is loaded, else from its saved file.
|
||||
fn map_read<T>(
|
||||
cached: Option<&MapData>,
|
||||
saved: impl FnOnce() -> Option<MapData>,
|
||||
read: impl Fn(&MapData) -> T,
|
||||
) -> Option<T> {
|
||||
match cached {
|
||||
Some(map) => Some(read(map)),
|
||||
None => saved().map(|map| read(&map)),
|
||||
}
|
||||
}
|
||||
pub(super) struct Maps {
|
||||
pub data: HashMap<i32, MapData>,
|
||||
/// The highest map id handed out (`maps/last_id.dat`), loaded on first use.
|
||||
@@ -926,6 +937,30 @@ impl Game<'_> {
|
||||
fn map_path(&self, name: &str) -> std::path::PathBuf {
|
||||
self.world_root.join("data/minecraft/maps").join(name)
|
||||
}
|
||||
/// `native_map_colors`: the 128 x 128 colour ids of the world's map `map_id`, row by row (vanilla's
|
||||
/// `MapItemSavedData.colors`), from the loaded map or its saved file, which is not loaded here. None for a map the
|
||||
/// world never made or saved, and on a client of a server (as `native_paint_map`).
|
||||
pub(super) fn native_map_colors(&self, map_id: i32) -> Option<Vec<u8>> {
|
||||
self.native_map_read(map_id, |map| map.colors.clone())
|
||||
}
|
||||
/// `native_map_meta`: the scale, dimension and locked flag of the world's map `map_id`, read as
|
||||
/// `native_map_colors` reads it.
|
||||
pub(super) fn native_map_meta(&self, map_id: i32) -> Option<(u8, String, bool)> {
|
||||
self.native_map_read(map_id, |map| (map.scale, map.dimension.clone(), map.locked))
|
||||
}
|
||||
fn native_map_read<T>(&self, map_id: i32, read: impl Fn(&MapData) -> T) -> Option<T> {
|
||||
if self.remote.is_some() || self.world_root.as_os_str().is_empty() {
|
||||
return None;
|
||||
}
|
||||
let saved = || {
|
||||
if self.maps.missing.contains(&map_id) {
|
||||
return None;
|
||||
}
|
||||
horizon_client::save::read_dat(&self.map_path(&format!("{map_id}.dat")))
|
||||
.and_then(|t| MapData::from_nbt(&t))
|
||||
};
|
||||
map_read(self.maps.data.get(&map_id), saved, read)
|
||||
}
|
||||
/// The map's data, loaded from `data/minecraft/maps/<id>.dat` on first use.
|
||||
pub(super) fn map(&mut self, id: i32) -> Option<&mut MapData> {
|
||||
if !self.maps.data.contains_key(&id) && !self.maps.missing.contains(&id) {
|
||||
@@ -1973,6 +2008,8 @@ impl Game<'_> {
|
||||
#[cfg(test)]
|
||||
mod live_frame_tests;
|
||||
#[cfg(test)]
|
||||
mod native_read_tests;
|
||||
#[cfg(test)]
|
||||
mod save_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
use super::{MapData, map_read};
|
||||
|
||||
#[test]
|
||||
fn a_painted_picture_reads_back_as_painted() {
|
||||
let mut map = MapData::fresh(0.0, 0.0, 0, "minecraft:overworld");
|
||||
let colors: Vec<u8> = (0..16384).map(|i| (i * 7 % 256) as u8).collect();
|
||||
map.paint(&colors);
|
||||
assert_eq!(
|
||||
map_read(Some(&map), || None, |m| m.colors.clone()),
|
||||
Some(colors)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_loaded_map_wins_over_its_saved_file() {
|
||||
let loaded = MapData::fresh(0.0, 0.0, 0, "minecraft:overworld");
|
||||
let saved = MapData::fresh(0.0, 0.0, 2, "minecraft:the_end");
|
||||
let scale = |cached: Option<&MapData>| {
|
||||
map_read(
|
||||
cached,
|
||||
|| Some(MapData::fresh(0.0, 0.0, 2, "minecraft:the_end")),
|
||||
|m| m.scale,
|
||||
)
|
||||
};
|
||||
assert_eq!(scale(Some(&loaded)), Some(0));
|
||||
assert_eq!(scale(None), Some(2));
|
||||
assert_eq!(saved.dimension, "minecraft:the_end");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_map_neither_loaded_nor_saved_reads_as_none() {
|
||||
assert_eq!(map_read(None, || None, |m| m.locked), None);
|
||||
}
|
||||
@@ -44,12 +44,18 @@ fn game() -> Game<'static> {
|
||||
.unwrap();
|
||||
game.build_registries();
|
||||
game.set_allow_commands(true);
|
||||
game.world
|
||||
.chunks
|
||||
.insert((0, 0), Arc::new(Chunk::empty(0, 0)));
|
||||
game.block_ticks
|
||||
.add_empty_chunk(ChunkPos::new(0, 0))
|
||||
.unwrap();
|
||||
// Entities tick once every chunk within 2 columns is loaded
|
||||
// (ChunkMap.prepareEntityTickingChunk, #13953).
|
||||
for x in -2..=2 {
|
||||
for z in -2..=2 {
|
||||
game.world
|
||||
.chunks
|
||||
.insert((x, z), Arc::new(Chunk::empty(x, z)));
|
||||
game.block_ticks
|
||||
.add_empty_chunk(ChunkPos::new(x, z))
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
assert!(game.run_command("fill 0 99 0 15 99 15 stone"));
|
||||
game
|
||||
}
|
||||
|
||||
@@ -1258,6 +1258,7 @@ mod tests {
|
||||
ingredient: |_, _, _| {},
|
||||
ingredient_test: |_, _, _, _| {},
|
||||
log_line: |_, _, _, _| {},
|
||||
living_death_message: |_, _, _| {},
|
||||
handshake: |_, _, _, _, _| {},
|
||||
});
|
||||
assert!(installed.is_ok());
|
||||
|
||||
@@ -84,7 +84,7 @@ impl Game<'_> {
|
||||
}
|
||||
let camera = self.eye_position().unwrap_or(at);
|
||||
self.particles
|
||||
.fireworks(at, velocity, explosions.to_vec(), camera);
|
||||
.fireworks(at, velocity, explosions.to_vec(), camera, true);
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
//! earlier client level), they return `None`.
|
||||
use super::{Dim, Game, commands::Target};
|
||||
use horizon_client::mod_api::{EntityRef, LevelRef};
|
||||
use horizon_core::geometry::Vec3;
|
||||
|
||||
impl Game<'_> {
|
||||
/// What a handle names now, or None for a stale or foreign one.
|
||||
@@ -156,6 +157,53 @@ impl Game<'_> {
|
||||
};
|
||||
Some(LevelRef::new(entity.epoch(), dim.id()))
|
||||
}
|
||||
/// `LivingEntity.hasLineOfSight(target)` for a server mod, with the entities as the events name them by id:
|
||||
/// whether `from_entity` sees `to_entity`. The ray runs from the eyes of `from_entity` to those of `to_entity`
|
||||
/// (`Entity.getEyeY`), against `ClipContext.Block.COLLIDER` shapes with `from_entity` as the collision context
|
||||
/// and no fluids, and it counts only when the two stand in the same level and no more than 128 blocks apart.
|
||||
/// False also for an unknown entity, a client, and a level other than the one being ticked (the level it can
|
||||
/// answer for). API @API:line_of_sight@.
|
||||
pub(super) fn native_has_line_of_sight(&self, from_entity: u32, to_entity: u32) -> bool {
|
||||
if !self.native_level_open(self.dim.id()) {
|
||||
return false;
|
||||
}
|
||||
let (Some(from), Some(to)) = (
|
||||
self.native_entity(from_entity)
|
||||
.and_then(|e| self.native_entity_target(e)),
|
||||
self.native_entity(to_entity)
|
||||
.and_then(|e| self.native_entity_target(e)),
|
||||
) else {
|
||||
return false;
|
||||
};
|
||||
let here = self.dim.id();
|
||||
if self.target_level(from).id() != here || self.target_level(to).id() != here {
|
||||
return false;
|
||||
}
|
||||
let (Some(a), Some(b)) = (self.target_position(from), self.target_position(to)) else {
|
||||
return false;
|
||||
};
|
||||
let from_eye = Vec3::new(a[0], a[1] + self.target_eye_height(from), a[2]);
|
||||
let to_eye = Vec3::new(b[0], b[1] + self.target_eye_height(to), b[2]);
|
||||
self.mob_world_base(&[])
|
||||
.line_of_sight_for(Some(a[1]), from_eye, to_eye)
|
||||
}
|
||||
/// `Level.clip` with `ClipContext.Block.COLLIDER`, no fluids and no entity (`CollisionContext.empty()`), as a
|
||||
/// mod's own ray: true when the segment from `from` to `to` meets no block's collider (a miss). Only the level
|
||||
/// being ticked is answered; another dimension, and a client, give false, since reaching another loaded level
|
||||
/// needs a mutable borrow this read takes without one. API @API:line_of_sight@.
|
||||
pub(super) fn native_clear_between(
|
||||
&self,
|
||||
dimension: &str,
|
||||
from: [f64; 3],
|
||||
to: [f64; 3],
|
||||
) -> bool {
|
||||
self.native_level_open(dimension)
|
||||
&& self.mob_world_base(&[]).clear_between(
|
||||
Vec3::new(from[0], from[1], from[2]),
|
||||
Vec3::new(to[0], to[1], to[2]),
|
||||
false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// A saved UUID's four ints (`UUIDUtil.uuidFromIntArray`) as one number.
|
||||
@@ -493,3 +541,135 @@ mod client_tests {
|
||||
assert!(made(&game) > before, "event 42 draws the sweat");
|
||||
}
|
||||
}
|
||||
|
||||
/// `native_has_line_of_sight` and `native_clear_between` against real blocks and entities of a server's level.
|
||||
#[cfg(test)]
|
||||
mod line_of_sight_tests {
|
||||
use crate::game::{Audio, Game, Options, Progress, Settings, execution_role, load_resources};
|
||||
use horizon_client::mod_api::NativeAuthority;
|
||||
use horizon_world::nbt::Compound;
|
||||
|
||||
fn game() -> Game<'static> {
|
||||
let root = std::path::PathBuf::from(
|
||||
std::env::var_os("HORIZON_TEST_RESOURCES")
|
||||
.expect("set HORIZON_TEST_RESOURCES to prepared resources"),
|
||||
);
|
||||
let settings = Settings::default();
|
||||
let loaded = load_resources(
|
||||
root.clone(),
|
||||
None,
|
||||
&root,
|
||||
&settings,
|
||||
std::time::Instant::now(),
|
||||
false,
|
||||
true,
|
||||
&Progress::new(),
|
||||
&mut |_, _| unreachable!(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
|
||||
options.resources = root.clone();
|
||||
options.execution_role = execution_role::ExecutionRole::IntegratedServer;
|
||||
options.dedicated = Some(Vec::new());
|
||||
let res: &'static _ = Box::leak(Box::new(loaded.resources));
|
||||
let mut game = Game::new(
|
||||
res,
|
||||
loaded.pack,
|
||||
loaded.gui.unwrap(),
|
||||
options,
|
||||
settings,
|
||||
Audio::silent(&root),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
game.build_registries();
|
||||
game.set_allow_commands(true);
|
||||
game.native_lifecycle
|
||||
.begin_open(NativeAuthority::IntegratedServer);
|
||||
game.world.chunks.insert(
|
||||
(0, 0),
|
||||
std::sync::Arc::new(horizon_client::chunk::Chunk::empty(0, 0)),
|
||||
);
|
||||
game.block_ticks
|
||||
.add_empty_chunk(horizon_core::position::ChunkPos::new(0, 0))
|
||||
.unwrap();
|
||||
game
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_collider_blocks_sight_and_a_bottom_slab_or_water_does_not() {
|
||||
let mut game = game();
|
||||
let dim = game.dim.id();
|
||||
let a = game
|
||||
.native_summon(dim, "minecraft:pig", [2.5, 200.0, 2.5], Compound::new())
|
||||
.unwrap();
|
||||
let b = game
|
||||
.native_summon(dim, "minecraft:pig", [10.5, 200.0, 2.5], Compound::new())
|
||||
.unwrap();
|
||||
let at = |x: f64| [x, 200.8, 2.5];
|
||||
assert!(game.native_has_line_of_sight(a, b), "open air");
|
||||
assert!(game.native_has_line_of_sight(b, a), "sight is symmetric");
|
||||
assert!(game.native_clear_between(dim, at(2.5), at(10.5)));
|
||||
|
||||
// A bottom slab's collider tops out at 200.5, under the eyes: the ray passes over it.
|
||||
assert!(game.run_command("setblock 6 200 2 minecraft:oak_slab[type=bottom]"));
|
||||
assert!(
|
||||
game.native_has_line_of_sight(a, b),
|
||||
"bottom slab under the eyes"
|
||||
);
|
||||
|
||||
// Water has no collider and fluids do not count (`ClipContext.Fluid.NONE`).
|
||||
assert!(game.run_command("fill 6 199 0 6 205 5 minecraft:water"));
|
||||
assert!(game.native_has_line_of_sight(a, b), "water is no collider");
|
||||
|
||||
// Stone's collider covers the eye line: no sight, and the ray is not clear.
|
||||
assert!(game.run_command("fill 6 199 0 6 205 5 minecraft:stone"));
|
||||
assert!(!game.native_has_line_of_sight(a, b), "stone wall");
|
||||
assert!(
|
||||
!game.native_has_line_of_sight(b, a),
|
||||
"stone wall, both ways"
|
||||
);
|
||||
assert!(!game.native_clear_between(dim, at(2.5), at(10.5)));
|
||||
// A segment that stops short of the wall is clear.
|
||||
assert!(game.native_clear_between(dim, at(2.5), at(5.5)));
|
||||
|
||||
// Once the wall is gone, the same pair sees each other again.
|
||||
assert!(game.run_command("fill 6 199 0 6 205 5 minecraft:air"));
|
||||
assert!(game.native_has_line_of_sight(a, b), "wall removed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn unknown_entities_and_other_levels_see_nothing() {
|
||||
let mut game = game();
|
||||
let dim = game.dim.id();
|
||||
let a = game
|
||||
.native_summon(dim, "minecraft:pig", [2.5, 200.0, 2.5], Compound::new())
|
||||
.unwrap();
|
||||
let b = game
|
||||
.native_summon(dim, "minecraft:pig", [10.5, 200.0, 2.5], Compound::new())
|
||||
.unwrap();
|
||||
assert!(
|
||||
!game.native_has_line_of_sight(a, u32::MAX),
|
||||
"no such entity"
|
||||
);
|
||||
assert!(
|
||||
!game.native_has_line_of_sight(a, b + 1000),
|
||||
"no such entity"
|
||||
);
|
||||
|
||||
// A pig in the Nether is in another level: no sight to it, and no ray in that level here.
|
||||
let nether = "minecraft:the_nether";
|
||||
let uuid = (7u128 << 96) | 0x51;
|
||||
assert!(game.run_command(&format!(
|
||||
"execute in {nether} run summon pig 8.5 200 8.5 {{UUID:[I;7,0,0,81],NoAI:1b}}"
|
||||
)));
|
||||
let (_, far, _) = game
|
||||
.native_entity_by_uuid(uuid)
|
||||
.expect("summoned in the Nether");
|
||||
assert!(!game.native_has_line_of_sight(a, far));
|
||||
assert!(!game.native_clear_between(nether, [2.5, 200.8, 2.5], [8.5, 200.8, 8.5]));
|
||||
assert_eq!(game.dim.id(), dim, "the level in place is unchanged");
|
||||
}
|
||||
}
|
||||
@@ -685,6 +685,8 @@ fn catalog_matches_hook_points() {
|
||||
let conn_hooks = free_hooks(include_str!("../lan/conn.rs"));
|
||||
// The log writer's (`horizon_client_lib::native_client_hooks`).
|
||||
let client_hooks = free_hooks(include_str!("../native_client_hooks.rs"));
|
||||
// The tamed mobs' death messages (`horizon_client_lib::living_death`).
|
||||
let death_hooks = free_hooks(include_str!("../living_death.rs"));
|
||||
let declared: BTreeSet<_> = section
|
||||
.lines()
|
||||
.chain(level_section.lines())
|
||||
@@ -709,6 +711,7 @@ fn catalog_matches_hook_points() {
|
||||
.chain(mob_hooks.iter().copied())
|
||||
.chain(conn_hooks.iter().copied())
|
||||
.chain(client_hooks.iter().copied())
|
||||
.chain(death_hooks.iter().copied())
|
||||
.collect();
|
||||
let mut names = BTreeSet::new();
|
||||
let mut contracts = BTreeSet::new();
|
||||
@@ -755,6 +758,8 @@ fn catalog_matches_hook_points() {
|
||||
format!("horizon_client_lib::lan::conn::native_{name}")
|
||||
} else if client_hooks.contains(name) {
|
||||
format!("horizon_client_lib::native_client_hooks::native_{name}")
|
||||
} else if death_hooks.contains(name) {
|
||||
format!("horizon_client_lib::living_death::native_{name}")
|
||||
} else if level_hooks.contains(name) {
|
||||
format!("horizon_client::game::native_hooks::GameLevel<'_>::native_{name}")
|
||||
} else {
|
||||
|
||||
@@ -107,3 +107,61 @@ fn markers_are_listed_with_their_data() {
|
||||
assert_eq!(game.dim.id(), overworld);
|
||||
assert!(game.native_level_markers("minecraft:nowhere").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_shared_lookup_finds_the_level_in_place_and_leaves_the_others() {
|
||||
let mut game = game();
|
||||
let nether = "minecraft:the_nether";
|
||||
let overworld = game.dim.id();
|
||||
let here = "[I;7,0,0,3]";
|
||||
let here_number = (7u128 << 96) | 3;
|
||||
assert!(game.run_command(&format!(
|
||||
"execute in {nether} run summon cow 8.5 70 8.5 {{UUID:{UUID},NoAI:1b}}"
|
||||
)));
|
||||
assert!(game.run_command(&format!("summon cow 3.5 80 3.5 {{UUID:{here},NoAI:1b}}")));
|
||||
let shared = &game;
|
||||
// The level in place is read from `&self`; the nether level is not, and says so with None.
|
||||
let (dimension, id, kind) = shared
|
||||
.native_find_entity_by_uuid(here_number)
|
||||
.expect("found");
|
||||
assert_eq!(
|
||||
(dimension.as_str(), kind.as_str()),
|
||||
(overworld, "minecraft:cow")
|
||||
);
|
||||
assert_eq!(shared.native_find_entity_by_uuid(UUID_NUMBER), None);
|
||||
// The `&mut` lookup still reaches the nether level, and the same entity answers the same way.
|
||||
assert_eq!(
|
||||
game.native_entity_by_uuid(here_number),
|
||||
Some((overworld.to_owned(), id, kind))
|
||||
);
|
||||
assert_eq!(
|
||||
game.native_entity_by_uuid(UUID_NUMBER).map(|f| f.0),
|
||||
Some(nether.to_owned())
|
||||
);
|
||||
assert_eq!(game.dim.id(), overworld);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn an_entity_in_another_level_has_its_data_read_in_place() {
|
||||
let mut game = game();
|
||||
let nether = "minecraft:the_nether";
|
||||
let overworld = game.dim.id();
|
||||
assert!(game.run_command(&format!(
|
||||
"execute in {nether} run summon cow 8.5 70 8.5 {{UUID:{UUID},NoAI:1b}}"
|
||||
)));
|
||||
let (dimension, id, _) = game.native_entity_by_uuid(UUID_NUMBER).expect("found");
|
||||
let nbt = game
|
||||
.native_entity_nbt_in(&dimension, id)
|
||||
.expect("read in its own level");
|
||||
assert_eq!(nbt.get(&"NoAI".into()), Some(&Tag::Byte(1)));
|
||||
assert!(
|
||||
!nbt.contains_key(&"id".into()),
|
||||
"as native_entity_nbt: no id"
|
||||
);
|
||||
// The level in place is the overworld again, and the id names nothing there.
|
||||
assert_eq!(game.dim.id(), overworld);
|
||||
assert_eq!(game.native_entity_nbt_in(overworld, id), None);
|
||||
assert_eq!(game.native_entity_nbt_in("minecraft:nowhere", id), None);
|
||||
}
|
||||
@@ -21,6 +21,26 @@ impl Game<'_> {
|
||||
/// packet is handled (the hub is taken out then): call it from a tick or
|
||||
/// console hook. API @API:npc_entity_by_uuid@.
|
||||
pub(super) fn native_entity_by_uuid(&mut self, uuid: u128) -> Option<(String, u32, String)> {
|
||||
if let Some(found) = self.native_find_entity_by_uuid(uuid) {
|
||||
return Some(found);
|
||||
}
|
||||
// The levels not in place: each one is put in place to look (`npc_in_level`).
|
||||
let words = horizon_client::lan::playerdata::uuid_ints(uuid);
|
||||
let dims: Vec<&'static str> = self.levels.parked_dims().iter().map(|d| d.id()).collect();
|
||||
for dimension in dims {
|
||||
if let Some(Some((id, kind))) =
|
||||
self.npc_in_level(dimension, |g| g.uuid_holder_here(words))
|
||||
{
|
||||
return Some((dimension.to_owned(), id, kind));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
/// `native_entity_by_uuid` from `&self`, for many read-only lookups: the server's players and the
|
||||
/// entities of the level in place. A level not in place keeps its entities in its own state, and
|
||||
/// reading one means putting it in place (a `&mut` swap), so those levels are left to
|
||||
/// `native_entity_by_uuid`. Same answer as it for these; None on a client. API @API:npc_entity_lookup@.
|
||||
pub(super) fn native_find_entity_by_uuid(&self, uuid: u128) -> Option<(String, u32, String)> {
|
||||
if self.remote.is_some() || self.options.execution_role.is_client() {
|
||||
return None;
|
||||
}
|
||||
@@ -32,37 +52,29 @@ impl Game<'_> {
|
||||
return Some((dimension, id, "minecraft:player".into()));
|
||||
}
|
||||
let words = horizon_client::lan::playerdata::uuid_ints(uuid);
|
||||
let dims: Vec<&'static str> = std::iter::once(self.dim)
|
||||
.chain(self.levels.parked_dims())
|
||||
.map(|d| d.id())
|
||||
.collect();
|
||||
for dimension in dims {
|
||||
let found = self.npc_in_level(dimension, |g| {
|
||||
// The UUIDs the level remembers, checked against its entities: a
|
||||
// remembered id may belong to an entity that is gone.
|
||||
let id = g
|
||||
.persist
|
||||
.uuids
|
||||
.iter()
|
||||
.find(|(_, w)| **w == words)
|
||||
.map(|(id, _)| *id)?;
|
||||
let t = g.level_entity(i32::try_from(id).ok()?)?;
|
||||
if let Target::Mob(m) = t
|
||||
&& !g.mobs.iter().any(|x| x.id == m && !x.removed)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
// The mod API has no natural fence knots (#13036).
|
||||
if let Target::Knot(_) = t {
|
||||
return None;
|
||||
}
|
||||
Some((id, g.target_type(t)))
|
||||
});
|
||||
if let Some(Some((id, kind))) = found {
|
||||
return Some((dimension.to_owned(), id, kind));
|
||||
}
|
||||
let (id, kind) = self.uuid_holder_here(words)?;
|
||||
Some((self.dim.id().to_owned(), id, kind))
|
||||
}
|
||||
/// The entity of the level in place that `words` names, with its type: the UUIDs the level
|
||||
/// remembers, checked against its entities (a remembered id may belong to an entity that is gone).
|
||||
fn uuid_holder_here(&self, words: [i32; 4]) -> Option<(u32, String)> {
|
||||
let id = self
|
||||
.persist
|
||||
.uuids
|
||||
.iter()
|
||||
.find(|(_, w)| **w == words)
|
||||
.map(|(id, _)| *id)?;
|
||||
let t = self.level_entity(i32::try_from(id).ok()?)?;
|
||||
if let Target::Mob(m) = t
|
||||
&& !self.mobs.iter().any(|x| x.id == m && !x.removed)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
None
|
||||
// The mod API has no natural fence knots (#13036).
|
||||
if let Target::Knot(_) = t {
|
||||
return None;
|
||||
}
|
||||
Some((id, self.target_type(t)))
|
||||
}
|
||||
/// The `minecraft:marker` entities of the loaded level `dimension`: each one's id, its position
|
||||
/// and its `data` compound (empty for a marker saved without data), as `/data get entity <marker>
|
||||
@@ -117,6 +129,17 @@ impl Game<'_> {
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
/// The data of entity `id` in the loaded level `dimension`, as `native_entity_nbt` gives it for the
|
||||
/// level in place (`/execute in <dimension> run data get entity`, without the `id`). Read after
|
||||
/// `native_find_entity_by_uuid` or `native_entity_by_uuid` found the entity. None on a client, for a
|
||||
/// level not loaded, and for no such entity. API @API:npc_entity_lookup@.
|
||||
pub(super) fn native_entity_nbt_in(&mut self, dimension: &str, id: u32) -> Option<Compound> {
|
||||
self.npc_in_level(dimension, |g| {
|
||||
let entity = g.native_entity(id)?;
|
||||
g.native_entity_nbt(entity)
|
||||
})
|
||||
.flatten()
|
||||
}
|
||||
/// Turns entity `id` of the loaded level `dimension` to `yaw` and `pitch`
|
||||
/// in degrees, as `/rotate` does (`Entity.forceSetRotation`: `setYRot`,
|
||||
/// `setXRot` clamped to -90..90, its body and head with it), without
|
||||
|
||||
@@ -34,6 +34,10 @@ pub(super) struct RecipeTables {
|
||||
/// recipe_content`) over the window's own: what `Game::recipes` reads on
|
||||
/// a client once they came.
|
||||
synced: Option<horizon_client::crafting::Recipes>,
|
||||
/// The display numbers of those recipes: the server numbers its book's
|
||||
/// displays over its own recipe set, a world's data pack recipes
|
||||
/// included, so the book numbers them the same (`Game::display_index`).
|
||||
pub(super) synced_index: Option<horizon_client::recipe_book::RecipeIndex>,
|
||||
/// The recipe content's parts so far, until its last.
|
||||
incoming: Vec<(String, serde_json::Value)>,
|
||||
/// The types that came before the player did: the mods hear of them
|
||||
@@ -384,9 +388,18 @@ impl Game<'_> {
|
||||
pub(super) fn synced_recipes(&self) -> Option<&horizon_client::crafting::Recipes> {
|
||||
self.recipe_tables.synced.as_ref()
|
||||
}
|
||||
/// The display numbers the recipe book reads: the server's recipes',
|
||||
/// once a server sent them, else the world's own.
|
||||
pub(super) fn display_index(&self) -> &horizon_client::recipe_book::RecipeIndex {
|
||||
match &self.recipe_tables.synced_index {
|
||||
Some(index) => index,
|
||||
None => &self.stations().recipe_index,
|
||||
}
|
||||
}
|
||||
/// A new play session (`Login`): what the last server sent is gone.
|
||||
pub(super) fn forget_synced_recipes(&mut self) {
|
||||
self.recipe_tables.synced = None;
|
||||
self.recipe_tables.synced_index = None;
|
||||
self.recipe_tables.incoming.clear();
|
||||
self.recipe_tables.received = None;
|
||||
self.recipe_tables.tables = None;
|
||||
@@ -453,6 +466,7 @@ impl Game<'_> {
|
||||
let received = std::mem::take(&mut self.recipe_tables.incoming);
|
||||
// No type any more: back on the window's own recipes.
|
||||
if types.is_empty() {
|
||||
self.recipe_tables.synced_index = None;
|
||||
if self.recipe_tables.synced.take().is_some() {
|
||||
self.recipe_tables.tables = None;
|
||||
self.forget_book();
|
||||
@@ -467,11 +481,18 @@ impl Game<'_> {
|
||||
let of = |v: &serde_json::Value| types.iter().any(|t| v["type"].as_str() == Some(t));
|
||||
let mut all: Vec<_> = own.into_iter().filter(|(_, v)| !of(v)).collect();
|
||||
all.extend(received);
|
||||
// The server numbers the displays over these same recipes: a
|
||||
// world's data pack recipes shift the numbers of every recipe
|
||||
// after them, and the book must agree (the server's entries go
|
||||
// over its own by number).
|
||||
let index = horizon_client::recipe_book::RecipeIndex::load_from_sources(&all);
|
||||
horizon_client::crafting::Recipes::from_sources(root, self.items(), &overlay, all)
|
||||
.map(|recipes| (recipes, index))
|
||||
});
|
||||
match built {
|
||||
Ok(recipes) => {
|
||||
Ok((recipes, index)) => {
|
||||
self.recipe_tables.synced = Some(recipes);
|
||||
self.recipe_tables.synced_index = Some(index);
|
||||
// What was made of the window's own recipes is made again.
|
||||
self.recipe_tables.tables = None;
|
||||
self.forget_book();
|
||||
|
||||
@@ -702,7 +702,7 @@ impl Game<'_> {
|
||||
host.hub
|
||||
.chat_filter
|
||||
.as_ref()
|
||||
.is_none_or(|g| !g.filter.matches(text)),
|
||||
.is_none_or(|g| !g.matches(text)),
|
||||
)
|
||||
}
|
||||
/// A system line in every player's chat and the server's log.
|
||||
@@ -761,6 +761,30 @@ impl Game<'_> {
|
||||
.map(|lan| lan.hub.lan.shared.motd())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
/// Whether the dedicated server runs in online mode (`online-mode` of server.properties):
|
||||
/// its players sign in with their accounts. None off a dedicated server and before it listens.
|
||||
/// API @API:server_settings@.
|
||||
pub(super) fn native_online_mode(&self) -> Option<bool> {
|
||||
Some(online_mode_of(&self.dedicated_lan()?.shared.config))
|
||||
}
|
||||
/// The port the dedicated server listens on: `server-port` of server.properties, or the
|
||||
/// `--port` it was started with. None off a dedicated server and before it listens.
|
||||
/// API @API:server_settings@.
|
||||
pub(super) fn native_server_port(&self) -> Option<u16> {
|
||||
Some(self.dedicated_lan()?.port)
|
||||
}
|
||||
/// How the dedicated server takes its players from a proxy: `"velocity"` when it signs the
|
||||
/// forwarded player information with `forwarding.secret` (offline mode behind Velocity),
|
||||
/// `"none"` when it doesn't; None off a dedicated server and before it listens. The secret
|
||||
/// itself is never returned. API @API:server_settings@.
|
||||
pub(super) fn native_proxy_forwarding(&self) -> Option<&'static str> {
|
||||
Some(proxy_forwarding_of(&self.dedicated_lan()?.shared.config))
|
||||
}
|
||||
/// The listening socket of a dedicated server; None on any other side and before it listens.
|
||||
fn dedicated_lan(&self) -> Option<&horizon_client::lan::Lan> {
|
||||
self.options.dedicated.as_ref()?;
|
||||
Some(&self.lan.as_ref()?.hub.lan)
|
||||
}
|
||||
/// Whether a player has played on the open world before: their player
|
||||
/// data file is there (`players/data/<uuid>.dat`). False when no world
|
||||
/// is open. API @API:join_leave_helpers@.
|
||||
@@ -1511,6 +1535,22 @@ const DEFAULT_WORLDS: [(&str, &str); 3] = [
|
||||
("world_the_end", "minecraft:the_end"),
|
||||
];
|
||||
|
||||
/// The online mode a dedicated server's network config carries: it has an
|
||||
/// `OnlineMode` key (and so signs its logins) only in online mode.
|
||||
fn online_mode_of(config: &horizon_client::lan::NetConfig) -> bool {
|
||||
config.online.is_some()
|
||||
}
|
||||
|
||||
/// The proxy forwarding a dedicated server's network config carries: the secret
|
||||
/// is set only offline behind a proxy (game/dedicated.rs `forwarding_secret`).
|
||||
fn proxy_forwarding_of(config: &horizon_client::lan::NetConfig) -> &'static str {
|
||||
if config.forwarding_secret.is_some() {
|
||||
"velocity"
|
||||
} else {
|
||||
"none"
|
||||
}
|
||||
}
|
||||
|
||||
fn dimension_of_world(world: &str) -> Option<&'static str> {
|
||||
DEFAULT_WORLDS
|
||||
.iter()
|
||||
@@ -2136,4 +2176,22 @@ mod tests {
|
||||
assert_eq!(world_of_dimension("minecraft:overworld"), Some("world"));
|
||||
assert_eq!(world_of_dimension("world"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_settings_follow_the_network_config() {
|
||||
use horizon_client::lan::{NetConfig, OnlineMode};
|
||||
let mut config = NetConfig::loopback(25565, String::new(), 20);
|
||||
// online-mode=false, no proxy: no key, no forwarding secret.
|
||||
assert!(!online_mode_of(&config));
|
||||
assert_eq!(proxy_forwarding_of(&config), "none");
|
||||
// online-mode=false behind Velocity: the secret is set.
|
||||
config.forwarding_secret = Some(b"secret".to_vec());
|
||||
assert!(!online_mode_of(&config));
|
||||
assert_eq!(proxy_forwarding_of(&config), "velocity");
|
||||
// online-mode=true: the server's key, and forwarding stays off.
|
||||
config.forwarding_secret = None;
|
||||
config.online = Some(std::sync::Arc::new(OnlineMode::generate(false).unwrap()));
|
||||
assert!(online_mode_of(&config));
|
||||
assert_eq!(proxy_forwarding_of(&config), "none");
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ use std::{
|
||||
};
|
||||
|
||||
use horizon_client::text::Text;
|
||||
use horizon_shader_api::{Api, Blend, Manifest, Pack, check};
|
||||
use horizon_shader_api::{Api, Blend, Manifest, Pack, Primaries, check};
|
||||
|
||||
use super::Game;
|
||||
|
||||
@@ -202,6 +202,12 @@ impl Game<'_> {
|
||||
/// (docs/design/hdr-linear-light.md, steps 3 and 4) isn't served yet. 0.2 itself is drawn
|
||||
/// under `HORIZON_HDR_FRAME=float` (`switch`).
|
||||
fn not_drawn(m: &Manifest, switch: bool) -> Option<String> {
|
||||
if m.primaries == Primaries::Bt2020 {
|
||||
return Some(
|
||||
"[frame] primaries = \"bt2020\" isn't served yet: leave it out, or set \"bt709\""
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
if m.blend == Blend::Linear {
|
||||
return Some(
|
||||
"[frame] blend = \"linear\" isn't served yet: leave it out, or set \"gamma\"".into(),
|
||||
@@ -354,6 +360,14 @@ mod tests {
|
||||
assert_eq!(not_drawn(&manifest("0.1", ""), switch), None);
|
||||
let why = not_drawn(&manifest("0.2", "[frame]\nblend = \"linear\"\n"), switch);
|
||||
assert!(why.unwrap().contains("blend = \"linear\" isn't served yet"));
|
||||
let wide = "[frame]\nblend = \"linear\"\nprimaries = \"bt2020\"\n";
|
||||
let why = not_drawn(&manifest("0.2", wide), switch).unwrap();
|
||||
assert!(
|
||||
why.contains("primaries = \"bt2020\" isn't served yet"),
|
||||
"{why}"
|
||||
);
|
||||
let named = manifest("0.2", "[frame]\nprimaries = \"bt709\"\n");
|
||||
assert_eq!(not_drawn(&named, true), None);
|
||||
}
|
||||
let why = not_drawn(&manifest("0.2", ""), false).unwrap();
|
||||
assert!(
|
||||
|
||||
@@ -237,6 +237,69 @@ impl Game<'_> {
|
||||
any
|
||||
}
|
||||
|
||||
/// The vehicle the entity `entity` (a server player or a mob) sits on, by entity id, as
|
||||
/// Bukkit's `Entity.getVehicle` reads it: a boat, a minecart or a mob. None when it rides
|
||||
/// nothing, is not reached (see `native_rider`: inside another player's hook only the acting
|
||||
/// player and mobs are), and on a client. Read-only. API @API:riding_getters@.
|
||||
pub(super) fn native_vehicle_of(&self, entity: u32) -> Option<u32> {
|
||||
if !self.native_serves() {
|
||||
return None;
|
||||
}
|
||||
match self.native_rider(entity)? {
|
||||
Target::Player => self.riding,
|
||||
Target::Guest(conn) => {
|
||||
self.lan
|
||||
.as_ref()?
|
||||
.hub
|
||||
.guests
|
||||
.iter()
|
||||
.find(|g| g.conn == conn)?
|
||||
.vehicle
|
||||
}
|
||||
Target::Mob(id) => self.mobs.iter().find(|m| m.id == id)?.extra.mount,
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The entity ids of the server players and mobs riding the vehicle `entity` (a boat, a
|
||||
/// minecart or a mob), in vanilla's `getPassengers` order: the acting player, then the hub's
|
||||
/// guests in the level, then the mobs in the level's order, so the first is the controlling
|
||||
/// passenger where a player rides. Empty when nothing rides it, when it is no vehicle Horizon
|
||||
/// seats riders on, and on a client. Only what `native_rider` reaches: inside another
|
||||
/// player's hook the hub's guests are not listed. Read-only. API @API:riding_getters@.
|
||||
pub(super) fn native_passengers_of(&self, entity: u32) -> Vec<u32> {
|
||||
if !self.native_serves() || self.native_vehicle_target(entity).is_none() {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut riders = Vec::new();
|
||||
if self.lan_acting.is_some() && self.riding == Some(entity) {
|
||||
riders.push(self.native_acting_entity());
|
||||
}
|
||||
if self.lan_acting.is_none()
|
||||
&& let Some(host) = &self.lan
|
||||
{
|
||||
riders.extend(
|
||||
host.hub
|
||||
.guests
|
||||
.iter()
|
||||
.filter(|g| {
|
||||
g.placed
|
||||
&& !g.dead
|
||||
&& g.vehicle == Some(entity)
|
||||
&& g.dimension == self.dim.id()
|
||||
})
|
||||
.map(|g| g.entity_id as u32),
|
||||
);
|
||||
}
|
||||
riders.extend(
|
||||
self.mobs
|
||||
.iter()
|
||||
.filter(|m| m.extra.mount == Some(entity) && !m.removed && m.alive())
|
||||
.map(|m| m.id),
|
||||
);
|
||||
riders
|
||||
}
|
||||
|
||||
/// The rider an entity id names in the level in place: the acting player, a server player
|
||||
/// out of their turn (only while nobody's is swapped in), or a living mob.
|
||||
fn native_rider(&self, entity: u32) -> Option<Target> {
|
||||
@@ -391,3 +454,113 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::*;
|
||||
use horizon_client::mod_api::NativeAuthority;
|
||||
use horizon_world::nbt::Compound;
|
||||
|
||||
fn game() -> Game<'static> {
|
||||
let root = std::path::PathBuf::from(
|
||||
std::env::var_os("HORIZON_TEST_RESOURCES")
|
||||
.expect("set HORIZON_TEST_RESOURCES to prepared resources"),
|
||||
);
|
||||
let settings = Settings::default();
|
||||
let loaded = load_resources(
|
||||
root.clone(),
|
||||
None,
|
||||
&root,
|
||||
&settings,
|
||||
std::time::Instant::now(),
|
||||
false,
|
||||
true,
|
||||
&Progress::new(),
|
||||
&mut |_, _| unreachable!(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
|
||||
options.resources = root.clone();
|
||||
options.execution_role = execution_role::ExecutionRole::IntegratedServer;
|
||||
options.dedicated = Some(Vec::new());
|
||||
let res: &'static _ = Box::leak(Box::new(loaded.resources));
|
||||
let mut game = Game::new(
|
||||
res,
|
||||
loaded.pack,
|
||||
loaded.gui.unwrap(),
|
||||
options,
|
||||
settings,
|
||||
Audio::silent(&root),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
game.build_registries();
|
||||
game.set_allow_commands(true);
|
||||
game.native_lifecycle
|
||||
.begin_open(NativeAuthority::IntegratedServer);
|
||||
game.world.chunks.insert(
|
||||
(0, 0),
|
||||
std::sync::Arc::new(horizon_client::chunk::Chunk::empty(0, 0)),
|
||||
);
|
||||
game.block_ticks
|
||||
.add_empty_chunk(horizon_core::position::ChunkPos::new(0, 0))
|
||||
.unwrap();
|
||||
game
|
||||
}
|
||||
|
||||
const AT: [f64; 3] = [4.5, 200.0, 4.5];
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_mob_on_a_mob_reads_as_mounted_and_clears_after_a_dismount() {
|
||||
let mut game = game();
|
||||
let dim = game.dim.id();
|
||||
let pig = game
|
||||
.native_summon(dim, "minecraft:pig", AT, Compound::new())
|
||||
.unwrap();
|
||||
let chicken = game
|
||||
.native_summon(dim, "minecraft:chicken", AT, Compound::new())
|
||||
.unwrap();
|
||||
assert_eq!(game.native_vehicle_of(chicken), None);
|
||||
assert!(game.native_passengers_of(pig).is_empty());
|
||||
assert!(game.native_mount(chicken, pig));
|
||||
assert_eq!(game.native_vehicle_of(chicken), Some(pig));
|
||||
assert_eq!(game.native_passengers_of(pig), vec![chicken]);
|
||||
assert_eq!(game.native_vehicle_of(pig), None);
|
||||
assert!(game.native_passengers_of(chicken).is_empty());
|
||||
assert!(game.native_dismount(chicken));
|
||||
assert_eq!(game.native_vehicle_of(chicken), None);
|
||||
assert!(game.native_passengers_of(pig).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_mob_in_a_boat_is_listed_by_the_boat() {
|
||||
let mut game = game();
|
||||
let dim = game.dim.id();
|
||||
let boat = game
|
||||
.native_summon(dim, "minecraft:oak_boat", AT, Compound::new())
|
||||
.unwrap();
|
||||
let cow = game
|
||||
.native_summon(dim, "minecraft:cow", AT, Compound::new())
|
||||
.unwrap();
|
||||
assert!(game.native_mount(cow, boat));
|
||||
assert_eq!(game.native_vehicle_of(cow), Some(boat));
|
||||
assert_eq!(game.native_passengers_of(boat), vec![cow]);
|
||||
assert_eq!(game.native_vehicle_of(boat), None);
|
||||
assert!(game.native_passengers_of(cow).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn an_unknown_entity_reads_as_nothing() {
|
||||
let mut game = game();
|
||||
let dim = game.dim.id();
|
||||
let boat = game
|
||||
.native_summon(dim, "minecraft:oak_boat", AT, Compound::new())
|
||||
.unwrap();
|
||||
assert_eq!(game.native_vehicle_of(u32::MAX), None);
|
||||
assert!(game.native_passengers_of(u32::MAX).is_empty());
|
||||
assert!(game.native_passengers_of(boat).is_empty());
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@
|
||||
//! A server keeps one level per dimension. Entity portal processes and
|
||||
//! cooldowns follow their level; shared `Arrival`s hold each traveller's
|
||||
//! saved compound until its destination chunks are loaded.
|
||||
use super::entity::{Entity as _, entity_lists_of, for_each_entity};
|
||||
use super::entity::{Entity as _, entity_lists_of, for_each_entity, with_entity};
|
||||
use super::{Dim, Game, Result, Screen, level_flags};
|
||||
use horizon_client::fast_hash::FxHashMap;
|
||||
use horizon_client::nether_portal::{ENTITY_DELAY, PortalState};
|
||||
@@ -269,6 +269,16 @@ fn arrival_from_tag(a: &Tag) -> Option<Arrival> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Keep an explicitly captured counter. Old archives without a positive counter
|
||||
/// retain the historical fallback; their migration needs separate evidence.
|
||||
fn preserve_arrival_cooldown(tag: &mut Tag) {
|
||||
if let Tag::Compound(fields) = tag
|
||||
&& !matches!(fields.get(&"PortalCooldown".into()), Some(Tag::Int(delay)) if *delay > 0)
|
||||
{
|
||||
fields.insert("PortalCooldown".into(), Tag::Int(ENTITY_DELAY));
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// The world border's extent.
|
||||
pub(super) fn border_bounds(&self) -> BorderBounds {
|
||||
@@ -630,15 +640,15 @@ impl Game<'_> {
|
||||
/// at the start of their tick: an item or mob that stayed in a portal
|
||||
/// goes through (transition time 0) and waits for its dimension.
|
||||
pub(super) fn handle_entity_portals(&mut self) {
|
||||
self.handle_portals(false);
|
||||
self.handle_portals(false, false);
|
||||
}
|
||||
/// `Entity.handlePortal` of the boats and minecarts, which `tick_boats`
|
||||
/// and `tick_carts` move: it runs before them, from the position they
|
||||
/// ended the last tick at, as the start of a vehicle's own tick does.
|
||||
pub(super) fn handle_vehicle_portals(&mut self) {
|
||||
self.handle_portals(true);
|
||||
self.handle_portals(true, false);
|
||||
}
|
||||
fn handle_portals(&mut self, vehicles: bool) {
|
||||
fn handle_portals(&mut self, vehicles: bool, after_move: bool) {
|
||||
if self.remote.is_some() {
|
||||
return;
|
||||
}
|
||||
@@ -653,51 +663,47 @@ impl Game<'_> {
|
||||
if vehicles {
|
||||
before.clear();
|
||||
}
|
||||
let mut ids: Vec<(u32, bool)> = Vec::new();
|
||||
// ServerLevel.tick ticks only the entities in entity-ticking chunks,
|
||||
// and handlePortal runs in their tick: past the simulation distance an
|
||||
// entity's portal time and cooldown wait, kept as they are.
|
||||
let mut ids: Vec<(u32, bool, i32)> = Vec::new();
|
||||
// Portal handling runs only in entity-ticking chunks; retain frozen
|
||||
// states while their entities are outside the simulation distance.
|
||||
let entity_ticking = self.entity_ticking_area();
|
||||
let mut waiting: Vec<u32> = Vec::new();
|
||||
let tracked = &self.portals.entities;
|
||||
let mut present = std::collections::HashSet::new();
|
||||
for_each_entity!(entity_lists_of!(self), entity => {
|
||||
let can_sweep = entity.sweeps_portals();
|
||||
let has_portal_state = tracked.contains_key(&entity.id());
|
||||
if has_portal_state {
|
||||
present.insert(entity.id());
|
||||
}
|
||||
// Entity.processPortalCooldown still runs for entities this
|
||||
// portal pass cannot send, such as an ender pearl.
|
||||
if entity.is_vehicle() == vehicles && (can_sweep || has_portal_state) {
|
||||
if entity.is_vehicle() == vehicles
|
||||
&& entity.portal_after_move() == after_move
|
||||
&& (can_sweep || has_portal_state)
|
||||
{
|
||||
let at = entity.position();
|
||||
if !entity_ticking(at.x.floor() as i32, at.z.floor() as i32) {
|
||||
if has_portal_state {
|
||||
waiting.push(entity.id());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if can_sweep && entity.portal_from().is_none() {
|
||||
before.insert(entity.id(), entity.position());
|
||||
}
|
||||
if has_portal_state {
|
||||
ids.push((entity.id(), can_sweep && entity.can_use_portal(self)));
|
||||
ids.push((
|
||||
entity.id(),
|
||||
can_sweep && entity.can_use_portal(self),
|
||||
entity.dimension_changing_delay(),
|
||||
));
|
||||
}
|
||||
}
|
||||
});
|
||||
self.portals.before = before;
|
||||
let present: std::collections::HashSet<u32> = ids
|
||||
.iter()
|
||||
.map(|(id, _)| *id)
|
||||
.chain(waiting.iter().copied())
|
||||
.collect();
|
||||
// Each pass keeps the other group's states.
|
||||
let (boats, carts) = (&self.boats, &self.carts);
|
||||
self.portals.entities.retain(|id, _| {
|
||||
let vehicle = boats.iter().any(|b| b.id == *id) || carts.iter().any(|c| c.id == *id);
|
||||
present.contains(id) || vehicle != vehicles
|
||||
});
|
||||
for (id, allowed) in ids {
|
||||
self.portals.entities.retain(|id, _| present.contains(id));
|
||||
for (id, allowed, delay) in ids {
|
||||
let Some(state) = self.portals.entities.get_mut(&id) else {
|
||||
continue;
|
||||
};
|
||||
if let Some(entry) = state.handle(allowed, 0, ENTITY_DELAY) {
|
||||
if let Some(entry) = state.handle(allowed, 0, delay) {
|
||||
leaving.push((id, entry));
|
||||
}
|
||||
if state.cooldown == 0 && state.process.is_none() {
|
||||
@@ -739,6 +745,16 @@ impl Game<'_> {
|
||||
);
|
||||
}
|
||||
}
|
||||
/// The live entity's `getDimensionChangingDelay`; unknown ids use Entity's default.
|
||||
pub(super) fn entity_portal_delay(&self, id: u32) -> i32 {
|
||||
self.entity_handles()
|
||||
.find_map(|handle| {
|
||||
with_entity!(self, handle, entity => {
|
||||
(entity.id() == id).then(|| entity.dimension_changing_delay())
|
||||
})
|
||||
})
|
||||
.unwrap_or(ENTITY_DELAY)
|
||||
}
|
||||
/// `Entity.saveWithoutId`: the modeled non-arrow projectile codecs
|
||||
/// share the live cooldown between command reads and chunk writes.
|
||||
pub(super) fn save_projectile_portal_cooldown(
|
||||
@@ -760,21 +776,23 @@ impl Game<'_> {
|
||||
/// `Entity.setAsInsidePortal` on an entity that is still on its portal
|
||||
/// cooldown, whatever the portal: the cooldown restarts and it stays.
|
||||
pub(super) fn restart_portal_cooldown(&mut self, id: u32) -> bool {
|
||||
let delay = self.entity_portal_delay(id);
|
||||
match self.portals.entities.get_mut(&id) {
|
||||
Some(state) if state.cooldown > 0 => {
|
||||
state.cooldown = ENTITY_DELAY;
|
||||
state.cooldown = delay;
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
/// `Entity.setPortalCooldown` on an entity that has just spawned in a
|
||||
/// portal: it stands in the portal for 300 ticks without going through.
|
||||
/// portal: it waits for its entity-specific cooldown without going through.
|
||||
pub(super) fn set_portal_cooldown(&mut self, id: u32) {
|
||||
let delay = self.entity_portal_delay(id);
|
||||
self.portals.entities.insert(
|
||||
id,
|
||||
PortalState {
|
||||
cooldown: ENTITY_DELAY,
|
||||
cooldown: delay,
|
||||
process: None,
|
||||
},
|
||||
);
|
||||
@@ -844,6 +862,10 @@ impl Game<'_> {
|
||||
/// transition time is 0 too).
|
||||
pub(super) fn find_entity_portals(&mut self) {
|
||||
self.find_portals(None);
|
||||
// AbstractArrow, ThrowableProjectile and AbstractHurtingProjectile apply
|
||||
// block effects before calling Entity.tick, so their refreshed cooldown
|
||||
// is decremented (or their new portal handled) in this same tick.
|
||||
self.handle_portals(false, true);
|
||||
}
|
||||
/// The same for the vehicles server players ride, once `tick_guests` has
|
||||
/// moved them: a ridden vehicle moves with its rider, after the other
|
||||
@@ -878,7 +900,7 @@ impl Game<'_> {
|
||||
{
|
||||
super::lan_entities::assign_entity_id(&mut self.next_entity, &mut r.id);
|
||||
}
|
||||
let mut inside: Vec<(u32, BlockPos)> = Vec::new();
|
||||
let mut inside: Vec<(u32, BlockPos, i32)> = Vec::new();
|
||||
let mut portal_sections = std::mem::take(&mut self.portals.sections);
|
||||
// No loaded section has a portal state in its palette (nearly always): no
|
||||
// sweep below can meet a portal block, so the walk would find nothing.
|
||||
@@ -916,16 +938,16 @@ impl Game<'_> {
|
||||
continue;
|
||||
};
|
||||
if let Some(pos) = self.swept_portal(from, to, width, height, &mut portal_sections) {
|
||||
inside.push((id, pos));
|
||||
inside.push((id, pos, entity.dimension_changing_delay()));
|
||||
}
|
||||
});
|
||||
self.portals.sections = portal_sections;
|
||||
for (id, pos) in inside {
|
||||
for (id, pos, delay) in inside {
|
||||
self.portals
|
||||
.entities
|
||||
.entry(id)
|
||||
.or_default()
|
||||
.set_inside(pos, ENTITY_DELAY);
|
||||
.set_inside(pos, delay);
|
||||
}
|
||||
}
|
||||
/// The first nether or End portal block an entity box swept through
|
||||
@@ -1034,6 +1056,7 @@ impl Game<'_> {
|
||||
/// entity is saved and removed, and waits for its dimension to load.
|
||||
fn send_through_portal(&mut self, id: u32, entry: BlockPos) {
|
||||
let arrivals = self.portals.arrivals.len();
|
||||
let delay = self.entity_portal_delay(id);
|
||||
if let Some(i) = self.dropped.iter().position(|e| e.id == id) {
|
||||
let e = &self.dropped[i];
|
||||
let plan = self.entity_plan(entry, e.position, 0.25, 0.25, false);
|
||||
@@ -1203,6 +1226,11 @@ impl Game<'_> {
|
||||
self.portals.dirty = true;
|
||||
}
|
||||
if self.portals.arrivals.len() > arrivals {
|
||||
if let Some(arrival) = self.portals.arrivals.last_mut()
|
||||
&& let Tag::Compound(tag) = &mut arrival.tag
|
||||
{
|
||||
tag.insert("PortalCooldown".into(), Tag::Int(delay));
|
||||
}
|
||||
self.native_entity_portal(self.dim.id(), id, [entry.x, entry.y, entry.z]);
|
||||
}
|
||||
}
|
||||
@@ -1301,9 +1329,8 @@ impl Game<'_> {
|
||||
"Rotation".into(),
|
||||
Tag::List(vec![Tag::Float(yaw.to_bits()), Tag::Float(pitch.to_bits())]),
|
||||
);
|
||||
// setPortalCooldown before the teleport, copied by restoreFrom.
|
||||
c.insert("PortalCooldown".into(), Tag::Int(ENTITY_DELAY));
|
||||
}
|
||||
preserve_arrival_cooldown(&mut tag);
|
||||
// ServerLevel.addDuringTeleport: a fresh add, not loaded from disk.
|
||||
super::persist::mark_fresh(&mut tag);
|
||||
let key = ((at.x.floor() as i32) >> 4, (at.z.floor() as i32) >> 4);
|
||||
@@ -1613,6 +1640,27 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_arrivals_keep_explicit_positive_counters_without_type_migration() {
|
||||
for old in [None, Some(0), Some(-1), Some(5)] {
|
||||
let mut tag = Tag::compound([("id", Tag::String("minecraft:oak_boat".into()))]);
|
||||
if let (Some(old), Tag::Compound(fields)) = (old, &mut tag) {
|
||||
fields.insert("PortalCooldown".into(), Tag::Int(old));
|
||||
}
|
||||
preserve_arrival_cooldown(&mut tag);
|
||||
let arrival = Arrival {
|
||||
plan: Plan::end_portal(Dim::Overworld, BlockPos::new(0, 64, 0), 1.375, 0.5625),
|
||||
tag,
|
||||
};
|
||||
let restored = arrival_from_tag(&arrival_tag(&arrival)).unwrap();
|
||||
let expected = old.filter(|n| *n > 0).unwrap_or(ENTITY_DELAY);
|
||||
assert_eq!(
|
||||
restored.tag.get("PortalCooldown"),
|
||||
Some(&Tag::Int(expected))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn waiting_arrivals_keep_their_end_destination_when_saved() {
|
||||
let tag = Tag::compound([("id", Tag::String("minecraft:cow".into()))]);
|
||||
@@ -1638,3 +1686,7 @@ mod tests {
|
||||
assert!(!arrival_from_tag(&old).unwrap().plan.end);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "portal_cooldown_tests.rs"]
|
||||
mod cooldown_tests;
|
||||
@@ -749,7 +749,7 @@ impl Game<'_> {
|
||||
pub(super) fn hold_fullbright(&mut self) {
|
||||
let held = fullbright_held(
|
||||
self.remote.as_ref().map(|r| r.save_folder.is_some()),
|
||||
self.studio_replay.is_some(),
|
||||
self.replay.playing(),
|
||||
);
|
||||
self.settings.fullbright_held = held;
|
||||
if let Some(r) = self.renderer.as_mut() {
|
||||
|
||||
@@ -420,6 +420,7 @@ impl Game<'_> {
|
||||
self.random_tick_sections.clear();
|
||||
self.portals.sections.clear();
|
||||
self.options.resources = root.clone();
|
||||
self.falling_vertices.get_mut().clear();
|
||||
self.pack = pack;
|
||||
self.gui = gui;
|
||||
// The new atlas's skin and map slots are blank: players and maps
|
||||
|
||||
@@ -45,8 +45,9 @@ fn playing(test: impl FnOnce(&mut Game)) {
|
||||
test(&mut game);
|
||||
}
|
||||
|
||||
/// Hold `button` on the pad, as a poll that saw it go down would.
|
||||
fn pad_press(game: &mut Game, button: Button, action: &str) {
|
||||
/// Hold `button` on the pad, as a poll that saw it go down would: the press
|
||||
/// is recorded as `pad_button` records it, which is what `holds_action` reads.
|
||||
fn pad_press(game: &mut Game, button: Button, action: &'static str) {
|
||||
let mut state = PadState::default();
|
||||
// A trigger is held by how far it's pulled, not by a button bit.
|
||||
match button {
|
||||
@@ -55,6 +56,7 @@ fn pad_press(game: &mut Game, button: Button, action: &str) {
|
||||
_ => state.set(button, true),
|
||||
}
|
||||
game.pad.edges.update(&state, 0.4);
|
||||
game.pad.actions[button as usize] = vec![action];
|
||||
game.pad_action(action, true).unwrap();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
//! `PhantomSpawner.tick` tests the player's position with `Level.canSeeSky`,
|
||||
//! "sky light 15 here" (`BlockAndLightGetter.canSeeSky`), not a scan for a
|
||||
//! full collision cube above. Glass and stained glass are full collision cubes
|
||||
//! the sky light passes, so phantoms spawn under them; stone, water (which
|
||||
//! damps the light) and a bottom slab (whose up face occludes the sky-light
|
||||
//! column) do not. The world is lit by the vanilla light engine, not by hand.
|
||||
use super::super::*;
|
||||
use horizon_client::chunk::Chunk;
|
||||
use horizon_client::light::{LightTables, apply_lighting, light_chunk};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// The prepared resources, or `None` where CI has none (the test then skips).
|
||||
fn resources() -> Option<std::path::PathBuf> {
|
||||
let root = std::env::var_os("HORIZON_TEST_RESOURCES")
|
||||
.map(std::path::PathBuf::from)
|
||||
.unwrap_or_else(|| {
|
||||
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION)
|
||||
});
|
||||
(root.join("occlusion.nbt").is_file() && root.join("entity-types.json").is_file())
|
||||
.then_some(root)
|
||||
}
|
||||
|
||||
/// A server's game on `root`: hard difficulty, a long-inhabited chunk, the
|
||||
/// player at (2.5, 99, 2.5), midnight (sky darken 11) and a fixed level seed.
|
||||
fn game(root: &std::path::Path) -> Game<'static> {
|
||||
let settings = Settings::default();
|
||||
let loaded = load_resources(
|
||||
root.to_path_buf(),
|
||||
None,
|
||||
root,
|
||||
&settings,
|
||||
std::time::Instant::now(),
|
||||
false,
|
||||
true,
|
||||
&Progress::new(),
|
||||
&mut |_, _| unreachable!(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
|
||||
options.resources = root.to_path_buf();
|
||||
options.execution_role = execution_role::ExecutionRole::IntegratedServer;
|
||||
let res: &'static _ = Box::leak(Box::new(loaded.resources));
|
||||
let mut game = Game::new(
|
||||
res,
|
||||
loaded.pack,
|
||||
loaded.gui.unwrap(),
|
||||
options,
|
||||
settings,
|
||||
Audio::silent(root),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
game.build_registries();
|
||||
game.set_allow_commands(true);
|
||||
assert!(game.run_command("difficulty hard"));
|
||||
for x in -1..=0 {
|
||||
for z in -1..=0 {
|
||||
game.world
|
||||
.chunks
|
||||
.insert((x, z), Arc::new(Chunk::empty(x, z)));
|
||||
}
|
||||
}
|
||||
// `getCurrentDifficultyAt`: an old chunk is harder, so every attempt
|
||||
// passes `isHarderThan(random * 3)` and the seed decides only the spawn.
|
||||
game.world.inhabited.insert((0, 0), 3_600_000);
|
||||
game.world.day_time = 18000;
|
||||
game.player = Some(PlayerMotion::new(game.motion, Vec3::new(2.5, 99.0, 2.5)).unwrap());
|
||||
game.stats.custom("time_since_rest", i32::MAX);
|
||||
game.world.random.set_seed(0x5EED);
|
||||
game
|
||||
}
|
||||
|
||||
/// The sky light under a 5×5 roof of `roof` at y=100 (`None`: open sky), lit by
|
||||
/// `tables`, and the phantoms 64 spawn attempts make for the player under it.
|
||||
fn attempt(game: &mut Game<'_>, tables: &LightTables, roof: Option<&str>) -> (i32, Vec<Vec3>) {
|
||||
let mut chunk = Chunk::empty(0, 0);
|
||||
if let Some(roof) = roof {
|
||||
let state = game.pack.assets.registry.default_state(roof).unwrap().0;
|
||||
for x in 0..5 {
|
||||
for z in 0..5 {
|
||||
chunk.set(x, 100, z, state);
|
||||
}
|
||||
}
|
||||
}
|
||||
let region = [
|
||||
[None, None, None],
|
||||
[None, Some(&chunk), None],
|
||||
[None, None, None],
|
||||
];
|
||||
let lights = light_chunk(®ion, tables, true);
|
||||
apply_lighting(&mut chunk, lights);
|
||||
game.world.chunks.insert((0, 0), Arc::new(chunk));
|
||||
game.mobs.clear();
|
||||
let mut spawned = Vec::new();
|
||||
// `random.nextInt(timeSinceRest) >= 72000` almost always passes at
|
||||
// `i32::MAX`; 64 attempts leave no room for the draw to decide the test.
|
||||
for _ in 0..64 {
|
||||
game.stats.phantom_tick = 0;
|
||||
game.spawn_phantoms();
|
||||
spawned = game
|
||||
.mobs
|
||||
.iter()
|
||||
.filter(|m| m.species() == horizon_client::mobs::Species::Phantom)
|
||||
.map(|m| m.position)
|
||||
.collect();
|
||||
if !spawned.is_empty() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
(i32::from(game.world.light(2, 99, 2) >> 4), spawned)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn phantoms_spawn_under_a_glass_roof() {
|
||||
let Some(root) = resources() else { return };
|
||||
let mut game = game(&root);
|
||||
let tables = LightTables::load(&root, game.pack.assets.registry, game.res.facts()).unwrap();
|
||||
|
||||
// Open night sky.
|
||||
let (sky, phantoms) = attempt(&mut game, &tables, None);
|
||||
assert_eq!(sky, 15, "open sky light");
|
||||
assert!(!phantoms.is_empty(), "open sky spawns phantoms");
|
||||
|
||||
// Full collision cubes the sky light still passes.
|
||||
for roof in ["minecraft:glass", "minecraft:red_stained_glass"] {
|
||||
let (sky, phantoms) = attempt(&mut game, &tables, Some(roof));
|
||||
assert_eq!(sky, 15, "{roof} passes the sky light down");
|
||||
assert!(!phantoms.is_empty(), "phantoms spawn under {roof}");
|
||||
// `playerPos.above(20 + nextInt(15)).east(-10 + nextInt(21))...`
|
||||
for at in phantoms {
|
||||
assert!(
|
||||
(119.0..=133.0).contains(&at.y) && (at.x - 2.0).abs() <= 10.5,
|
||||
"{at:?} is not in the spawn box above the player"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_phantoms_under_a_roof_the_sky_light_does_not_reach() {
|
||||
let Some(root) = resources() else { return };
|
||||
let mut game = game(&root);
|
||||
let tables = LightTables::load(&root, game.pack.assets.registry, game.res.facts()).unwrap();
|
||||
|
||||
// Stone stops the light; water and ice damp it by a level; a bottom slab
|
||||
// is no full cube yet its up face occludes the sky-light column.
|
||||
for roof in [
|
||||
"minecraft:stone",
|
||||
"minecraft:water",
|
||||
"minecraft:ice",
|
||||
"minecraft:stone_slab",
|
||||
] {
|
||||
let (sky, phantoms) = attempt(&mut game, &tables, Some(roof));
|
||||
assert!(sky < 15, "{roof} keeps the sky light out, got {sky}");
|
||||
assert!(phantoms.is_empty(), "no phantom under {roof}");
|
||||
}
|
||||
}
|
||||
@@ -79,6 +79,7 @@ impl Game<'_> {
|
||||
/// TNT duper's TNT stops at the block that has not moved yet (#11399).
|
||||
pub(super) fn push_piston_entities(&mut self, pushes: Vec<Push>) {
|
||||
let mut accumulators = std::collections::HashMap::new();
|
||||
let mut pricked = Vec::new();
|
||||
// Where each moving piston first sits in both lists, so a clock of a
|
||||
// thousand pistons doesn't scan them a thousand times a tick.
|
||||
let mut index = PistonIndex::default();
|
||||
@@ -92,9 +93,58 @@ impl Game<'_> {
|
||||
let targets = self.piston_push_targets(&pushes, &steps);
|
||||
for push in pushes {
|
||||
let pos = push.0;
|
||||
self.push_for_piston(push, &targets, &steps, &mut accumulators);
|
||||
self.push_for_piston(push, &targets, &steps, &mut accumulators, &mut pricked);
|
||||
self.advance_piston_collision(pos, &index);
|
||||
}
|
||||
self.hurt_cactus_pricked_vehicles(pricked);
|
||||
}
|
||||
|
||||
/// `moveEntityByPiston`'s `applyEffectsFromBlocks` for a boat or a
|
||||
/// minecart: every cactus its box touches on the way from `start` to
|
||||
/// `end` pricks it (`CactusBlock.entityInside`, `Entity.checkInsideBlocks`
|
||||
/// over the box less 1e-5), even when the push moved it nowhere. The hurt
|
||||
/// waits for the end of the tick's pushes, whose targets are found first.
|
||||
fn piston_cactus_contact(
|
||||
&self,
|
||||
target: Target,
|
||||
start: Aabb,
|
||||
end: Aabb,
|
||||
pricked: &mut Vec<u32>,
|
||||
) {
|
||||
let id = match target {
|
||||
Target::Entity(EntityHandle::Boat(i)) => self.boats[i].id,
|
||||
Target::Entity(EntityHandle::Cart(i)) => self.carts[i].id,
|
||||
_ => return,
|
||||
};
|
||||
let eps = f64::from(1.0e-5_f32);
|
||||
let (a, b) = (start.min_position(), end.min_position());
|
||||
let lo = Vec3::new(a.x.min(b.x), a.y.min(b.y), a.z.min(b.z));
|
||||
let (a, b) = (start.max_position(), end.max_position());
|
||||
let hi = Vec3::new(a.x.max(b.x), a.y.max(b.y), a.z.max(b.z));
|
||||
let facts = self.res.facts();
|
||||
for x in (lo.x + eps).floor() as i32..=(hi.x - eps).floor() as i32 {
|
||||
for y in (lo.y + eps).floor() as i32..=(hi.y - eps).floor() as i32 {
|
||||
for z in (lo.z + eps).floor() as i32..=(hi.z - eps).floor() as i32 {
|
||||
if &*facts.get(self.world.block(x, y, z)).name == "minecraft:cactus" {
|
||||
pricked.push(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `Entity.hurt(cactus, 1)` for the vehicles `piston_cactus_contact`
|
||||
/// found, in the order of their pushes (`VehicleEntity.hurtServer`: one
|
||||
/// more point of damage; a vehicle a piston pushes in a chunk that does
|
||||
/// not tick entities never wears it off, so the fifth breaks it).
|
||||
fn hurt_cactus_pricked_vehicles(&mut self, pricked: Vec<u32>) {
|
||||
for id in pricked {
|
||||
if let Some(i) = self.boats.iter().position(|b| b.id == id) {
|
||||
self.hurt_boat(i, 1.0, false);
|
||||
} else if let Some(i) = self.carts.iter().position(|c| c.id == id) {
|
||||
self.hurt_cart(i, 1.0, false, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The bodies any of this tick's moving blocks can reach, in
|
||||
@@ -373,6 +423,7 @@ impl Game<'_> {
|
||||
targets: &[Target],
|
||||
steps: &StepHeights,
|
||||
accumulators: &mut std::collections::HashMap<Target, PistonMovement>,
|
||||
pricked: &mut Vec<u32>,
|
||||
) {
|
||||
let delta_progress = f64::from(new_progress - piston.progress);
|
||||
if delta_progress <= 0.0 {
|
||||
@@ -459,6 +510,7 @@ impl Game<'_> {
|
||||
step.scale(amount),
|
||||
movement,
|
||||
accumulators,
|
||||
pricked,
|
||||
);
|
||||
// `fixEntityWithinPistonBase`: a retracting source piston drags
|
||||
// an entity that is still inside its own block.
|
||||
@@ -482,6 +534,7 @@ impl Game<'_> {
|
||||
back.scale(fix),
|
||||
movement,
|
||||
accumulators,
|
||||
pricked,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -510,6 +563,7 @@ impl Game<'_> {
|
||||
piston_push::step_vector(movement).scale(delta_progress),
|
||||
movement,
|
||||
accumulators,
|
||||
pricked,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -669,6 +723,7 @@ impl Game<'_> {
|
||||
movement: Vec3,
|
||||
piston_direction: Direction,
|
||||
accumulators: &mut std::collections::HashMap<Target, PistonMovement>,
|
||||
pricked: &mut Vec<u32>,
|
||||
) {
|
||||
let Some(limited) = self.limit_piston(target, movement, accumulators) else {
|
||||
return;
|
||||
@@ -686,6 +741,7 @@ impl Game<'_> {
|
||||
self.projectile_piston_block_effects(i, at);
|
||||
}
|
||||
self.piston_block_triggers(before, before);
|
||||
self.piston_cactus_contact(target, before, before, pricked);
|
||||
self.release_after_block_move(target);
|
||||
return;
|
||||
}
|
||||
@@ -787,6 +843,9 @@ impl Game<'_> {
|
||||
if let Some((i, from, ..)) = tnt {
|
||||
self.finish_tnt_piston_move(i, from, resolved);
|
||||
}
|
||||
if let Target::Entity(EntityHandle::Falling(i)) = target {
|
||||
self.finish_falling_piston_move(i, resolved);
|
||||
}
|
||||
if let Some((i, from)) = projectile_from {
|
||||
self.projectile_piston_block_effects(i, from);
|
||||
}
|
||||
@@ -796,6 +855,7 @@ impl Game<'_> {
|
||||
before
|
||||
};
|
||||
self.piston_block_triggers(before, after);
|
||||
self.piston_cactus_contact(target, before, after, pricked);
|
||||
}
|
||||
|
||||
/// `Entity.restituteMovementAfterCollisions` after a block's push, for a
|
||||
@@ -1571,6 +1631,7 @@ mod h255_mob_piston_ground_tests {
|
||||
movement,
|
||||
Direction::East,
|
||||
&mut std::collections::HashMap::new(),
|
||||
&mut Vec::new(),
|
||||
);
|
||||
}
|
||||
/// `AbstractArrow.move` after a push the collisions cut to nothing: a stuck
|
||||
@@ -1606,6 +1667,7 @@ mod h255_mob_piston_ground_tests {
|
||||
Vec3::new(0.3, 0.0, 0.0),
|
||||
Direction::East,
|
||||
&mut std::collections::HashMap::new(),
|
||||
&mut Vec::new(),
|
||||
);
|
||||
let p = &game.creatures.projectiles[i];
|
||||
assert_eq!(p.position, before, "the stone stops the push");
|
||||
@@ -1643,6 +1705,7 @@ mod h255_mob_piston_ground_tests {
|
||||
movement,
|
||||
Direction::Down,
|
||||
budget,
|
||||
&mut Vec::new(),
|
||||
);
|
||||
};
|
||||
push(game, &mut budget, Vec3::ZERO);
|
||||
|
||||
@@ -58,6 +58,22 @@ impl Game<'_> {
|
||||
self.keys.remove(&key);
|
||||
}
|
||||
}
|
||||
pub(super) fn update_modifiers(&mut self, state: winit::keyboard::ModifiersState) {
|
||||
self.modifiers = state;
|
||||
horizon_client_input::modifiers::reconcile(
|
||||
self.modifiers,
|
||||
&mut self.physical_keys,
|
||||
&self.settings.keys,
|
||||
&mut self.toggles,
|
||||
[self.settings.toggle_crouch, self.settings.toggle_sprint],
|
||||
[
|
||||
self.pad.holds_action("key.sneak"),
|
||||
self.pad.holds_action("key.sprint"),
|
||||
],
|
||||
);
|
||||
self.sync_toggle(SNEAK);
|
||||
self.sync_toggle(SPRINT);
|
||||
}
|
||||
/// Let a held sneak, sprint or modifier key go when the keyboard reports
|
||||
/// its modifier up. A release does not always reach the mapping: a screen
|
||||
/// reads presses only, and an unfocused window is given no release at all,
|
||||
@@ -90,6 +106,16 @@ impl Game<'_> {
|
||||
self.physical_keys.remove(&key);
|
||||
}
|
||||
}
|
||||
if !self.modifiers.alt_key() {
|
||||
for key in [KeyCode::AltLeft, KeyCode::AltRight] {
|
||||
self.physical_keys.remove(&key);
|
||||
}
|
||||
}
|
||||
if !self.modifiers.super_key() {
|
||||
for key in [KeyCode::SuperLeft, KeyCode::SuperRight] {
|
||||
self.physical_keys.remove(&key);
|
||||
}
|
||||
}
|
||||
for (slot, mapping, needs_toggle) in [
|
||||
(0, "key.sneak", self.settings.toggle_crouch),
|
||||
(1, "key.sprint", self.settings.toggle_sprint),
|
||||
|
||||
@@ -111,12 +111,11 @@ impl Game<'_> {
|
||||
equipped.push((self.inventory[self.selected].as_ref(), "mainhand"));
|
||||
equipped.push((self.offhand.as_ref(), "offhand"));
|
||||
for (stack, slot) in equipped {
|
||||
let Some(info) = stack.and_then(|s| world_items!(self).get(&s.item)) else {
|
||||
let Some(stack) = stack else {
|
||||
continue;
|
||||
};
|
||||
for (attribute, id, amount, op, group) in &info.modifiers {
|
||||
// The attack code still reads the held item's attack damage and
|
||||
// speed directly; the map copy is what `/attribute` reports.
|
||||
let modifiers = world_items!(self).attribute_modifiers(stack);
|
||||
for (attribute, id, amount, op, group) in &modifiers {
|
||||
if !equipment_modifier_applies(group, slot) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -339,6 +339,7 @@ impl Game<'_> {
|
||||
Cursor::Arrow => winit::window::CursorIcon::Default,
|
||||
Cursor::PointingHand => winit::window::CursorIcon::Pointer,
|
||||
Cursor::NotAllowed => winit::window::CursorIcon::NotAllowed,
|
||||
Cursor::ResizeNs => winit::window::CursorIcon::NsResize,
|
||||
});
|
||||
}
|
||||
self.cursor_icon = effective;
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
//! Direct production handlers over an empty in-memory world; no server or renderer.
|
||||
use super::*;
|
||||
use crate::game::{Audio, Options, Progress, Settings, load_resources};
|
||||
use horizon_client::chunk::Chunk;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs explicit HORIZON_TEST_RESOURCES and HORIZON_PORTAL_REFERENCE"]
|
||||
fn portal_contact_and_waiting_handoff_match_vanilla_counters() {
|
||||
let reference: serde_json::Value = serde_json::from_str(
|
||||
&std::fs::read_to_string(std::env::var_os("HORIZON_PORTAL_REFERENCE").unwrap()).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
|
||||
let settings = Settings::default();
|
||||
let loaded = load_resources(
|
||||
root.clone(),
|
||||
None,
|
||||
&root,
|
||||
&settings,
|
||||
std::time::Instant::now(),
|
||||
false,
|
||||
true,
|
||||
&Progress::new(),
|
||||
&mut |_, _| unreachable!(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
|
||||
options.resources = root.clone();
|
||||
options.headless = true;
|
||||
options.game_dir = Some(std::env::current_dir().unwrap());
|
||||
options.execution_role = crate::game::execution_role::ExecutionRole::IntegratedServer;
|
||||
let mut game = Game::new(
|
||||
&loaded.resources,
|
||||
loaded.pack,
|
||||
loaded.gui.unwrap(),
|
||||
options,
|
||||
settings,
|
||||
Audio::silent(&root),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
game.build_registries();
|
||||
game.options.dedicated = Some(Vec::new());
|
||||
game.world = horizon_client::world::World::new(game.res.facts().air, 1);
|
||||
for z in -2..=2 {
|
||||
for x in -2..=2 {
|
||||
game.world
|
||||
.chunks
|
||||
.insert((x, z), Arc::new(Chunk::empty(x, z)));
|
||||
}
|
||||
}
|
||||
let entry = BlockPos::new(4, 210, 4);
|
||||
let portal = game
|
||||
.pack
|
||||
.assets
|
||||
.registry
|
||||
.default_state("minecraft:nether_portal")
|
||||
.unwrap();
|
||||
game.world.set(entry.x, entry.y, entry.z, portal).unwrap();
|
||||
let kinds = [
|
||||
"minecraft:oak_boat",
|
||||
"minecraft:minecart",
|
||||
"minecraft:arrow",
|
||||
"minecraft:pig",
|
||||
];
|
||||
let mut ids = Vec::new();
|
||||
for (index, kind) in kinds.iter().enumerate() {
|
||||
let uuid = [11318, 10, 0, index as i32];
|
||||
let tag = Tag::compound([
|
||||
("id", Tag::String((*kind).into())),
|
||||
("UUID", Tag::IntArray(uuid.to_vec())),
|
||||
("Pos", doubles([4.5, 210.0, 4.5])),
|
||||
("NoAI", Tag::Byte(1)),
|
||||
("NoGravity", Tag::Byte(1)),
|
||||
("PortalCooldown", Tag::Int(5)),
|
||||
]);
|
||||
game.load_listed(kind, Some(uuid), None, tag).unwrap();
|
||||
ids.push(game.next_entity);
|
||||
}
|
||||
assert_eq!(game.boats.len(), 1);
|
||||
assert_eq!(game.carts.len(), 1);
|
||||
assert_eq!(game.creatures.projectiles.len(), 1);
|
||||
assert_eq!(game.mobs.len(), 1);
|
||||
// Direct handler calls do not refresh the headless world's chunk tickets.
|
||||
// Frozen entities retain their counters across every group/phase pass.
|
||||
game.tickets
|
||||
.update(Some(crate::ticket_levels::Sources::default()));
|
||||
game.handle_vehicle_portals();
|
||||
game.handle_entity_portals();
|
||||
game.handle_portals(false, true);
|
||||
for id in &ids {
|
||||
assert_eq!(game.portals.entities.get(id).unwrap().cooldown, 5);
|
||||
}
|
||||
game.tickets.update(Some(crate::ticket_levels::Sources {
|
||||
forced: vec![(0, 0)],
|
||||
..Default::default()
|
||||
}));
|
||||
assert!(game.entity_ticking_area()(entry.x, entry.z));
|
||||
let mut runtime = Vec::new();
|
||||
for (step, ticks) in [1, 3, 1, 1, 8].into_iter().enumerate() {
|
||||
if step == 2 {
|
||||
game.world
|
||||
.set(entry.x, entry.y, entry.z, game.res.facts().air)
|
||||
.unwrap();
|
||||
}
|
||||
for _ in 0..ticks {
|
||||
game.handle_vehicle_portals();
|
||||
game.handle_entity_portals();
|
||||
game.find_entity_portals();
|
||||
}
|
||||
runtime.push(
|
||||
ids.iter()
|
||||
.map(|id| {
|
||||
game.portals
|
||||
.entities
|
||||
.get(id)
|
||||
.map_or(0, |state| state.cooldown)
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
);
|
||||
}
|
||||
// The actual departure handler emits a waiting compound before any destination is resolved.
|
||||
let end = game
|
||||
.pack
|
||||
.assets
|
||||
.registry
|
||||
.default_state("minecraft:end_portal")
|
||||
.unwrap();
|
||||
game.world.set(entry.x, entry.y, entry.z, end).unwrap();
|
||||
for id in &ids {
|
||||
game.portals.entities.insert(*id, PortalState::default());
|
||||
}
|
||||
game.find_entity_portals();
|
||||
game.handle_vehicle_portals();
|
||||
game.handle_entity_portals();
|
||||
assert_eq!(
|
||||
game.portals.arrivals.len(),
|
||||
4,
|
||||
"all four production travellers departed"
|
||||
);
|
||||
let mut handoff = Vec::new();
|
||||
for kind in kinds {
|
||||
let arrival = game
|
||||
.portals
|
||||
.arrivals
|
||||
.iter()
|
||||
.find(|a| horizon_client::save::text(a.tag.get("id")).as_deref() == Some(kind))
|
||||
.unwrap();
|
||||
let restored = arrival_from_tag(&arrival_tag(arrival)).unwrap();
|
||||
let counter = counter(&restored.tag);
|
||||
game.load_listed(kind, None, None, restored.tag).unwrap();
|
||||
let id = game.next_entity;
|
||||
assert_eq!(
|
||||
game.portals.entities.get(&id).map_or(0, |s| s.cooldown),
|
||||
counter
|
||||
);
|
||||
handoff.push(counter);
|
||||
}
|
||||
let report = serde_json::json!({"runtime":runtime,"handoff":handoff});
|
||||
if let Some(path) = std::env::var_os("HORIZON_PORTAL_REPORT") {
|
||||
std::fs::write(path, serde_json::to_vec_pretty(&report).unwrap()).unwrap();
|
||||
}
|
||||
for (field, expected) in [("runtime", "contact"), ("handoff", "delays")] {
|
||||
assert_eq!(report[field], reference[expected], "{field}");
|
||||
}
|
||||
}
|
||||
|
||||
fn counter(tag: &Tag) -> i32 {
|
||||
horizon_client::save::number(tag.get("PortalCooldown")).unwrap() as i32
|
||||
}
|
||||
@@ -344,8 +344,8 @@ impl Game<'_> {
|
||||
self.process_level_events()
|
||||
}
|
||||
|
||||
/// `PotentSulfurBlockEntity.LAUNCH_ENTITY_TICKER` on the server: mobs and
|
||||
/// items and primed TNT in the geyser's column with less than
|
||||
/// `PotentSulfurBlockEntity.LAUNCH_ENTITY_TICKER` on the server: mobs, items,
|
||||
/// primed TNT, projectiles, boats and minecarts in the geyser's column with less than
|
||||
/// `0.3 + 0.1 * water` upward speed get 0.2 more, unless they ride
|
||||
/// something or are of a type geysers do not move. Players are moved by
|
||||
/// their own client.
|
||||
@@ -408,6 +408,37 @@ impl Game<'_> {
|
||||
tnt.velocity.y += push;
|
||||
}
|
||||
}
|
||||
// Every entity in the column is launched (`getEntitiesOfClass(Entity.class)`):
|
||||
// a projectile in flight or at rest, a boat and a minecart too.
|
||||
for p in self
|
||||
.creatures
|
||||
.projectiles
|
||||
.iter_mut()
|
||||
.filter(|p| p.bounds().intersects(area))
|
||||
{
|
||||
if p.velocity.y < limit {
|
||||
p.velocity.y += push;
|
||||
p.needs_sync = true;
|
||||
}
|
||||
}
|
||||
for boat in self
|
||||
.boats
|
||||
.iter_mut()
|
||||
.filter(|b| b.bounds().intersects(area))
|
||||
{
|
||||
if boat.velocity.y < limit {
|
||||
boat.velocity.y += push;
|
||||
}
|
||||
}
|
||||
for cart in self
|
||||
.carts
|
||||
.iter_mut()
|
||||
.filter(|c| c.bounds().intersects(area))
|
||||
{
|
||||
if cart.velocity.y < limit {
|
||||
cart.velocity.y += push;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `PotentSulfurBlockEntity.LAUNCH_ENTITY_TICKER` on the client: the local
|
||||
|
||||
@@ -415,7 +415,7 @@ impl Game<'_> {
|
||||
/// a guest keeps their own while the host's are in `PotionState`.
|
||||
#[derive(Default)]
|
||||
pub(super) struct BodyPotions {
|
||||
applied: Vec<(&'static str, &'static str, f64, &'static str)>,
|
||||
pub(super) applied: Vec<(&'static str, &'static str, f64, &'static str)>,
|
||||
protection_for: Option<(u64, Vec<Option<ItemStack>>)>,
|
||||
enchant_modifiers: Vec<(String, String, f64, String)>,
|
||||
}
|
||||
@@ -426,7 +426,7 @@ pub(super) struct PotionState {
|
||||
thrown: Vec<ThrownPotion>,
|
||||
/// The area effect clouds a server tracks (game/remote_projectiles.rs).
|
||||
pub(super) clouds: Vec<super::remote_projectiles::RemoteCloud>,
|
||||
applied: Vec<(&'static str, &'static str, f64, &'static str)>,
|
||||
pub(super) applied: Vec<(&'static str, &'static str, f64, &'static str)>,
|
||||
/// Armor the protection table was computed for.
|
||||
protection_for: Option<(u64, Vec<Option<ItemStack>>)>,
|
||||
|
||||
@@ -3269,3 +3269,159 @@ mod depleted_absorption_tests {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod arrow_contents {
|
||||
//! Actual in-memory Game potion paths. No run loop, world load or server startup.
|
||||
use super::*;
|
||||
use crate::game::{Audio, Options, Progress, Settings, execution_role, load_resources};
|
||||
use horizon_client::chunk::Chunk;
|
||||
use serde_json::{Value, json};
|
||||
use std::{path::PathBuf, sync::Arc, time::Instant};
|
||||
|
||||
fn stack(row: &Value) -> ItemStack {
|
||||
let mut stack = ItemStack::new("minecraft:tipped_arrow", 1);
|
||||
if let Some(potion) = row["potion"].as_str() {
|
||||
stack = stack.with_potion(&format!("minecraft:{potion}"));
|
||||
}
|
||||
let effects = row["custom"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|e| {
|
||||
Tag::compound([
|
||||
(
|
||||
"id",
|
||||
Tag::String(
|
||||
format!("minecraft:{}", e["id"].as_str().unwrap())
|
||||
.as_str()
|
||||
.into(),
|
||||
),
|
||||
),
|
||||
("duration", Tag::Int(e["duration"].as_i64().unwrap() as i32)),
|
||||
("amplifier", Tag::Int(e["amp"].as_i64().unwrap() as i32)),
|
||||
(
|
||||
"ambient",
|
||||
Tag::Byte(i8::from(e["ambient"].as_bool().unwrap())),
|
||||
),
|
||||
(
|
||||
"show_particles",
|
||||
Tag::Byte(i8::from(e["visible"].as_bool().unwrap())),
|
||||
),
|
||||
])
|
||||
})
|
||||
.collect();
|
||||
stack
|
||||
.data_mut()
|
||||
.potion_extra
|
||||
.push(("custom_effects".into(), Tag::List(effects)));
|
||||
if let Some(color) = row["color"].as_i64() {
|
||||
stack
|
||||
.data_mut()
|
||||
.potion_extra
|
||||
.push(("custom_color".into(), Tag::Int(color as i32)));
|
||||
}
|
||||
if let Some(name) = row["name"].as_str() {
|
||||
stack
|
||||
.data_mut()
|
||||
.potion_extra
|
||||
.push(("custom_name".into(), Tag::String(name.into())));
|
||||
}
|
||||
stack
|
||||
}
|
||||
#[test]
|
||||
#[ignore = "explicit HORIZON_TEST_RESOURCES and isolated potion oracle input/output"]
|
||||
fn actual_arrow_contents_match_pinned_methods() {
|
||||
let root =
|
||||
PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").expect("explicit resources"));
|
||||
let input =
|
||||
PathBuf::from(std::env::var_os("HORIZON_POTION_INPUT").expect("explicit input"));
|
||||
let output =
|
||||
PathBuf::from(std::env::var_os("HORIZON_POTION_OUTPUT").expect("explicit output"));
|
||||
let expected: Vec<Value> = serde_json::from_slice(
|
||||
&std::fs::read(
|
||||
std::env::var_os("HORIZON_POTION_EXPECTED").expect("pinned method output"),
|
||||
)
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let rows: Vec<Value> = serde_json::from_slice(&std::fs::read(input).unwrap()).unwrap();
|
||||
let settings = Settings::default();
|
||||
let loaded = load_resources(
|
||||
root.clone(),
|
||||
None,
|
||||
&root,
|
||||
&settings,
|
||||
Instant::now(),
|
||||
false,
|
||||
true,
|
||||
&Progress::new(),
|
||||
&mut |_, _| unreachable!(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
|
||||
options.resources = root.clone();
|
||||
options.game_dir = Some(std::env::current_dir().unwrap());
|
||||
options.execution_role = execution_role::ExecutionRole::IntegratedServer;
|
||||
let mut game = Game::new(
|
||||
&loaded.resources,
|
||||
loaded.pack,
|
||||
loaded.gui.unwrap(),
|
||||
options,
|
||||
settings,
|
||||
Audio::silent(&root),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
game.world
|
||||
.chunks
|
||||
.insert((0, 0), Arc::new(Chunk::empty(0, 0)));
|
||||
let stone = game
|
||||
.pack
|
||||
.assets
|
||||
.registry
|
||||
.default_state("minecraft:stone")
|
||||
.unwrap();
|
||||
game.world.set(0, 64, 0, stone).unwrap();
|
||||
assert_eq!(rows.len(), 5, "all five arrow cases must execute");
|
||||
let mut results = Vec::new();
|
||||
for (index, row) in rows.iter().enumerate() {
|
||||
let id = row["id"].clone();
|
||||
let mut arrow = Projectile::new(
|
||||
ProjectileKind::Arrow {
|
||||
base_damage: 2.,
|
||||
critical: false,
|
||||
effect: None,
|
||||
},
|
||||
Vec3::new(0.5, 64.5, 0.5),
|
||||
None,
|
||||
);
|
||||
let st = arrow.arrow.as_mut().unwrap();
|
||||
st.pickup_item = stack(row);
|
||||
st.in_ground = true;
|
||||
st.in_ground_time = 598;
|
||||
st.last_state = Some(stone);
|
||||
arrow.stuck = true;
|
||||
let rain = |_| false;
|
||||
let scene = game.arrow_scene(&rain);
|
||||
horizon_client::arrows::tick(&mut arrow, &scene, &[]);
|
||||
let st = arrow.arrow.as_ref().unwrap();
|
||||
let item599 = st
|
||||
.pickup_item
|
||||
.item
|
||||
.trim_start_matches("minecraft:")
|
||||
.to_owned();
|
||||
let color599 = st.potion_color().map_or(-1, |v| v as i32);
|
||||
horizon_client::arrows::tick(&mut arrow, &scene, &[]);
|
||||
let st = arrow.arrow.as_ref().unwrap();
|
||||
results.push(json!({"id":id,"item599":item599,"color599":color599,"item600":st.pickup_item.item.trim_start_matches("minecraft:"),"color600":st.potion_color().map_or(-1,|v|v as i32)}));
|
||||
assert_eq!(
|
||||
results.last().unwrap(),
|
||||
&expected[index],
|
||||
"actual arrow tick: {}",
|
||||
id
|
||||
);
|
||||
}
|
||||
std::fs::write(output, serde_json::to_vec_pretty(&results).unwrap()).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -150,12 +150,6 @@ impl Game<'_> {
|
||||
fn excused(g: &Guest, world: &World, entities: &[EntityState], guests: &[Guest]) -> bool {
|
||||
let at = g.position;
|
||||
let slack = slack(std::array::from_fn(|i| at[i] - g.previous_position[i]));
|
||||
let close = |p: [f64; 3]| {
|
||||
let r = 3.0 + slack;
|
||||
(p[0] - at[0]).abs() <= r
|
||||
&& (p[2] - at[2]).abs() <= r
|
||||
&& (-3.0 - slack..=4.0 + slack).contains(&(p[1] - at[1]))
|
||||
};
|
||||
let unsent = world
|
||||
.block_log
|
||||
.iter()
|
||||
@@ -174,17 +168,152 @@ fn excused(g: &Guest, world: &World, entities: &[EntityState], guests: &[Guest])
|
||||
.motion
|
||||
.as_deref()
|
||||
.is_some_and(|m: &PlayerMotion| m.auto_spin_attack_ticks > 0);
|
||||
// Items and experience orbs neither push nor hold anyone up; the list holds the player itself.
|
||||
let entity = entities.iter().any(|e| {
|
||||
e.id != g.entity_id
|
||||
let entity = entity_near(entities, g.entity_id, at, slack);
|
||||
let player = guests.iter().any(|o| {
|
||||
o.conn != g.conn && o.placed && o.dimension == g.dimension && close(at, slack, o.position)
|
||||
});
|
||||
unsent || pistons || unloaded || riptide || entity || player
|
||||
}
|
||||
|
||||
/// Whether `p` is in the box around `at` that a player's moves are excused for.
|
||||
fn close(at: [f64; 3], slack: f64, p: [f64; 3]) -> bool {
|
||||
let r = 3.0 + slack;
|
||||
(p[0] - at[0]).abs() <= r
|
||||
&& (p[2] - at[2]).abs() <= r
|
||||
&& (-3.0 - slack..=4.0 + slack).contains(&(p[1] - at[1]))
|
||||
}
|
||||
|
||||
/// Whether an entity in `entities` other than the guest's own (`own`) is close to `at`. The
|
||||
/// distance test runs first: most entities are far, and the kind is a string compare. Items and
|
||||
/// experience orbs neither push nor hold anyone up; the list holds the player itself.
|
||||
fn entity_near(entities: &[EntityState], own: i32, at: [f64; 3], slack: f64) -> bool {
|
||||
entities.iter().any(|e| {
|
||||
close(at, slack, e.position)
|
||||
&& e.id != own
|
||||
&& !matches!(
|
||||
e.kind.as_str(),
|
||||
"minecraft:item" | "minecraft:experience_orb"
|
||||
)
|
||||
&& close(e.position)
|
||||
});
|
||||
let player = guests
|
||||
.iter()
|
||||
.any(|o| o.conn != g.conn && o.placed && o.dimension == g.dimension && close(o.position));
|
||||
unsent || pistons || unloaded || riptide || entity || player
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The scan `excused` ran over the entities before `entity_near`, verbatim.
|
||||
fn old_entity(entities: &[EntityState], own: i32, at: [f64; 3], slack: f64) -> bool {
|
||||
let close = |p: [f64; 3]| {
|
||||
let r = 3.0 + slack;
|
||||
(p[0] - at[0]).abs() <= r
|
||||
&& (p[2] - at[2]).abs() <= r
|
||||
&& (-3.0 - slack..=4.0 + slack).contains(&(p[1] - at[1]))
|
||||
};
|
||||
entities.iter().any(|e| {
|
||||
e.id != own
|
||||
&& !matches!(
|
||||
e.kind.as_str(),
|
||||
"minecraft:item" | "minecraft:experience_orb"
|
||||
)
|
||||
&& close(e.position)
|
||||
})
|
||||
}
|
||||
|
||||
/// xorshift64*: a fixed stream, so a failure reruns the same inputs.
|
||||
struct Rng(u64);
|
||||
|
||||
impl Rng {
|
||||
fn next_u64(&mut self) -> u64 {
|
||||
self.0 ^= self.0 >> 12;
|
||||
self.0 ^= self.0 << 25;
|
||||
self.0 ^= self.0 >> 27;
|
||||
self.0.wrapping_mul(0x2545_F491_4F6C_DD1D)
|
||||
}
|
||||
|
||||
fn below(&mut self, n: u64) -> u64 {
|
||||
self.next_u64() % n
|
||||
}
|
||||
|
||||
/// A multiple of 1/8 in `[-n/8, n/8]`: every edge of the box (multiples of 1/4) is exact.
|
||||
fn step(&mut self, n: i64) -> f64 {
|
||||
(self.below(2 * n as u64 + 1) as i64 - n) as f64 * 0.125
|
||||
}
|
||||
|
||||
/// An offset from the guest: half the time on or next to an edge of the box.
|
||||
fn offset(&mut self, edges: [f64; 4]) -> f64 {
|
||||
if self.below(2) == 0 {
|
||||
edges[self.below(4) as usize] + self.step(2)
|
||||
} else {
|
||||
self.step(160)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn entity(rng: &mut Rng, at: [f64; 3], slack: f64) -> EntityState {
|
||||
const KINDS: [&str; 4] = [
|
||||
"minecraft:item",
|
||||
"minecraft:experience_orb",
|
||||
"minecraft:zombie",
|
||||
"minecraft:pig",
|
||||
];
|
||||
let r = 3.0 + slack;
|
||||
let xz = [r, -r, r, -r];
|
||||
let y = [-3.0 - slack, 4.0 + slack, -3.0 - slack, 4.0 + slack];
|
||||
let off = [rng.offset(xz), rng.offset(y), rng.offset(xz)];
|
||||
EntityState {
|
||||
id: rng.below(6) as i32,
|
||||
kind: KINDS[rng.below(4) as usize].to_string(),
|
||||
position: [at[0] + off[0], at[1] + off[1], at[2] + off[2]],
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entity_near_matches_the_scan_it_replaced() {
|
||||
const SLACKS: [f64; 6] = [0.0, 0.25, 0.5, 1.5, 3.0, 16.0];
|
||||
let mut rng = Rng(0x9E37_79B9_7F4A_7C15);
|
||||
let (mut yes, mut no) = (0, 0);
|
||||
for seed in 0..2_000 {
|
||||
let slack = SLACKS[rng.below(6) as usize];
|
||||
let at = [rng.step(160), rng.step(160), rng.step(160)];
|
||||
let n = rng.below(40) as usize;
|
||||
let entities: Vec<EntityState> = (0..n).map(|_| entity(&mut rng, at, slack)).collect();
|
||||
for own in 0..6 {
|
||||
let new = entity_near(&entities, own, at, slack);
|
||||
let old = old_entity(&entities, own, at, slack);
|
||||
assert_eq!(new, old, "seed {seed}, own {own}, at {at:?}, slack {slack}");
|
||||
if new {
|
||||
yes += 1;
|
||||
} else {
|
||||
no += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(yes > 0 && no > 0, "the inputs must give both answers");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn box_edges_are_inclusive_and_own_and_drops_are_skipped() {
|
||||
let at = [0.0, 0.0, 0.0];
|
||||
let zombie = |id, position| EntityState {
|
||||
id,
|
||||
kind: "minecraft:zombie".to_string(),
|
||||
position,
|
||||
..Default::default()
|
||||
};
|
||||
let s = 0.5;
|
||||
assert!(entity_near(&[zombie(1, [3.5, 0.0, 0.0])], 0, at, s));
|
||||
assert!(!entity_near(&[zombie(1, [3.625, 0.0, 0.0])], 0, at, s));
|
||||
assert!(entity_near(&[zombie(1, [0.0, 4.5, 0.0])], 0, at, s));
|
||||
assert!(!entity_near(&[zombie(1, [0.0, 4.625, 0.0])], 0, at, s));
|
||||
assert!(entity_near(&[zombie(1, [0.0, -3.5, 0.0])], 0, at, s));
|
||||
assert!(!entity_near(&[zombie(1, [0.0, -3.625, 0.0])], 0, at, s));
|
||||
// The guest's own entity, and items and orbs, never excuse.
|
||||
assert!(!entity_near(&[zombie(7, [0.0, 0.0, 0.0])], 7, at, s));
|
||||
let item = EntityState {
|
||||
kind: "minecraft:item".to_string(),
|
||||
..zombie(2, [0.0, 0.0, 0.0])
|
||||
};
|
||||
assert!(!entity_near(&[item], 0, at, s));
|
||||
}
|
||||
}
|
||||
@@ -231,7 +231,7 @@ impl Game<'_> {
|
||||
return;
|
||||
}
|
||||
self.ensure_book();
|
||||
let index = &self.stations().recipe_index;
|
||||
let index = self.display_index();
|
||||
let book = self.recipe_ui.book.as_ref();
|
||||
// SmithingTrimDemoSlotDisplay draws from a random of its own.
|
||||
let mut random = random_source::entity();
|
||||
@@ -413,10 +413,15 @@ impl Game<'_> {
|
||||
r.builtin
|
||||
.get(registry)
|
||||
.and_then(|ids| ids.name(id))
|
||||
.or_else(|| r.registries.name(registry, id))
|
||||
.map(str::to_owned)
|
||||
};
|
||||
let item = |id: i32| name_of("minecraft:item", id);
|
||||
let category = |id: i32| name_of("minecraft:recipe_book_category", id);
|
||||
let category = |id: i32| {
|
||||
name_of("minecraft:recipe_book_category", id).or_else(|| {
|
||||
horizon_client::recipe_book::Category::from_id(id).map(|c| c.name().to_owned())
|
||||
})
|
||||
};
|
||||
let tag = |name: &str| items.item_tag_order.get(name).cloned().unwrap_or_default();
|
||||
book.use_server_entries(
|
||||
entries,
|
||||
@@ -430,7 +435,7 @@ impl Game<'_> {
|
||||
fn ensure_book(&mut self) {
|
||||
if self.recipe_ui.book.is_none() {
|
||||
let mut book = Book::build(self.recipes(), self.items());
|
||||
book.set_display_ids(&self.stations().recipe_index);
|
||||
book.set_display_ids(self.display_index());
|
||||
// A server numbers and describes its recipes itself.
|
||||
self.server_entries_over(&mut book, self.recipe_ui.server_entries.values());
|
||||
self.recipe_ui.book = Some(book);
|
||||
@@ -1368,6 +1373,11 @@ impl Game<'_> {
|
||||
p: horizon_protocol::packets::recipes::RecipeBookAdd,
|
||||
) {
|
||||
let ui = &mut self.recipe_ui;
|
||||
// `display_index`, borrowed by field beside the book.
|
||||
let index = match &self.recipe_tables.synced_index {
|
||||
Some(index) => index,
|
||||
None => &world_stations!(self).recipe_index,
|
||||
};
|
||||
if p.replace {
|
||||
ui.client.clear();
|
||||
ui.server_entries.clear();
|
||||
@@ -1380,7 +1390,7 @@ impl Game<'_> {
|
||||
ui.client.highlight.insert(e.id);
|
||||
}
|
||||
if e.flags & 1 != 0
|
||||
&& let Some(id) = world_stations!(self).recipe_index.recipe_of_display(e.id)
|
||||
&& let Some(id) = index.recipe_of_display(e.id)
|
||||
&& !toast.iter().any(|t| t == id)
|
||||
{
|
||||
toast.push(id.to_owned());
|
||||
|
||||
@@ -739,7 +739,7 @@ impl Game<'_> {
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
fn resolve_selector_text(&mut self, raw: &str) -> String {
|
||||
pub(super) fn resolve_selector_text(&mut self, raw: &str) -> String {
|
||||
let mut reader = horizon_client::command::Reader::new(raw);
|
||||
let ty = horizon_client::command::args::ArgType::Entity {
|
||||
single: false,
|
||||
|
||||
@@ -1101,6 +1101,23 @@ impl Game<'_> {
|
||||
let source = PositionSource::Block([key.0, key.1, key.2]);
|
||||
self.vibration_particle(source, info.pos, center(key), travel);
|
||||
}
|
||||
if let Some((info, travel)) = tick.resumed {
|
||||
let source = PositionSource::Block([key.0, key.1, key.2]);
|
||||
let dest = center(key);
|
||||
let initial = info.distance.floor() as i32;
|
||||
// `tryReloadVibrationParticle` works in doubles.
|
||||
let alpha = if initial > 0 {
|
||||
1.0 - f64::from(travel) / f64::from(initial)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let current_pos = [
|
||||
info.pos[0] + (dest[0] - info.pos[0]) * alpha,
|
||||
info.pos[1] + (dest[1] - info.pos[1]) * alpha,
|
||||
info.pos[2] + (dest[2] - info.pos[2]) * alpha,
|
||||
];
|
||||
self.vibration_particle(source, current_pos, dest, travel);
|
||||
}
|
||||
if let Some(info) = tick.received {
|
||||
self.sculk_receive(key, info)?;
|
||||
}
|
||||
|
||||
Loaded 100 of 254 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user